From 422245c13c2528889ec791ae1564f7574aaa2aa0 Mon Sep 17 00:00:00 2001 From: alyldas Date: Mon, 29 Jun 2026 17:32:46 +0300 Subject: [PATCH 1/5] Align phone scanner with shared hints --- README.md | 11 ++++-- docs/architecture.md | 11 ++++-- scripts/benchmark-phone.mjs | 29 +++++++++++++++- src/index.ts | 67 ++++++++++++++++++++++++++++++++++--- src/parser.ts | 2 +- src/ranges.ts | 6 +++- src/scanner.ts | 16 +++++++-- tests/scanner.spec.ts | 61 +++++++++++++++++++++++++++++++++ 8 files changed, 189 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 124b99f..133f3fb 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,12 @@ import { createPhoneScanner } from "@textfilters/phone"; const scanner = createPhoneScanner(); const codePoints = Array.from("call +1 202 555 0187"); const result = scanner.scan({ text: "call +1 202 555 0187", codePoints }); +const hasPhone = scanner.check({ text: "call +1 202 555 0187", codePoints }); + +scanner.scan({ text: "call +1 202 555 0187", codePoints }, (match) => { + console.log(match.range); + return false; +}); ``` The default shared instance is exported as `filter`. It has stable `name: "phone"`. @@ -68,8 +74,9 @@ False-positive guards keep date-like, time-like, coordinate-like, IP/server-like See [docs/architecture.md](docs/architecture.md) for the parser flow, module map, and change guide. -Run `npm run benchmark:phone` from this package to compare clean, long clean, -direct phone, phone-like, and late-match cases on the same machine. +Run `npm run benchmark:phone` from this package to compare scanner setup, +`check()`, clean, low-digit, direct phone, phone-like, and late-match cases on +the same machine. ## Related Textfilters Packages diff --git a/docs/architecture.md b/docs/architecture.md index f364c16..5cfc885 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,6 +21,10 @@ The public API is intentionally small: - `PhoneFilterConfig` currently supports `maskChar`. - `maskChar` is normalized by `@textfilters/core` before masking. - `createPhoneScanner()` exposes the same matching behavior as a range scanner. +- `createPhoneScanner().check(input)` returns a boolean without collecting all + ranges. +- `createPhoneScanner().scan(input, sink)` streams ranges and supports early + stop when the sink returns `false`. - `scanPhoneRanges(text)` returns code point ranges directly for callers that want to compose masking through `@textfilters/core`. @@ -80,9 +84,10 @@ with NFKC and lowercasing through `@textfilters/core`, then Unicode decimal digits are folded to ASCII in `digits.ts`. Before metadata creation, the public scanner checks for a cheap folded digit -count signal. Clearly clean text returns no ranges without candidate parsing; -numeric candidate text still runs through the same group validation and -false-positive guards as before. +count signal. Shared-style hints such as digit count, plus sign, punctuation, and +text length can reject low-digit text before candidate parsing. Clearly clean +text returns no ranges without candidate parsing; numeric candidate text still +runs through the same group validation and false-positive guards as before. The raw arrays and source code point arrays stay aligned: every source code point has exactly one metadata slot. Zero-width code points can be skipped by cursors diff --git a/scripts/benchmark-phone.mjs b/scripts/benchmark-phone.mjs index 1b15ed9..0da7799 100644 --- a/scripts/benchmark-phone.mjs +++ b/scripts/benchmark-phone.mjs @@ -1,11 +1,12 @@ import { performance } from "node:perf_hooks"; -import { createPhoneFilter } from "../dist/index.js"; +import { createPhoneFilter, createPhoneScanner } from "../dist/index.js"; const ITERATIONS = 1_000; const SETUP_ITERATIONS = 100; const SHORT_CLEAN = "Hello world"; const LONG_CLEAN = "The quick brown fox jumps over the lazy dog. ".repeat(50); +const LOW_DIGIT_TEXT = "Use code 12345 before Friday"; const DIRECT_PHONE = "Call +1 202 555 0187 for details"; const PHONE_LIKE = "Call 202.555.0187 for details"; const LATE_MATCH = @@ -38,11 +39,37 @@ function printResults(results) { } const filter = createPhoneFilter(); +const scanner = createPhoneScanner(); +const input = (text) => ({ text, codePoints: Array.from(text) }); +const hintedInput = (text) => ({ + text, + codePoints: Array.from(text), + hints: { + textLength: text.length, + digitCount: Array.from(text).filter((codePoint) => + /\p{Nd}/u.test(codePoint), + ).length, + hasPlus: text.includes("+"), + hasPunctuation: /[^\p{L}\p{N}\s]/u.test(text), + }, +}); printResults([ bench("createPhoneFilter()", () => createPhoneFilter(), SETUP_ITERATIONS), + bench("createPhoneScanner()", () => createPhoneScanner(), SETUP_ITERATIONS), + bench("check short clean", () => scanner.check(input(SHORT_CLEAN))), + bench("check hinted short clean", () => + scanner.check(hintedInput(SHORT_CLEAN)), + ), + bench("check low digit text", () => scanner.check(input(LOW_DIGIT_TEXT))), + bench("check hinted low digit text", () => + scanner.check(hintedInput(LOW_DIGIT_TEXT)), + ), + bench("check direct phone", () => scanner.check(input(DIRECT_PHONE))), + bench("check late-match phone", () => scanner.check(input(LATE_MATCH))), bench("censor short clean", () => filter.censor(SHORT_CLEAN)), bench("censor long clean", () => filter.censor(LONG_CLEAN)), + bench("censor low digit text", () => filter.censor(LOW_DIGIT_TEXT)), bench("censor direct phone", () => filter.censor(DIRECT_PHONE)), bench("censor phone-like", () => filter.censor(PHONE_LIKE)), bench("censor late-match phone", () => filter.censor(LATE_MATCH)), diff --git a/src/index.ts b/src/index.ts index 0f7bce7..4249a80 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,11 @@ import { type TextCodePointRange, } from "@textfilters/core"; import { toRawChar } from "./digits.js"; -import { collectRanges, createMeta } from "./parser.js"; +import { + collectCandidateRangeMatches, + collectRanges, + createMeta, +} from "./parser.js"; export interface PhoneFilterConfig { readonly maskChar?: string; @@ -23,27 +27,54 @@ export interface PhoneScannerConfig {} export interface PhoneScanInput { readonly text: string; readonly codePoints: readonly string[]; + readonly hints?: { + readonly textLength?: number; + readonly digitCount?: number; + readonly hasPlus?: boolean; + readonly hasPunctuation?: boolean; + }; } export interface PhoneRangeScanResult { readonly ranges: readonly TextCodePointRange[]; } +export interface PhoneRangeMatch { + readonly range: TextCodePointRange; +} + +export type PhoneRangeMatchSink = (match: PhoneRangeMatch) => boolean | void; + export interface PhoneRangeScanner { readonly name: typeof PHONE_FILTER_NAME; + check(input: PhoneScanInput): boolean; scan(input: PhoneScanInput): PhoneRangeScanResult; + scan(input: PhoneScanInput, sink: PhoneRangeMatchSink): boolean | void; } export function createPhoneScanner( _config: PhoneScannerConfig = {}, ): PhoneRangeScanner { - return { - name: PHONE_FILTER_NAME, - scan(input) { + function scan(input: PhoneScanInput): { + ranges: readonly TextCodePointRange[]; + }; + function scan(input: PhoneScanInput, sink: PhoneRangeMatchSink): boolean; + function scan(input: PhoneScanInput, sink?: PhoneRangeMatchSink) { + if (sink === undefined) { return { ranges: scanPhoneRanges(input.text), }; + } + + return scanPhoneRangeMatches(input, sink); + } + + return { + name: PHONE_FILTER_NAME, + check(input) { + return checkPhoneRanges(input); }, + scan, }; } @@ -55,6 +86,28 @@ export function scanPhoneRanges(text: unknown): readonly TextCodePointRange[] { return collectRanges(meta); } +export function checkPhoneRanges(input: PhoneScanInput): boolean { + if (!hasPhoneCandidateInput(input)) return false; + + const meta = createMeta(input.text); + let found = false; + collectCandidateRangeMatches(meta, () => { + found = true; + return false; + }); + return found; +} + +export function scanPhoneRangeMatches( + input: PhoneScanInput, + sink: PhoneRangeMatchSink, +): boolean { + if (!hasPhoneCandidateInput(input)) return true; + + const meta = createMeta(input.text); + return collectCandidateRangeMatches(meta, (range) => sink({ range })); +} + export function createPhoneFilter(config: PhoneFilterConfig = {}): PhoneFilter { const scanner = createPhoneScanner(); const maskChar = normalizeMaskChar(config.maskChar); @@ -87,3 +140,9 @@ function hasPhoneCandidate(source: string): boolean { return false; } + +function hasPhoneCandidateInput(input: PhoneScanInput): boolean { + const hints = input.hints; + if (hints?.digitCount !== undefined && hints.digitCount < 10) return false; + return hasPhoneCandidate(input.text); +} diff --git a/src/parser.ts b/src/parser.ts index c975c60..a7df075 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,2 +1,2 @@ export { createMeta } from "./meta.js"; -export { collectRanges } from "./ranges.js"; +export { collectCandidateRangeMatches, collectRanges } from "./ranges.js"; diff --git a/src/ranges.ts b/src/ranges.ts index cc7f1a9..821c83c 100644 --- a/src/ranges.ts +++ b/src/ranges.ts @@ -3,9 +3,13 @@ import { type TextCodePointRange, } from "@textfilters/core"; import { type TextMeta } from "./meta.js"; -import { collectCandidateRanges } from "./scanner.js"; +import { + collectCandidateRangeMatches, + collectCandidateRanges, +} from "./scanner.js"; export type CodePointRange = TextCodePointRange; +export { collectCandidateRangeMatches }; export const collectRanges = (meta: TextMeta): readonly CodePointRange[] => mergeCodePointRanges(collectCandidateRanges(meta)); diff --git a/src/scanner.ts b/src/scanner.ts index 5fed08c..6936559 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -415,6 +415,18 @@ export const collectCandidateRanges = ( meta: TextMeta, ): readonly CodePointRange[] => { const ranges: CodePointRange[] = []; + collectCandidateRangeMatches(meta, (range) => { + ranges.push(range); + }); + return ranges; +}; + +export type PhoneCandidateRangeSink = (range: CodePointRange) => boolean | void; + +export const collectCandidateRangeMatches = ( + meta: TextMeta, + sink: PhoneCandidateRangeSink, +): boolean => { for (let i = 0; i < meta.codePoints.length; i++) { const candidate = parsePhoneCandidate(meta, i); if (!candidate) continue; @@ -422,8 +434,8 @@ export const collectCandidateRanges = ( i = Math.max(i, candidate.rejectedUntil - 1); continue; } - ranges.push([candidate.start, candidate.end]); + if (sink([candidate.start, candidate.end]) === false) return false; i = Math.max(i, candidate.end - 1); } - return ranges; + return true; }; diff --git a/tests/scanner.spec.ts b/tests/scanner.spec.ts index f8b2de4..29f467c 100644 --- a/tests/scanner.spec.ts +++ b/tests/scanner.spec.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { + checkPhoneRanges, createPhoneScanner, + scanPhoneRangeMatches, scanPhoneRanges, PHONE_FILTER_NAME, } from "../src/index.js"; @@ -36,6 +38,65 @@ describe("@textfilters/phone scanner", () => { ).toEqual({ ranges: [] }); }); + it("checks phone candidates without collecting every range", () => { + const scanner = createPhoneScanner(); + const text = "call +1 202 555 0187 or +1 303 555 0199"; + const input = { text, codePoints: Array.from(text) }; + + expect(scanner.check(input)).toBe(true); + expect(checkPhoneRanges(input)).toBe(true); + expect(scanner.check({ text: "plain words only", codePoints: [] })).toBe( + false, + ); + }); + + it("streams ranges into a sink and supports early stop", () => { + const scanner = createPhoneScanner(); + const text = "call +1 202 555 0187 or +1 303 555 0199"; + const seen: Array = []; + + const completed = scanner.scan( + { text, codePoints: Array.from(text) }, + (match) => { + seen.push(match.range); + return false; + }, + ); + + expect(completed).toBe(false); + expect(seen).toEqual([[5, 20]]); + }); + + it("uses shared-style hints to skip low-digit text", () => { + expect( + checkPhoneRanges({ + text: "code 12345", + codePoints: Array.from("code 12345"), + hints: { + textLength: "code 12345".length, + digitCount: 5, + hasPlus: false, + hasPunctuation: false, + }, + }), + ).toBe(false); + }); + + it("streams separated, plus-prefixed, and punctuated ranges", () => { + const text = "call +1 (202) 555-0187, then 303.555.0199"; + const seen: Array = []; + + expect( + scanPhoneRangeMatches({ text, codePoints: Array.from(text) }, (match) => { + seen.push(match.range); + }), + ).toBe(true); + expect(seen).toEqual([ + [5, 22], + [29, 41], + ]); + }); + it("keeps false-positive guards behind the prefilter", () => { expect(scanPhoneRanges("server 10.100.100.100")).toEqual([]); expect(scanPhoneRanges("balance 1,234,567,890")).toEqual([]); From 29f2ddfe102a848ae8daf571918ca9d24d99c8cb Mon Sep 17 00:00:00 2001 From: alyldas Date: Mon, 29 Jun 2026 17:55:39 +0300 Subject: [PATCH 2/5] Preserve folded phone hint coverage --- src/index.ts | 2 -- tests/scanner.spec.ts | 25 +++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4249a80..2a30086 100644 --- a/src/index.ts +++ b/src/index.ts @@ -142,7 +142,5 @@ function hasPhoneCandidate(source: string): boolean { } function hasPhoneCandidateInput(input: PhoneScanInput): boolean { - const hints = input.hints; - if (hints?.digitCount !== undefined && hints.digitCount < 10) return false; return hasPhoneCandidate(input.text); } diff --git a/tests/scanner.spec.ts b/tests/scanner.spec.ts index 29f467c..8efe14a 100644 --- a/tests/scanner.spec.ts +++ b/tests/scanner.spec.ts @@ -82,6 +82,31 @@ describe("@textfilters/phone scanner", () => { ).toBe(false); }); + it("does not let non-folded digit hints hide foldable phone digits", () => { + const scanner = createPhoneScanner(); + const text = "call ⁰¹²³⁴⁵⁶⁷⁸⁹"; + const input = { + text, + codePoints: Array.from(text), + hints: { + textLength: text.length, + digitCount: 0, + hasPlus: false, + hasPunctuation: false, + }, + }; + const seen: Array = []; + + expect(scanner.check(input)).toBe(true); + expect( + scanner.scan(input, (match) => { + seen.push(match.range); + return false; + }), + ).toBe(false); + expect(seen).toEqual([[5, 15]]); + }); + it("streams separated, plus-prefixed, and punctuated ranges", () => { const text = "call +1 (202) 555-0187, then 303.555.0199"; const seen: Array = []; From aa7284f985666d3461fdbdf87c1b64604e9047d3 Mon Sep 17 00:00:00 2001 From: alyldas Date: Mon, 29 Jun 2026 18:21:22 +0300 Subject: [PATCH 3/5] Restore safe phone hint rejection --- src/index.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/index.ts b/src/index.ts index 2a30086..290f7b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,7 @@ export type PhoneRangeMatchSink = (match: PhoneRangeMatch) => boolean | void; export interface PhoneRangeScanner { readonly name: typeof PHONE_FILTER_NAME; + readonly allocationAware: true; check(input: PhoneScanInput): boolean; scan(input: PhoneScanInput): PhoneRangeScanResult; scan(input: PhoneScanInput, sink: PhoneRangeMatchSink): boolean | void; @@ -71,6 +72,7 @@ export function createPhoneScanner( return { name: PHONE_FILTER_NAME, + allocationAware: true, check(input) { return checkPhoneRanges(input); }, @@ -142,5 +144,22 @@ function hasPhoneCandidate(source: string): boolean { } function hasPhoneCandidateInput(input: PhoneScanInput): boolean { + const hints = input.hints; + if ( + hints?.digitCount !== undefined && + hints.digitCount < 10 && + hasAsciiOnly(input.text) + ) { + return false; + } + return hasPhoneCandidate(input.text); } + +function hasAsciiOnly(source: string): boolean { + for (let index = 0; index < source.length; index++) { + if (source.charCodeAt(index) > 0x7f) return false; + } + + return true; +} From 087e179c0bf8b6e7f97610519c25d09faeae6a6c Mon Sep 17 00:00:00 2001 From: alyldas Date: Mon, 29 Jun 2026 18:42:42 +0300 Subject: [PATCH 4/5] Preserve merged phone stream ranges --- src/index.ts | 5 ++++- tests/scanner.spec.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 290f7b1..98efc11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,7 +107,10 @@ export function scanPhoneRangeMatches( if (!hasPhoneCandidateInput(input)) return true; const meta = createMeta(input.text); - return collectCandidateRangeMatches(meta, (range) => sink({ range })); + for (const range of collectRanges(meta)) { + if (sink({ range }) === false) return false; + } + return true; } export function createPhoneFilter(config: PhoneFilterConfig = {}): PhoneFilter { diff --git a/tests/scanner.spec.ts b/tests/scanner.spec.ts index 8efe14a..d05115e 100644 --- a/tests/scanner.spec.ts +++ b/tests/scanner.spec.ts @@ -122,6 +122,20 @@ describe("@textfilters/phone scanner", () => { ]); }); + it("streams merged adjacent ranges", () => { + const text = "79991234567(79991234567)"; + const seen: Array = []; + + expect( + scanPhoneRangeMatches({ text, codePoints: Array.from(text) }, (match) => { + seen.push(match.range); + return false; + }), + ).toBe(false); + expect(scanPhoneRanges(text)).toEqual([[0, 24]]); + expect(seen).toEqual([[0, 24]]); + }); + it("keeps false-positive guards behind the prefilter", () => { expect(scanPhoneRanges("server 10.100.100.100")).toEqual([]); expect(scanPhoneRanges("balance 1,234,567,890")).toEqual([]); From 3815033337a5915b395b27a6a230c78aa535142a Mon Sep 17 00:00:00 2001 From: alyldas Date: Mon, 29 Jun 2026 18:51:14 +0300 Subject: [PATCH 5/5] Stream merged phone ranges incrementally --- src/index.ts | 30 ++++++++++++++++++++++++++---- tests/scanner.spec.ts | 4 ++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index 98efc11..e183276 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,10 +107,32 @@ export function scanPhoneRangeMatches( if (!hasPhoneCandidateInput(input)) return true; const meta = createMeta(input.text); - for (const range of collectRanges(meta)) { - if (sink({ range }) === false) return false; - } - return true; + let pending: TextCodePointRange | null = null; + let stopped = false; + + const completed = collectCandidateRangeMatches(meta, (range) => { + if (pending === null) { + pending = range; + return true; + } + + if (range[0] <= pending[1]) { + pending = [pending[0], Math.max(pending[1], range[1])]; + return true; + } + + if (sink({ range: pending }) === false) { + stopped = true; + return false; + } + + pending = range; + return true; + }); + + if (stopped) return false; + if (pending === null) return completed !== false; + return sink({ range: pending }) !== false && completed !== false; } export function createPhoneFilter(config: PhoneFilterConfig = {}): PhoneFilter { diff --git a/tests/scanner.spec.ts b/tests/scanner.spec.ts index d05115e..0171e40 100644 --- a/tests/scanner.spec.ts +++ b/tests/scanner.spec.ts @@ -123,7 +123,7 @@ describe("@textfilters/phone scanner", () => { }); it("streams merged adjacent ranges", () => { - const text = "79991234567(79991234567)"; + const text = "79991234567(79991234567) or 79991234568"; const seen: Array = []; expect( @@ -132,7 +132,7 @@ describe("@textfilters/phone scanner", () => { return false; }), ).toBe(false); - expect(scanPhoneRanges(text)).toEqual([[0, 24]]); + expect(scanPhoneRanges("79991234567(79991234567)")).toEqual([[0, 24]]); expect(seen).toEqual([[0, 24]]); });