euredact 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,24 @@
1
1
  import { EntityType } from "../../types.js";
2
+ import { IBAN_LENGTHS } from "../validators.js";
3
+ /**
4
+ * Build a country-independent IBAN pattern from the ISO 13616 length table.
5
+ *
6
+ * An IBAN carries its own country code and a mod-97 checksum, so it is
7
+ * self-identifying and must never depend on the caller's `countries` setting —
8
+ * a Belgian account in an Austrian file is still an account.
9
+ *
10
+ * Each alternative pins the *exact* character count for its country, which
11
+ * stops the match absorbing a following word: without it, greedy matching
12
+ * turns `BE68 5390 0754 7034 KBC` into one over-long candidate that fails
13
+ * mod-97 and is lost entirely.
14
+ */
15
+ function buildIbanPattern() {
16
+ const alternatives = Object.entries(IBAN_LENGTHS)
17
+ .sort(([a], [b]) => a.localeCompare(b))
18
+ .map(([code, length]) => `${code}\\d{2}(?:[ \\-]?[A-Z0-9]){${length - 4}}`);
19
+ return String.raw `\b(?:` + alternatives.join("|") + String.raw `)\b`;
20
+ }
21
+ const GENERIC_IBAN = buildIbanPattern();
2
22
  /**
3
23
  * Helper to create a PatternDef with sensible defaults.
4
24
  */
@@ -6,6 +26,45 @@ function p(entityType, pattern, validator = null, description = "", contextKeywo
6
26
  return { entityType, pattern, validator, description, contextKeywords, requiresContext };
7
27
  }
8
28
  // ---------------------------------------------------------------------------
29
+ // Secret context keywords (consolidated, reused across SECRET patterns)
30
+ // ---------------------------------------------------------------------------
31
+ const SECRET_CONTEXT = [
32
+ // English
33
+ "key", "token", "secret", "password", "credential",
34
+ "api_key", "apikey", "api-key", "auth", "bearer",
35
+ "aws_secret_access_key", "access_key", "private_key",
36
+ "AES_KEY", "ENCRYPTION_KEY", "SIGNING_KEY",
37
+ "ACCOUNT_SID", "AUTH_TOKEN", "TWILIO",
38
+ "RAILS_SECRET", "SECRET_KEY_BASE", "DATABASE_URL",
39
+ "NPM_TOKEN", "DIGITALOCEAN", "GOOGLE_API",
40
+ "DB_PASS", "DB_PASSWORD", "DATASOURCE_PASSWORD",
41
+ "JWT_SIGNING", "passwd", "DISCORD_TOKEN",
42
+ // Dutch
43
+ "wachtwoord", "sleutel", "geheim",
44
+ // French
45
+ "mot de passe", "mot_de_passe", "clé", "jeton",
46
+ // German
47
+ "Passwort", "Kennwort", "Schlüssel", "Geheimnis",
48
+ // Italian
49
+ "chiave", "segreto",
50
+ // Spanish
51
+ "contraseña", "clave",
52
+ // Portuguese
53
+ "senha", "chave",
54
+ // Polish
55
+ "hasło", "klucz",
56
+ // Czech/Slovak
57
+ "heslo", "kľúč",
58
+ // Hungarian
59
+ "jelszó", "kulcs",
60
+ // Romanian
61
+ "parola",
62
+ // Nordic
63
+ "lösenord", "nøkkel", "nyckel", "avain", "lykill",
64
+ // Greek
65
+ "κωδικός",
66
+ ];
67
+ // ---------------------------------------------------------------------------
9
68
  // DOB context keywords (reused across SHARED patterns)
10
69
  // ---------------------------------------------------------------------------
11
70
  const DOB_CONTEXT = [
@@ -33,8 +92,28 @@ const SHARED = {
33
92
  code: "SHARED",
34
93
  name: "Shared EU Patterns",
35
94
  patterns: [
36
- p(EntityType.EMAIL, String.raw `\b[\w._%+\-]+@[\w.\-]+\.[a-zA-Z]{2,}\b`),
95
+ // JavaScript's \w and \b are ASCII-only, so the Python engine's \w (which
96
+ // is Unicode-aware) matched local parts like "petras_ž@..." while this one
97
+ // did not. \p{L}\p{N} with the `u` flag restores parity; the leading guard
98
+ // is a Unicode-safe word boundary.
99
+ p(EntityType.EMAIL, String.raw `(?<![\p{L}\p{N}._%+\-])[\p{L}\p{N}._%+\-]+@(?:[\p{L}\p{N}\-]+\.)+\p{L}{2,}\b`),
100
+ // IBAN (any country) — structure + mod-97 only. Country-specific IBAN
101
+ // patterns still exist and win on identical spans; this one guarantees an
102
+ // IBAN is never missed just because its country was not requested.
103
+ p(EntityType.BANK_ACCOUNT, GENERIC_IBAN, "iban", "IBAN — any country (ISO 13616, mod-97 validated)"),
104
+ // International phone (E.164, any country). Runs alongside the per-country
105
+ // patterns, which each hard-code a single grouping and miss the rest:
106
+ // "+43 664 8213 907" was invisible to the Austrian pattern, which expects
107
+ // an unbroken subscriber number. A leading "+" is self-identifying, so
108
+ // this is deliberately not country-gated.
109
+ p(EntityType.PHONE, String.raw `\+\d{1,3}(?:[\s\-/]?\(?\d{1,4}\)?){2,7}`, "e164", "International phone number (E.164) — any country"),
37
110
  p(EntityType.BIC, String.raw `\b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b`, "bic"),
111
+ // Space-separated form: "RZBA AT WW", "NICA BE BB". Written as a separate
112
+ // pattern requiring a space between *every* group, rather than making the
113
+ // spaces optional above — optional spaces would let "GEBABEBB KBC" match
114
+ // as one 11-character code and claim the following word. Only a literal
115
+ // space is allowed, so a match cannot span a line break.
116
+ p(EntityType.BIC, String.raw `\b[A-Z]{4} [A-Z]{2} [A-Z0-9]{2}(?: [A-Z0-9]{3})?\b`, "bic", "BIC/SWIFT code (space-separated groups)"),
38
117
  p(EntityType.CREDIT_CARD, String.raw `\b(?:4[0-9]{3}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}|5[1-5][0-9]{2}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}|3[47][0-9]{2}[\s\-]?[0-9]{6}[\s\-]?[0-9]{5})\b`, "luhn"),
39
118
  p(EntityType.VIN, String.raw `\b[A-HJ-NPR-Z0-9]{17}\b`, "vin"),
40
119
  p(EntityType.IP_ADDRESS, String.raw `\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`),
@@ -45,7 +124,7 @@ const SHARED = {
45
124
  p(EntityType.IMEI, String.raw `\b\d{2}[\-\s]\d{6}[\-\s]\d{6}[\-\s]\d\b`, "imei", "formatted"),
46
125
  p(EntityType.GPS_COORDINATES, String.raw `-?(?:[1-8]?\d(?:\.\d{4,})|90(?:\.0{4,}))\s*[,;/]\s*-?(?:1[0-7]\d(?:\.\d{4,})|180(?:\.0{4,})|\d{1,2}(?:\.\d{4,}))`),
47
126
  p(EntityType.UUID, String.raw `\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}\b`),
48
- p(EntityType.SOCIAL_HANDLE, String.raw `(?<!\w)@[a-zA-Z][a-zA-Z0-9_.]{1,29}\b`),
127
+ p(EntityType.SOCIAL_HANDLE, String.raw `(?<![\p{L}\p{N}_])@\p{L}[\p{L}\p{N}_.]{1,29}\b`),
49
128
  // --- Secret / API Key (known prefixes — always active) ---
50
129
  p(EntityType.SECRET, String.raw `\bAKIA[A-Z0-9]{16}\b`, null, "AWS Access Key ID"),
51
130
  p(EntityType.SECRET, String.raw `\bgh[poas]_[a-zA-Z0-9]{36,}\b`, null, "GitHub token"),
@@ -67,35 +146,18 @@ const SHARED = {
67
146
  p(EntityType.SECRET, String.raw `\bxapp-\d[\d\-]{20,}`, null, "Slack app numeric token"),
68
147
  p(EntityType.SECRET, String.raw `\bxapp-[a-zA-Z0-9\-]{20,}`, null, "Slack app token"),
69
148
  p(EntityType.SECRET, String.raw `\bdapi[a-f0-9]{32,}\b`, null, "Databricks PAT"),
149
+ // Discord bot token (3-segment base64 with numeric first segment)
150
+ p(EntityType.SECRET, String.raw `\b[MN][A-Za-z0-9]{23,}\.[A-Za-z0-9_\-]{6}\.[A-Za-z0-9_\-]{27,}\b`, null, "Discord bot token"),
151
+ // PEM private key headers
152
+ p(EntityType.SECRET, String.raw `-----BEGIN\s(?:RSA\s)?PRIVATE\sKEY-----[\s\S]{0,16384}?-----END\s(?:RSA\s)?PRIVATE\sKEY-----`, null, "PEM private key"),
153
+ // AWS Secret Access Key (40 chars, base64 with +/ after an AKIA line)
154
+ p(EntityType.SECRET, String.raw `(?<=aws_secret_access_key\s{0,4}[:=]\s{0,4})[A-Za-z0-9+/]{40}`, null, "AWS Secret Access Key"),
70
155
  // --- Secret / API Key (connection strings with embedded credentials) ---
71
- p(EntityType.SECRET, String.raw `(?:mongodb|mysql|postgres(?:ql)?|redis|amqp|rabbitmq):\/\/[^\s:]+:[^\s@]+@[^\s]+`, null, "Connection string with credentials"),
156
+ p(EntityType.SECRET, String.raw `(?:mongodb|mysql|postgres(?:ql)?|redis|amqp|rabbitmq):\/\/[^\s:]{1,256}:[^\s@]{1,256}@[^\s]+`, null, "Connection string with credentials"),
72
157
  // --- Secret / API Key (assignment-based: KEY=value or KEY: value) ---
73
- p(EntityType.SECRET, String.raw `(?<=[:=] )\S{8,}|(?<=[:=])\S{8,}`, "high_entropy", "Assigned secret value", [
74
- "key", "token", "secret", "password", "credential",
75
- "api_key", "apikey", "api-key", "auth", "bearer",
76
- "aws_secret_access_key", "access_key", "private_key",
77
- "wachtwoord", "mot de passe", "Passwort", "Schlüssel",
78
- "clé", "sleutel", "lösenord", "hasło", "heslo",
79
- "jelszó", "contraseña", "senha", "parola",
80
- "AES_KEY", "ENCRYPTION_KEY", "SIGNING_KEY",
81
- "ACCOUNT_SID", "AUTH_TOKEN", "TWILIO",
82
- "RAILS_SECRET", "SECRET_KEY_BASE", "DATABASE_URL",
83
- "NPM_TOKEN", "DIGITALOCEAN", "GOOGLE_API",
84
- "kľúč", "nøkkel", "nyckel", "avain", "lykill",
85
- "DB_PASS", "DB_PASSWORD", "DATASOURCE_PASSWORD",
86
- "JWT_SIGNING", "SIGNING_KEY",
87
- "passwd", "Kennwort",
88
- ], true),
158
+ p(EntityType.SECRET, String.raw `(?<=[:=] )\S{8,}|(?<=[:=])\S{8,}`, "high_entropy", "Assigned secret value", SECRET_CONTEXT, true),
89
159
  // --- Secret / API Key (entropy-based fallback for longer tokens) ---
90
- p(EntityType.SECRET, String.raw `\b[A-Za-z0-9_\-+/]{24,}[A-Za-z0-9_\-+/=]*\b`, "high_entropy", "High-entropy token", [
91
- "key", "token", "secret", "password", "credential",
92
- "api_key", "apikey", "api-key", "auth", "bearer",
93
- "aws_secret_access_key", "access_key", "private_key",
94
- "wachtwoord", "mot de passe", "Passwort", "Schlüssel",
95
- "clé", "sleutel", "lösenord", "hasło", "heslo",
96
- "jelszó", "contraseña", "senha", "parola",
97
- "kľúč", "nøkkel", "nyckel", "avain", "lykill",
98
- ], true),
160
+ p(EntityType.SECRET, String.raw `\b[A-Za-z0-9_\-+/]{24,}[A-Za-z0-9_\-+/=]*\b`, "high_entropy", "High-entropy token", SECRET_CONTEXT, true),
99
161
  p(EntityType.DOB, String.raw `\b(?:0[1-9]|[12][0-9]|3[01])[/.\-](?:0[1-9]|1[0-2])[/.\-](?:19|20)\d{2}\b`, null, "DD/MM/YYYY", DOB_CONTEXT, true),
100
162
  p(EntityType.DATE_OF_DEATH, String.raw `\b(?:0[1-9]|[12][0-9]|3[01])[/.\-](?:0[1-9]|1[0-2])[/.\-](?:19|20)\d{2}\b`, null, "", DATE_OF_DEATH_CONTEXT, true),
101
163
  p(EntityType.DOB, String.raw `\b(?:19|20)\d{2}[/.\-](?:0[1-9]|1[0-2])[/.\-](?:0[1-9]|[12][0-9]|3[01])\b`, null, "ISO YYYY-MM-DD", DOB_ISO_CONTEXT, true),
@@ -169,6 +231,11 @@ const AT = {
169
231
  p(EntityType.IBAN, String.raw `\bAT\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\b`, "iban"),
170
232
  p(EntityType.VAT, String.raw `\bATU\d{8}\b`),
171
233
  p(EntityType.PHONE, String.raw `\b0\d{3,4}[\s\-]?\d{5,8}\b`),
234
+ // Short area codes (Vienna is "01") split across three groups:
235
+ // "01 53460 2215". The pattern above requires 3-4 digits after the trunk
236
+ // 0, so it never matched these. Both separators are mandatory here, which
237
+ // keeps "01 2025" (a date fragment) out.
238
+ p(EntityType.PHONE, String.raw `\b0\d{1,4}[\s\-/]\d{3,7}[\s\-/]\d{2,6}\b`, null, "Austrian national phone — short area code, grouped"),
172
239
  p(EntityType.PHONE, String.raw `\+43\s?\d{3,4}[\s\-]?\d{5,8}`),
173
240
  p(EntityType.LICENSE_PLATE, String.raw `\b[A-ZÄÖÜ]{1,2}\s?\d{1,5}\s?[A-Z]{1,2}\b`, null, "", ["Kennzeichen", "Nummernschild", "Kfz-Kennzeichen"], true),
174
241
  p(EntityType.POSTAL_CODE, String.raw `\b[1-9]\d{3}\b`, null, "", ["PLZ", "Postleitzahl", "Adresse", "Anschrift", "Straße", "Str.", "Gasse", "Weg", "Platz", "Postal:", "Wohnort"], true),
@@ -1,4 +1,12 @@
1
1
  import { type Detection } from "../types.js";
2
+ /**
3
+ * The country modules use the EU/VAT convention (UK, EL) while the public
4
+ * contract is ISO 3166-1 alpha-2 (GB, GR). Accept both spellings so a caller
5
+ * passing correct ISO codes is not punished for it.
6
+ */
7
+ export declare const COUNTRY_CODE_ALIASES: Record<string, string>;
8
+ /** Map a caller-supplied code to a registered one, or null if unknown. */
9
+ export declare function resolveCountryCode(countryCode: string): string | null;
2
10
  export declare class RuleEngine {
3
11
  private matcher;
4
12
  private loadedCountries;
@@ -1,12 +1,62 @@
1
- import { DetectionSource } from "../types.js";
1
+ import { DetectionSource, EntityType } from "../types.js";
2
2
  import { MultiPatternMatcher } from "./matchers.js";
3
3
  import { shouldSuppress } from "./suppressors.js";
4
4
  import { detectStructuralDob } from "./structural.js";
5
5
  import { COUNTRY_CONFIGS } from "./countries/index.js";
6
+ /**
7
+ * Heuristic to detect nested quantifiers that cause catastrophic backtracking.
8
+ * After stripping escaped chars and character classes, look for an unbounded
9
+ * quantifier (+/*) closing a group that is itself quantified (+/*).
10
+ */
11
+ const NESTED_QUANTIFIER_RE = /[+*]\)[+*]/;
12
+ function validateCustomPattern(pattern) {
13
+ try {
14
+ new RegExp(pattern, "gu");
15
+ }
16
+ catch (e) {
17
+ throw new Error(`Invalid regex pattern: ${e instanceof Error ? e.message : e}`);
18
+ }
19
+ const stripped = pattern
20
+ .replace(/\\./g, "X")
21
+ .replace(/\[(?:[^\]\\]|\\.)*\]/g, "X");
22
+ if (NESTED_QUANTIFIER_RE.test(stripped)) {
23
+ throw new Error("Pattern contains nested quantifiers (e.g. (a+)+) which can cause " +
24
+ "catastrophic backtracking (ReDoS). Restructure the pattern to " +
25
+ "avoid quantifiers inside quantified groups.");
26
+ }
27
+ }
28
+ /**
29
+ * The country modules use the EU/VAT convention (UK, EL) while the public
30
+ * contract is ISO 3166-1 alpha-2 (GB, GR). Accept both spellings so a caller
31
+ * passing correct ISO codes is not punished for it.
32
+ */
33
+ export const COUNTRY_CODE_ALIASES = {
34
+ GB: "UK",
35
+ GR: "EL",
36
+ UK: "UK",
37
+ EL: "EL",
38
+ };
39
+ /** Map a caller-supplied code to a registered one, or null if unknown. */
40
+ export function resolveCountryCode(countryCode) {
41
+ let code = countryCode.trim().toUpperCase();
42
+ code = COUNTRY_CODE_ALIASES[code] ?? code;
43
+ return code in COUNTRY_CONFIGS && code !== "SHARED" ? code : null;
44
+ }
45
+ const warnedCountries = new Set();
46
+ function warnUnknownCountry(code) {
47
+ if (warnedCountries.has(code))
48
+ return;
49
+ warnedCountries.add(code);
50
+ const available = Object.keys(COUNTRY_CONFIGS).filter(c => c !== "SHARED").sort();
51
+ console.warn(`[euredact] Unknown country code: ${JSON.stringify(code)}. Continuing with ` +
52
+ `shared country-independent patterns only (email, IBAN, international ` +
53
+ `phone, credit card, ...). Available: ${available.join(", ")}`);
54
+ }
6
55
  export class RuleEngine {
7
56
  matcher = new MultiPatternMatcher();
8
57
  loadedCountries = new Set();
9
58
  addCustomPattern(name, pattern) {
59
+ validateCustomPattern(pattern);
10
60
  const pdef = {
11
61
  entityType: name, pattern, validator: null,
12
62
  description: "", contextKeywords: [], requiresContext: false,
@@ -22,7 +72,15 @@ export class RuleEngine {
22
72
  else {
23
73
  configs = [];
24
74
  for (const c of countryCodes) {
25
- const code = c.toUpperCase();
75
+ const code = resolveCountryCode(c);
76
+ if (code === null) {
77
+ // Detection continues with the shared, country-independent
78
+ // patterns. Throwing on an unknown locale invites callers to wrap
79
+ // the call in try/catch and skip redaction — failing open, with
80
+ // unredacted PII in the output.
81
+ warnUnknownCountry(c);
82
+ continue;
83
+ }
26
84
  const config = COUNTRY_CONFIGS[code];
27
85
  if (config)
28
86
  configs.push(config);
@@ -44,38 +102,87 @@ export class RuleEngine {
44
102
  }
45
103
  detect(text, countryCodes) {
46
104
  this.loadCountries(countryCodes);
47
- let rawMatches = this.matcher.scan(text);
105
+ // Scope the scan to requested countries for efficiency
106
+ // Resolve through the alias table so `countries: ["GB"]` actually scans
107
+ // the patterns registered under "UK".
108
+ let scanCodes = null;
48
109
  if (countryCodes !== null) {
49
- const codesUpper = new Set(countryCodes.map(c => c.toUpperCase()));
50
- rawMatches = rawMatches.filter(m => codesUpper.has(m.countryCode) || m.countryCode === "SHARED" || m.countryCode === "CUSTOM");
110
+ scanCodes = new Set();
111
+ for (const c of countryCodes) {
112
+ const code = resolveCountryCode(c);
113
+ if (code !== null)
114
+ scanCodes.add(code);
115
+ }
116
+ scanCodes.add("SHARED");
117
+ scanCodes.add("CUSTOM");
51
118
  }
119
+ let rawMatches = this.matcher.scan(text, scanCodes);
52
120
  // Matches that have a validator but fail it create suppression zones:
53
121
  // the span is recognisably a specific entity (e.g. IBAN-shaped) so
54
122
  // overlapping regex-only matches (license plate, phone) are false
55
123
  // positives and must be suppressed.
56
124
  const validated = [];
57
- const suppressionZones = [];
125
+ const rawZones = [];
58
126
  for (const m of rawMatches) {
59
- if (this.matcher.validate(m)) {
60
- validated.push(m);
127
+ const isValid = this.matcher.validate(m);
128
+ if (isValid) {
129
+ validated.push({ match: m, hasValidValidator: m.patternDef.validator !== null });
61
130
  }
62
131
  else if (m.patternDef.validator !== null && !m.patternDef.requiresContext) {
63
- suppressionZones.push([m.start, m.end]);
132
+ rawZones.push([m.start, m.end]);
133
+ }
134
+ }
135
+ // Merge overlapping suppression zones for O(log n) containment checks
136
+ let zoneStarts = [];
137
+ let zoneEnds = [];
138
+ if (rawZones.length > 0) {
139
+ rawZones.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
140
+ let [ms, me] = rawZones[0];
141
+ for (let i = 1; i < rawZones.length; i++) {
142
+ const [zs, ze] = rawZones[i];
143
+ if (zs <= me) {
144
+ me = Math.max(me, ze);
145
+ }
146
+ else {
147
+ zoneStarts.push(ms);
148
+ zoneEnds.push(me);
149
+ ms = zs;
150
+ me = ze;
151
+ }
64
152
  }
153
+ zoneStarts.push(ms);
154
+ zoneEnds.push(me);
65
155
  }
66
156
  // Build candidates with priority: validated (3) > custom (2) > regex-only (1)
67
157
  const candidates = [];
68
- for (const match of validated) {
158
+ for (const { match, hasValidValidator } of validated) {
69
159
  if (shouldSuppress(text, match))
70
160
  continue;
71
- if (match.patternDef.validator === null && suppressionZones.length > 0) {
72
- if (suppressionZones.some(([zStart, zEnd]) => match.start >= zStart && match.end <= zEnd)) {
73
- continue;
161
+ // O(log n) binary-search containment check on merged zones
162
+ if (match.patternDef.validator === null && zoneStarts.length > 0) {
163
+ let lo = 0, hi = zoneStarts.length;
164
+ while (lo < hi) {
165
+ const mid = (lo + hi) >>> 1;
166
+ if (zoneStarts[mid] <= match.start)
167
+ lo = mid + 1;
168
+ else
169
+ hi = mid;
74
170
  }
171
+ const idx = lo - 1;
172
+ if (idx >= 0 && zoneEnds[idx] >= match.end)
173
+ continue;
75
174
  }
76
- const hasValidator = match.patternDef.validator !== null;
77
- const isValid = this.matcher.validate(match);
78
- const priority = (hasValidator && isValid) ? 3 : match.countryCode === "CUSTOM" ? 2 : 1;
175
+ // A bare digit run is the weakest evidence in the engine and must never
176
+ // re-cut a span a structured detector claims (PHONE, SSN, NATIONAL_ID,
177
+ // BANK_ACCOUNT, VAT...). Postal codes take unclaimed spans only,
178
+ // whatever the span lengths.
179
+ const priority = hasValidValidator
180
+ ? 3
181
+ : match.countryCode === "CUSTOM"
182
+ ? 2
183
+ : match.patternDef.entityType === EntityType.POSTAL_CODE
184
+ ? 0
185
+ : 1;
79
186
  const spanLength = match.end - match.start;
80
187
  const score = priority * 1_000_000 + spanLength;
81
188
  candidates.push({
@@ -106,16 +213,20 @@ export class RuleEngine {
106
213
  return [];
107
214
  const sorted = [...candidates].sort((a, b) => b.score - a.score);
108
215
  const result = [];
216
+ const occupied = new Set();
109
217
  for (const { det } of sorted) {
110
218
  let overlaps = false;
111
- for (const kept of result) {
112
- if (det.start < kept.end && det.end > kept.start) {
219
+ for (let p = det.start; p < det.end; p++) {
220
+ if (occupied.has(p)) {
113
221
  overlaps = true;
114
222
  break;
115
223
  }
116
224
  }
117
- if (!overlaps)
225
+ if (!overlaps) {
226
+ for (let p = det.start; p < det.end; p++)
227
+ occupied.add(p);
118
228
  result.push(det);
229
+ }
119
230
  }
120
231
  return result;
121
232
  }
@@ -7,6 +7,6 @@ export declare class MultiPatternMatcher {
7
7
  addPattern(patternDef: PatternDef, countryCode: string): void;
8
8
  addCountry(config: CountryConfig): void;
9
9
  compile(): void;
10
- scan(text: string): RawMatch[];
10
+ scan(text: string, countryCodes?: Set<string> | null): RawMatch[];
11
11
  validate(match: RawMatch): boolean;
12
12
  }
@@ -19,11 +19,13 @@ export class MultiPatternMatcher {
19
19
  compile() {
20
20
  this.compiled = true;
21
21
  }
22
- scan(text) {
22
+ scan(text, countryCodes = null) {
23
23
  if (!this.compiled)
24
24
  this.compile();
25
25
  const matches = [];
26
26
  for (const { regex, patternDef, countryCode } of this.patterns) {
27
+ if (countryCodes !== null && !countryCodes.has(countryCode))
28
+ continue;
27
29
  regex.lastIndex = 0;
28
30
  let m;
29
31
  while ((m = regex.exec(text)) !== null) {