Skip to content

Commit b776c5f

Browse files
authored
fix: per-group char classes in toRegex to prevent false positives (#76)
* fix: use per-group char classes in toRegex to prevent false positives toRegex() inferred a single character class for all positions from examples. For validators where the compact form mixes letters and digits in distinct positions (e.g., German SVNR "12010188M011"), this produced [A-Z0-9] for every group. The overly broad pattern matched all-caps prose like "OF NOVEMBER 6" as a valid candidate, consuming the span and preventing the correct date pattern from firing. Add inferPerGroupInfo() which derives per-group character classes from the formatted output. For SVNR, "12 010188 M 01 1" now produces \d{2} \d{6} [A-Z]{1} \d{2} \d{1} instead of [A-Z0-9] for all positions. Letter-only groups before the first digit group (format-prepended prefixes like "CHE") are still excluded. * fix: address review comments - Extract shared charClassFor helper; inferCharClass now delegates to it instead of duplicating the letter/digit scanning logic - Add regression tests for de.svnr: per-group char class matching and all-caps prose rejection
1 parent e6f4392 commit b776c5f

2 files changed

Lines changed: 138 additions & 11 deletions

File tree

__test__/patterns.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,29 @@ describe("toRegex", () => {
6969
expect(matches).toHaveLength(1);
7070
expect(matches[0]![0]).toBe("DE136695976");
7171
});
72+
73+
test("per-group char classes for mixed-position validators", () => {
74+
// de.svnr format: "12 010188 M 01 1"
75+
// Groups must use \d for digit positions and
76+
// [A-Z] for the letter.
77+
const { source } = toRegex(de.svnr);
78+
const test = (s: string) =>
79+
new RegExp(source, "g").test(s);
80+
expect(test("12 010188 M 01 1")).toBe(true);
81+
expect(test("12010188M011")).toBe(true);
82+
expect(test("12.010188.M.01.1")).toBe(true);
83+
});
84+
85+
test("de.svnr rejects all-caps prose (regression)", () => {
86+
// The old pattern used [A-Z0-9] for all positions,
87+
// which matched "OF NOVEMBER 6" as a candidate.
88+
const { source } = toRegex(de.svnr);
89+
const test = (s: string) =>
90+
new RegExp(source, "g").test(s);
91+
expect(test("OF NOVEMBER 6")).toBe(false);
92+
expect(test("AS OF NOVEMBER 6, 2024, BY")).toBe(false);
93+
expect(test("AMENDMENT DATED NOVEMBER 6")).toBe(false);
94+
});
7295
});
7396

7497
describe("toPatterns", () => {

src/patterns.ts

Lines changed: 115 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,23 @@ const inferPrefix = (v: Validator): string | null => {
127127
return null;
128128
};
129129

130+
/**
131+
* Character class for a string fragment:
132+
* `\d` if all digits, `[A-Z]` if all letters,
133+
* `[A-Z0-9]` if mixed.
134+
*/
135+
const charClassFor = (s: string): string => {
136+
let hasLetter = false;
137+
let hasDigit = false;
138+
for (const ch of s) {
139+
if (/[a-zA-Z]/.test(ch)) hasLetter = true;
140+
if (/\d/.test(ch)) hasDigit = true;
141+
}
142+
if (hasLetter && hasDigit) return "[A-Z0-9]";
143+
if (hasLetter) return "[A-Z]";
144+
return "\\d";
145+
};
146+
130147
/**
131148
* Determine the character class needed for the
132149
* compact form: `\d` if all digits, `[A-Z0-9]`
@@ -136,18 +153,82 @@ const inferCharClass = (v: Validator): string => {
136153
if (!v.examples || v.examples.length === 0) {
137154
return "\\d";
138155
}
139-
let hasLetter = false;
140-
let hasDigit = false;
141-
for (const ex of v.examples) {
142-
const c = v.compact(ex);
143-
for (const ch of c) {
144-
if (/[a-zA-Z]/.test(ch)) hasLetter = true;
145-
if (/\d/.test(ch)) hasDigit = true;
156+
const combined = v.examples
157+
.map((ex) => v.compact(ex))
158+
.join("");
159+
return charClassFor(combined);
160+
};
161+
162+
type PerGroupInfo = {
163+
sizes: number[];
164+
classes: string[];
165+
};
166+
167+
/**
168+
* Infer per-group sizes AND character classes from
169+
* format(). Returns groups with per-position char
170+
* classes, e.g., "12 010188 M 01 1" →
171+
* sizes: [2, 6, 1, 2, 1]
172+
* classes: ["\d", "\d", "[A-Z]", "\d", "\d"]
173+
*
174+
* Only returns non-null when per-group classes
175+
* differ (some groups are letters, some digits).
176+
* Letter-only groups before the first digit group
177+
* are treated as prefixes and excluded (handled
178+
* by inferPrefix separately).
179+
*/
180+
const inferPerGroupInfo = (
181+
v: Validator,
182+
): PerGroupInfo | null => {
183+
if (!v.examples || v.examples.length === 0) {
184+
return null;
185+
}
186+
const compact = v.compact(v.examples[0]!);
187+
const formatted = v.format(compact);
188+
if (formatted === compact) return null;
189+
190+
// Only applies to mixed validators (compact has
191+
// both letters and digits in separate positions,
192+
// like German SVNR "12010188M011").
193+
const compactMixed =
194+
/[a-zA-Z]/.test(compact) && /\d/.test(compact);
195+
if (!compactMixed) return null;
196+
197+
// Also skip if any single part mixes letters and
198+
// digits (IBAN, ISIN) — inferGroups already
199+
// handles those correctly with the global class.
200+
const parts = formatted.split(/[^a-zA-Z0-9]+/);
201+
const isAlphanumeric = parts.some(
202+
(p) => /[a-zA-Z]/.test(p) && /\d/.test(p),
203+
);
204+
if (isAlphanumeric) return null;
205+
206+
// Keep digit groups and letter-only groups that
207+
// appear AFTER the first digit group (embedded
208+
// letters). Skip letter-only groups before the
209+
// first digit group (prefix like "CHE").
210+
const filtered: string[] = [];
211+
let seenDigitGroup = false;
212+
for (const p of parts) {
213+
if (p.length === 0) continue;
214+
if (/\d/.test(p)) {
215+
seenDigitGroup = true;
216+
filtered.push(p);
217+
} else if (seenDigitGroup) {
218+
filtered.push(p);
146219
}
147220
}
148-
if (hasLetter && hasDigit) return "[A-Z0-9]";
149-
if (hasLetter) return "[A-Z]";
150-
return "\\d";
221+
222+
if (filtered.length <= 1) return null;
223+
224+
const classes = filtered.map(charClassFor);
225+
const allSame = classes.every((c) => c === classes[0]);
226+
if (allSame) return null;
227+
228+
return {
229+
sizes: filtered.map((p) => p.length),
230+
classes,
231+
};
151232
};
152233

153234
/**
@@ -159,6 +240,17 @@ const groupsToPattern = (
159240
cc: string,
160241
): string => groups.map((g) => `${cc}{${g}}`).join(SEP);
161242

243+
/**
244+
* Build a group regex with per-group char classes.
245+
* Produces tighter patterns when groups have different
246+
* character types (e.g., digits vs letters).
247+
*/
248+
const groupsToPatternPerClass = (
249+
groups: number[],
250+
classes: string[],
251+
): string =>
252+
groups.map((g, i) => `${classes[i]!}{${g}}`).join(SEP);
253+
162254
/**
163255
* Derive a loose candidate-matching regex from
164256
* a validator. The regex finds potential matches;
@@ -175,7 +267,19 @@ export const toRegex = (v: Validator): RegExp => {
175267

176268
let pattern: string;
177269

178-
if (groups && lengths.length <= 1) {
270+
// Use per-group char classes when groups mix
271+
// digits and letters (e.g., German SVNR:
272+
// "12 010188 M 01 1" → [\d, \d, [A-Z], \d, \d]).
273+
// This prevents overly broad [A-Z0-9] from
274+
// matching all-caps prose as identifiers.
275+
const perGroup = inferPerGroupInfo(v);
276+
277+
if (perGroup && lengths.length <= 1) {
278+
pattern = groupsToPatternPerClass(
279+
perGroup.sizes,
280+
perGroup.classes,
281+
);
282+
} else if (groups && lengths.length <= 1) {
179283
pattern = groupsToPattern(groups, cc);
180284
} else if (lengths.length === 1) {
181285
pattern = `${cc}{${lengths[0]}}`;

0 commit comments

Comments
 (0)