euredact 0.6.0 → 0.7.1

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.
Files changed (57) hide show
  1. package/NOTICE +11 -0
  2. package/README.md +455 -44
  3. package/dist/cjs/cache.js +2 -8
  4. package/dist/cjs/cloud/account.js +180 -0
  5. package/dist/cjs/cloud/batches.js +469 -0
  6. package/dist/cjs/cloud/client.js +124 -14
  7. package/dist/cjs/cloud/config.js +28 -2
  8. package/dist/cjs/cloud/errors.js +39 -2
  9. package/dist/cjs/cloud/index.js +16 -1
  10. package/dist/cjs/index.js +9 -1
  11. package/dist/cjs/platform.browser.js +98 -0
  12. package/dist/cjs/platform.js +140 -0
  13. package/dist/cjs/rules/bicGleif.js +57 -0
  14. package/dist/cjs/rules/bicRegistry.js +37 -11
  15. package/dist/cjs/rules/countries/index.js +83 -13
  16. package/dist/cjs/rules/cues.js +29 -9
  17. package/dist/cjs/rules/engine.js +14 -6
  18. package/dist/cjs/rules/suppressors.js +479 -26
  19. package/dist/cjs/rules/validators.js +50 -0
  20. package/dist/cjs/sdk.js +108 -14
  21. package/dist/cjs/types.js +4 -1
  22. package/dist/esm/cache.js +2 -8
  23. package/dist/esm/cloud/account.d.ts +155 -0
  24. package/dist/esm/cloud/account.js +175 -0
  25. package/dist/esm/cloud/batches.d.ts +166 -0
  26. package/dist/esm/cloud/batches.js +464 -0
  27. package/dist/esm/cloud/client.d.ts +83 -1
  28. package/dist/esm/cloud/client.js +119 -22
  29. package/dist/esm/cloud/config.d.ts +6 -0
  30. package/dist/esm/cloud/config.js +27 -2
  31. package/dist/esm/cloud/errors.d.ts +26 -1
  32. package/dist/esm/cloud/errors.js +35 -1
  33. package/dist/esm/cloud/index.d.ts +4 -2
  34. package/dist/esm/cloud/index.js +4 -2
  35. package/dist/esm/index.d.ts +2 -2
  36. package/dist/esm/index.js +1 -1
  37. package/dist/esm/platform.browser.d.ts +27 -0
  38. package/dist/esm/platform.browser.js +92 -0
  39. package/dist/esm/platform.d.ts +20 -0
  40. package/dist/esm/platform.js +101 -0
  41. package/dist/esm/rules/bicGleif.d.ts +4 -0
  42. package/dist/esm/rules/bicGleif.js +53 -0
  43. package/dist/esm/rules/bicRegistry.d.ts +25 -11
  44. package/dist/esm/rules/bicRegistry.js +36 -11
  45. package/dist/esm/rules/countries/index.js +83 -13
  46. package/dist/esm/rules/cues.d.ts +1 -1
  47. package/dist/esm/rules/cues.js +29 -9
  48. package/dist/esm/rules/engine.js +14 -6
  49. package/dist/esm/rules/suppressors.d.ts +1 -0
  50. package/dist/esm/rules/suppressors.js +478 -25
  51. package/dist/esm/rules/validators.d.ts +14 -0
  52. package/dist/esm/rules/validators.js +47 -0
  53. package/dist/esm/sdk.d.ts +77 -4
  54. package/dist/esm/sdk.js +108 -17
  55. package/dist/esm/types.d.ts +64 -6
  56. package/dist/esm/types.js +4 -1
  57. package/package.json +13 -4
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SuppressionScratch = void 0;
3
+ exports.SuppressionScratch = exports.MONTH_NAMES = void 0;
4
4
  exports.shouldSuppress = shouldSuppress;
5
5
  const types_js_1 = require("../types.js");
6
6
  const bicRegistry_js_1 = require("./bicRegistry.js");
@@ -25,10 +25,11 @@ const CURRENCY_WORD = "EUR|USD|GBP|CHF|ISK|SEK|NOK|DKK|PLN|CZK|HUF|RON|BGN|HRK|"
25
25
  const CURRENCY_AFTER = new RegExp(`^\\s*(?:(?:${CURRENCY_SYMBOL})|(?:${CURRENCY_WORD})${_UWB})`, "i");
26
26
  const CURRENCY_COMMA_AFTER = new RegExp(`^[.,]\\d{1,2}\\s*(?:(?:${CURRENCY_SYMBOL})|(?:${CURRENCY_WORD})${_UWB})`, "i");
27
27
  const CURRENCY_BEFORE = new RegExp(`(?:(?:${CURRENCY_SYMBOL})|\\b(?:${CURRENCY_WORD}))\\s*$`, "i");
28
- const AMOUNT_LABEL_BEFORE = /\b(?:Montant|Beløb|Summa|Summe|Bedrag|Amount|Total|TTC|inkl|Upphæð|Importe|Importo|Valore|Wartość|Kwota|Částka|Összeg|Sum|Beloop|Prix|Preis|Price|Loyer|Miete|Huur|Rent|Betrag)\s*(?:\w+\s*)?:?\s*$/i;
28
+ const AMOUNT_LABEL_BEFORE = /\b(?:Montant|Beløb|Summa|Summe|Bedrag|Amount|Total|TTC|inkl|Upphæð|Importe|Importo|Valore|Wartość|Kwota|Částka|Összeg|Sum|Beloop|Prix|Preis|Price|Loyer|Miete|Huur|Rent|Betrag)(?:\s+\w+)?\s*:?\s*$/i; // second word separate: not "Rent"+"enversicherungsnummer" (rules-engine#111)
29
29
  // Use Unicode-aware word boundary via \p{L} negative lookahead instead of \b
30
30
  // because JS \b is ASCII-only and fails on Unicode letters (e.g. "München" → \bm\b matches M before ü)
31
- const UNIT_AFTER = /^\s*(?:kg|km|cm|mm|m[²³]|ml|mg|GB|MB|KB|TB|%|jaar|maanden|weken|dagen|uur|minuten|seconden|stuks|pcs|pieces|ans|mois|semaines|jours|heures|Jahre|Monate|Wochen|Tage|Stunden)(?![a-zA-Z\u00C0-\u024F\u0400-\u04FF])/i;
31
+ // Horizontal space only: a unit is on the number's own line (rules-engine#51).
32
+ const UNIT_AFTER = /^[ \t]*(?:kg|km|cm|mm|m[²³]|ml|mg|GB|MB|KB|TB|%|jaar|maanden|weken|dagen|uur|minuten|seconden|stuks|pcs|pieces|ans|mois|semaines|jours|heures|Jahre|Monate|Wochen|Tage|Stunden)(?![a-zA-Z\u00C0-\u024F\u0400-\u04FF])/i;
32
33
  // Same missing-boundary flaw: "ref" matched the tail of "kortref".
33
34
  const REFERENCE_BEFORE = /\b(?:dossier|ref\.?|referentie|reference|référence|factuurnummer|invoice\s*(?:nr|number|no)?|bestelnummer|order\s*(?:nr|number|no)?|kenmerk|ordernummer|Aktenzeichen|numéro\s*de\s*(?:dossier|facture|commande)|bestellnummer|Rechnungsnummer|artikelnr|article\s*no|contract\s*(?:nr|number|no)?|pagina|page|Seite|blz\.?|Facture\s*n[°o]?|Faktura\s*n[°or]\.?|Lasku\s*n[°or]o?\.?|Rechnung\s*(?:Nr|n[°o])?|faktura\s*(?:nr|n[°o])?|bestilling\s*(?:nr|n[°o])?|bestelling\s*n[°or]\.?|Reikningur\s*nr|ticket|incident\s*(?:report|nr|no)?|case\s*(?:nr|no|id)?|zaaknummer|meldingsnummer|Vorgangsnummer|Vorgang|Störungsmeldung|saksnummer|ärendenummer|sagsnummer|asianumero|Factuur\s*n[ro]?\.?|Nota\s*n[ro]?\.?)\s*[:.#]?\s*$/i;
34
35
  // "#" before a number marks a reference in every language in scope — a ticket,
@@ -36,9 +37,19 @@ const REFERENCE_BEFORE = /\b(?:dossier|ref\.?|referentie|reference|référence|f
36
37
  const HASH_BEFORE = /#\s*$/;
37
38
  // A short uppercase tag hyphenated to the number: "IR-43433", "INC-2024".
38
39
  const REF_PREFIX_BEFORE = /(?:^|[\s([])[A-Z]{2,5}-$/;
40
+ // The cross-border address form, "CH-8004 Zürich": a country code hyphenated to
41
+ // a postal code, told apart from a reference tag by position -- it opens an
42
+ // address line and a place name follows (rules-engine#58).
43
+ const COUNTRY_PREFIX_OPENS_LINE = /(?:^|\n|,)[ \t]*([A-Z]{2})-$/;
44
+ const POSTAL_PREFIX_COUNTRIES = new Set([
45
+ "AT", "BE", "BG", "CH", "CY", "CZ", "DE", "DK", "EE", "EL", "ES", "FI",
46
+ "FR", "GB", "GR", "HR", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV",
47
+ "MT", "NL", "NO", "PL", "PT", "RO", "SE", "SI", "SK", "UK",
48
+ ]);
39
49
  const LEGAL_BEFORE = /(?:Art(?:ikel|icle|\.)|§|Artikel|Section|Sectie|Afdeling|paragraaf|Absatz|alinéa|punt|point|Punkt|lid)\s*$/i;
40
- const MATH_BEFORE = /[=+\-×÷*/]\s*$/;
41
- const MATH_AFTER = /^\s*[=+\-×÷*/]/;
50
+ // An operator on the same line only: a bullet or "---" on the next line is not a minus sign (rules-engine#91).
51
+ const MATH_BEFORE = /[=+\-×÷*/][^\S\r\n]*$/;
52
+ const MATH_AFTER = /^[^\S\r\n]*[=+\-×÷*/]/;
42
53
  // "A-1010", "B-2000", "L-1234", "CH-8000", "D-10115": a country prefix on a
43
54
  // postal code, not a minus sign.
44
55
  const COUNTRY_PREFIX_HYPHEN = /(?:^|[^A-Za-z0-9])[A-Z]{1,2}-\s*$/;
@@ -64,10 +75,11 @@ const NUMERIC_TYPES = new Set([types_js_1.EntityType.PHONE, types_js_1.EntityTyp
64
75
  // Month names in the languages this engine covers. Long, but it is data: the
65
76
  // alternative is a proximity heuristic, and proximity is what caused the defect
66
77
  // this guards against. Kept in sync with the Python SDK's suppressors.py.
67
- const MONTH_NAMES = "january|february|march|april|may|june|july|august|september|october|november|december" +
78
+ exports.MONTH_NAMES = "january|february|march|april|may|june|july|august|september|october|november|december" +
68
79
  "|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec" +
69
80
  "|januar|jänner|februar|märz|mai|juni|juli|oktober|dezember|dez|okt|mrz" +
70
81
  "|janvier|février|mars|avril|juin|juillet|août|aout|septembre|octobre|novembre|décembre|decembre" +
82
+ "|janv|févr|fevr|avr|juil|déc" +
71
83
  "|januari|februari|maart|mei|augustus|mrt" +
72
84
  "|gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|dicembre" +
73
85
  "|enero|febrero|abril|mayo|junio|julio|septiembre|octubre|noviembre|diciembre" +
@@ -94,7 +106,7 @@ const SINCE_CUES = "seit|since|depuis|sinds|sedert|vanaf|desde|dal|od|sedan|side
94
106
  * candidate. Adjacent-only on purpose — a cue 150 characters away is what made
95
107
  * this a bug in the first place.
96
108
  */
97
- const DATE_BEFORE = new RegExp(`(?:\\b(?:${MONTH_NAMES})\\b\\.?|\\b(?:${SINCE_CUES})\\b|\\d{1,2}[./-]\\d{1,2}[./-])\\s*$`, "iu");
109
+ const DATE_BEFORE = new RegExp(`(?:\\b(?:${exports.MONTH_NAMES})\\b\\.?|\\b(?:${SINCE_CUES})\\b|\\d{1,2}[./-]\\d{1,2}[./-])\\s*$`, "iu");
98
110
  /** A date separator immediately after, making the candidate a leading year. */
99
111
  const DATE_AFTER = /^[./-]\d{1,2}[./-]\d{1,2}\b/;
100
112
  /**
@@ -191,6 +203,27 @@ function suppressSecretOverStructured(text, match) {
191
203
  * natural word. A random all-letter token would be mixed case and is left alone
192
204
  * — losing a real secret is the expensive direction.
193
205
  */
206
+ /**
207
+ * Values an assigned-secret rule finds after a "=" or ":" that are names, not
208
+ * secrets (rules-engine#126): `${VAR}` references, identifiers, hyphenated
209
+ * compounds, image tags, product versions, code calls. Not paths, host names
210
+ * or reference numbers: those carry personal data ("mac-mhartmann88").
211
+ * Kept in sync with the Python SDK's _SECRET_NAME_SHAPES.
212
+ */
213
+ const SECRET_NAME_SHAPES = new RegExp([
214
+ String.raw `^\$\{[A-Za-z_][A-Za-z0-9_]*(?::?[-=?+][^}]*)?\}?$`,
215
+ String.raw `^[a-z]{2,}(?:_[a-z]{2,})+$`, String.raw `^[A-Z]{2,}(?:_[A-Z]{2,})+$`,
216
+ String.raw `^(?:[A-Z]+[a-z]{2,}){3,}(?:V?\d{1,2})?$`,
217
+ String.raw `^[a-z][a-z0-9._/-]*:(?:\d+(?:\.\d+)*(?:-[a-z0-9]+)*|latest|stable|alpine|slim)$`,
218
+ String.raw `^[A-Za-z][\w.-]*\/\d+(?:\.\d+)+$`,
219
+ String.raw `^[\w.]+\(["'\x60]`,
220
+ ].join("|"));
221
+ /** A hyphenated compound of words and acronyms; two capitalised words alone
222
+ * ("Ann-Sophie", "Caron-Cordier") are names and are left alone. */
223
+ const COMPOUND_PART = String.raw `(?:[A-ZÀ-Þ]{2,6}\d{0,3}|[A-Za-zÀ-ÿ][a-zß-ÿ]{2,})`;
224
+ const COMPOUND = new RegExp(String.raw `^` + COMPOUND_PART + String.raw `(?:-` + COMPOUND_PART + String.raw `)+$`);
225
+ const NAME_PAIR = /^[A-ZÀ-Þ][a-zß-ÿ]{2,}-[A-ZÀ-Þ][a-zß-ÿ]{2,}$/;
226
+ const CONNECTION_WITH_REFERENCE = /^[a-z]+:\/\/[^\s:@]+:\$\{[^}]*\}@/;
194
227
  function suppressSecretNotASecret(_text, match) {
195
228
  if (match.patternDef.entityType !== types_js_1.EntityType.SECRET)
196
229
  return false;
@@ -205,6 +238,10 @@ function suppressSecretNotASecret(_text, match) {
205
238
  return true;
206
239
  if (HYPHENATED_WORDS.test(bare))
207
240
  return true;
241
+ if (SECRET_NAME_SHAPES.test(bare) || CONNECTION_WITH_REFERENCE.test(bare))
242
+ return true;
243
+ if (COMPOUND.test(bare) && !NAME_PAIR.test(bare))
244
+ return true;
208
245
  const alpha = /^[A-Za-zÀ-ÿ]+$/.test(bare);
209
246
  const oneWord = bare === bare.toLowerCase() ||
210
247
  (bare.slice(0, 1) === bare.slice(0, 1).toUpperCase() &&
@@ -289,7 +326,19 @@ function suppressReference(text, match) {
289
326
  if (REFERENCE_BEFORE.test(before))
290
327
  return true;
291
328
  const adjacent = text.slice(Math.max(0, match.start - 8), match.start);
292
- return HASH_BEFORE.test(adjacent) || REF_PREFIX_BEFORE.test(adjacent);
329
+ if (HASH_BEFORE.test(adjacent))
330
+ return true;
331
+ if (!REF_PREFIX_BEFORE.test(adjacent))
332
+ return false;
333
+ return !(match.patternDef.entityType === types_js_1.EntityType.POSTAL_CODE &&
334
+ isCountryPrefixedAddress(text, match.start, match.end));
335
+ }
336
+ /** "CH-8004 Zürich" on an address line, not the reference "IT-20431". */
337
+ function isCountryPrefixedAddress(text, start, end) {
338
+ const found = COUNTRY_PREFIX_OPENS_LINE.exec(text.slice(Math.max(0, start - 12), start));
339
+ return (found !== null &&
340
+ POSTAL_PREFIX_COUNTRIES.has(found[1]) &&
341
+ CITY_AFTER.test(text.slice(end, end + 24)));
293
342
  }
294
343
  function suppressLegal(text, match) {
295
344
  const applicable = LEGAL_TYPES;
@@ -334,7 +383,20 @@ function suppressSequential(_text, match) {
334
383
  // the "]". The opening bracket is still required.
335
384
  const PLACEHOLDER = /^(?:\[([A-Z][A-Z0-9_]*)\]?|([A-Z][A-Z0-9_]*?)_(?:\d+|[A-HJ-NP-Z2-9]{4}))$/;
336
385
  const KNOWN_TYPE_NAMES = new Set(Object.values(types_js_1.EntityType));
337
- function suppressRedactionPlaceholder(_text, match) {
386
+ function suppressRedactionPlaceholder(text, match) {
387
+ // The name between the brackets: an assigned value now starts after an
388
+ // opening bracket, so "credentials: [POSTAL_CODE]" offers "POSTAL_CODE" alone
389
+ // (rules-engine#33, #126).
390
+ if (text[match.start - 1] === "[" && text[match.end] === "]" && KNOWN_TYPE_NAMES.has(match.text))
391
+ return true;
392
+ // A placeholder inside the span: on text masked once, a value running across
393
+ // "[IP_ADDRESS]" is two things joined (rules-engine#126).
394
+ // One character either side: a span may start just inside the bracket.
395
+ const around = text.slice(Math.max(0, match.start - 1), match.end + 1);
396
+ for (const m of around.matchAll(/\[([A-Z][A-Z0-9_]*)\]/g)) {
397
+ if (KNOWN_TYPE_NAMES.has(m[1]))
398
+ return true;
399
+ }
338
400
  const found = PLACEHOLDER.exec(match.text);
339
401
  if (found === null)
340
402
  return false;
@@ -342,6 +404,8 @@ function suppressRedactionPlaceholder(_text, match) {
342
404
  return KNOWN_TYPE_NAMES.has(name);
343
405
  }
344
406
  const RECENT_YEAR = /^(?:19[5-9]\d|20[0-3]\d)$/;
407
+ // Any year a written date can carry: the date-construction test applies to all of them (rules-engine#111).
408
+ const DATE_YEAR = /^(?:1[89]\d\d|20\d\d)$/;
345
409
  const POSTAL_CONTEXT_NEAR = /(?:postcode|postal|code\s*postal|PLZ|Postleitzahl|postnummer|postinumero|póstnúmer|zip|straat|straße|strasse|rue\s|via\s|calle\s|rua\s|ulica|utca|street|avenue|laan\s|weg\s|plein|adres|adresse|address|woonplaats|wonende|woonachtig|gevestigd|domicili|demeurant|résidant|residant|bosatt|bopæl|wohnhaft|ansässig|stad\b|ville\b|city\b|Stadt|città|ciudad|cidade|miasto|město|város)/i;
346
410
  const DATE_KEYWORD_NEAR = /(?:født|fødselsdato|fødsel|Fødselsdato|född|födelsedatum|födelsedag|syntynyt|syntymäaika|fæddur|fæðingardagur|geboren|geboortedatum|Geburtsdatum|nascido|nacido|data di nascita|nato il|nata il|Tiltr[æa]delsesdato|Tiltredelsesdato|datum|date\b|Datum|jaar|year|année|Jahr|since|sinds|depuis|seit|\d{2}\.\d{2}\.|(?:januar|februar|marts|april|maj|juni|juli|august|september|oktober|november|december|januari|februari|mars|mei|juin|juillet|août))/i;
347
411
  // A sentence boundary: a full stop, question or exclamation mark followed by
@@ -371,14 +435,27 @@ function sentenceAround(text, start, end, reach = 160) {
371
435
  const POSTAL_LABEL_TOUCHING = new RegExp(`(?:${POSTAL_CONTEXT_NEAR.source})[\\s:.\\-]{0,4}$`, "i");
372
436
  // A capitalised place name after the code: "2000 Antwerpen". A following label
373
437
  // ("1970 Fødselsnummer:") is excluded by the colon.
374
- const CITY_AFTER = /^[ \t](?!\w+\s*:)[A-ZÀ-ÞŁŠŽ][\w\-']{2,}/u;
438
+ // `\p{L}\p{N}_` rather than `\w`: JS `\w` is ASCII-only, so "8004 Zürich" failed
439
+ // at the ü here while Python's Unicode `\w` accepted it (rules-engine#58).
440
+ const CITY_AFTER = /^[ \t](?![\p{L}\p{N}_]+\s*:)[A-ZÀ-ÞŁŠŽ][\p{L}\p{N}_\-']{2,}/u;
375
441
  // Every position is local to the value or inside its own sentence. Proximity to
376
442
  // an address *word* anywhere in a 300-character window is what the old rescue
377
443
  // asked, and that is a neighbourhood rather than a position (rules-engine#32).
444
+ /**
445
+ * The comma of an address line: the word before it is a place name or a number.
446
+ * "myocardial infarction, 2016" is a year in a list (rules-engine#111).
447
+ */
448
+ const ADDRESS_COMMA = /(?<!\S)(?:[A-ZÀ-Þ\d][^\s,]*|[^\s,]*\]),[ \t]*$/;
449
+ /**
450
+ * A redaction placeholder: "[POSTAL_CODE]" holds the words the address test
451
+ * looks for, so on masked text it made every year in its sentence a postcode
452
+ * (rules-engine#111). A masked value is no address word.
453
+ */
454
+ const MASKED_VALUE = /\[[A-Z][A-Z_]*\]/g;
378
455
  function yearSitsInAddressStructure(text, start, end) {
379
456
  const before = text.slice(Math.max(0, start - 28), start);
380
457
  // "Amsterdam, 2026" -- the comma of an address line.
381
- if (/,[ \t]*$/.test(before))
458
+ if (ADDRESS_COMMA.test(before))
382
459
  return true;
383
460
  // "PLZ: 2011" -- a postal label with nothing but punctuation between.
384
461
  if (POSTAL_LABEL_TOUCHING.test(before))
@@ -390,13 +467,13 @@ function yearSitsInAddressStructure(text, start, end) {
390
467
  // 12, rustige ligging in 2018, vlakbij openbaar vervoer." is an Antwerp
391
468
  // postal code in prose; the false positives put the year and the address in
392
469
  // different sentences.
393
- return POSTAL_CONTEXT_NEAR.test(sentenceAround(text, start, end));
470
+ return POSTAL_CONTEXT_NEAR.test(sentenceAround(text, start, end).replace(MASKED_VALUE, " "));
394
471
  }
395
472
  function suppressYearAsPostal(text, match) {
396
473
  if (match.patternDef.entityType !== types_js_1.EntityType.POSTAL_CODE)
397
474
  return false;
398
475
  const clean = match.text.trim();
399
- if (!RECENT_YEAR.test(clean))
476
+ if (!DATE_YEAR.test(clean))
400
477
  return false;
401
478
  // A date construction *touching* the candidate settles it, whatever the rest
402
479
  // of the document says. Without this, the postal-context test below rescued
@@ -408,6 +485,8 @@ function suppressYearAsPostal(text, match) {
408
485
  return true;
409
486
  if (DATE_AFTER.test(text.slice(match.end, match.end + 8)))
410
487
  return true;
488
+ if (!RECENT_YEAR.test(clean))
489
+ return false;
411
490
  const [before, after] = getContext(text, match.start, match.end);
412
491
  const context = before + after;
413
492
  // Keep as postal code only when the value sits in address *structure* --
@@ -415,10 +494,6 @@ function suppressYearAsPostal(text, match) {
415
494
  // words was the defect rather than the keyword list.
416
495
  if (yearSitsInAddressStructure(text, match.start, match.end))
417
496
  return false;
418
- // Keep if preceded by comma+space (address pattern: "Amsterdam, 2026")
419
- const immediateBefore = text.slice(Math.max(0, match.start - 3), match.start);
420
- if (/,\s*$/.test(immediateBefore))
421
- return false;
422
497
  // Suppress if date keyword nearby (birth date, employment date, etc.)
423
498
  if (DATE_KEYWORD_NEAR.test(context))
424
499
  return true;
@@ -461,26 +536,155 @@ const TOWN_AFTER = /^\s*[A-ZÀ-ÞĀ-Ž]/;
461
536
  * between the marker and the number, so allow exactly one.
462
537
  */
463
538
  const EXTENSION_BEFORE = /(?<![A-Za-z0-9_])(?:toest(?:el)?|ext|extension|poste|durchwahl|doorkiesnummer|tst|nebenstelle)\.?\s*(?:[a-zà-ÿ]{2,10}\s*)?[:.]?\s*$/i;
539
+ /**
540
+ * What else a bare digit run after these is (rules-engine#111): a head count
541
+ * ("Employees: 214"), a standard ("ISO/IEC 27001"), a product model with an
542
+ * inner capital ("Dell OptiPlex 7090"). Not the digits after "[PHONE]": the
543
+ * rest of a number an earlier pass cut short, safer masked as a postcode.
544
+ */
545
+ const COUNT_LABEL_BEFORE = /(?<![A-Za-z0-9_])(?:employees|headcount|staff|mitarbeiter(?:innen)?|beschäftigte|werknemers|medewerkers|employés|salariés|effectifs?|dipendenti|empleados|pracowników|zaměstnanců)\s*[:=]?\s*$/i;
546
+ const NOT_POSTAL_BEFORE = /(?<![A-Za-z0-9_])(?:(?:ISO(?:\/IEC)?|IEC|EN|DIN(?:\s+EN)?|NEN(?:-EN)?|NF\s+EN|BS(?:\s+EN)?|PN-EN|ÖNORM)[\s\-]*|[A-Za-z]*[a-z][A-Z][A-Za-z]*\s+)$/;
547
+ /**
548
+ * What else a number is, by what touches it (rules-engine#126): a build,
549
+ * version, event, lease, policy or ticket number; a port; an invoice, reference
550
+ * or study-subject number; a process id; a JSON id or port field; an HTTP
551
+ * response size; the hour after a date; a lab reference range; or a quantity
552
+ * with its unit after it. Kept in sync with the Python SDK.
553
+ */
554
+ const TECH_WORD_BEFORE = /(?<![A-Za-z0-9_])(?:build|version|policy\s+id|ticket(?:\s+[A-Za-z]{2,10})?|event|lease|pid|job|port|rfc|facture|invoice|r[ée]f[ée]rence|n[°º]|tn|sae|uczestnik|subject|narratief|participant)\.?\s*[:#=]?\s*["(«[]?\s*$/i;
555
+ const TECH_SHAPE_BEFORE = new RegExp("(?:" + [
556
+ String.raw `"[A-Za-z_]*(?:id|port)"\s*:\s*"?`, // "parent_id": 4400
557
+ String.raw `(?<![A-Za-z0-9])[A-Z_]*PORT\s*[:=]\s*"?`, // DB_PORT: "5432"
558
+ String.raw `(?:localhost|[a-z0-9\-]+\.[a-z0-9.\-]+):`, // localhost:3000
559
+ String.raw `(?<![\d.])\d{2,5}:`, // "8080:8080"
560
+ String.raw `HTTP\/\d(?:\.\d)?"?\s+\d{3}\s+`, // HTTP/1.1" 200 2048
561
+ String.raw `\d+\.\d+(?:\.\d+)+\s*\(`, // build 2.4.0 (4012)
562
+ String.raw `(?<!\d)\d{1,2}[./]\d{1,2}[./]`, // 03.10.2026 23:50
563
+ String.raw `\((?:N|norm(?:al)?|ref)\s*:?\s*`, // (N : 30-400)
564
+ String.raw `\|`,
565
+ ].join("|") + ")$", "i");
566
+ const POSTAL_UNIT_AFTER = /^(?:\s?(?:ms|s|sec|secs|seconds?|secondes?|sekunden|sek|min|bits?|bytes?|kb|mb|gb|tb|kbps|mbps|px|%|records|rows|entries|datensätze|einträge|micrograms|µg|ug|mg|mmol|fl)(?![A-Za-zÀ-ÿ])|\s*→\s*\d+\s*(?:bits?|bytes?)(?![A-Za-z])|:\d|\|)/i;
567
+ const PID_BEFORE = /[A-Za-z0-9_\-]\[$/;
568
+ /** A standard's name as the letters of a lettered postcode: "RFC 5737". Not
569
+ * BS or EN: those are Bristol and Enfield. */
570
+ const STANDARD_LETTERS = /^(?:RFC|ISO|IEC|DIN)\s?\d/;
571
+ /** A year closing a range: "1998–2011". En and em dashes only. */
572
+ const YEAR_RANGE_BEFORE = /(?:19|20)\d\d\s?[–—]\s?$/;
573
+ const YEAR_RANGE_AFTER = /^\s?[–—]\s?(?:19|20)\d\d(?!\d)/;
574
+ /** A thousands group of an amount: the "750" of "£28,750,000". */
575
+ const GROUP_BEFORE = /\d[,.]$/;
576
+ const GROUP_AFTER = /^[,.]\d{3}(?!\d)/;
464
577
  /** Suppress a postal code the word in front of it rules out. */
465
578
  function suppressPostalAfterDisqualifyingWord(text, match) {
466
579
  if (match.patternDef.entityType !== types_js_1.EntityType.POSTAL_CODE)
467
580
  return false;
468
581
  const before = text.slice(Math.max(0, match.start - 24), match.start);
469
- if (EXTENSION_BEFORE.test(before))
582
+ if (EXTENSION_BEFORE.test(before) || COUNT_LABEL_BEFORE.test(before)
583
+ || NOT_POSTAL_BEFORE.test(before))
584
+ return true;
585
+ if (GROUP_BEFORE.test(before) && GROUP_AFTER.test(text.slice(match.end, match.end + 5)))
586
+ return true;
587
+ const after = text.slice(match.end, match.end + 24);
588
+ if (TECH_WORD_BEFORE.test(before) || TECH_SHAPE_BEFORE.test(before) || POSTAL_UNIT_AFTER.test(after)
589
+ || STANDARD_LETTERS.test(match.text))
590
+ return true;
591
+ if (PID_BEFORE.test(before) && after.startsWith("]"))
592
+ return true;
593
+ if (DATE_YEAR.test(match.text.trim()) && (YEAR_RANGE_BEFORE.test(before) || YEAR_RANGE_AFTER.test(after)))
470
594
  return true;
471
595
  return RECENT_YEAR.test(match.text.trim())
472
596
  && (YEAR_WORD_BEFORE.test(before) || YEAR_VERB_BEFORE.test(before))
473
597
  && !TOWN_AFTER.test(text.slice(match.end, match.end + 3));
474
598
  }
599
+ /** A group with a leading zero: "2.00.079.01" is a version, not an address. */
600
+ const IP_LEADING_ZERO = /(?:^|\.)0\d/;
601
+ const CIDR_PREFIX_AFTER = /^\/(\d{1,2})(?!\d)/;
602
+ /** RFC 5737's documentation ranges: reserved, never routed, used in examples. */
603
+ const DOCUMENTATION_NETS = ["192.0.2.", "198.51.100.", "203.0.113."];
604
+ /**
605
+ * An IPv4 shape that names no host (rules-engine#126): a network address
606
+ * ("10.40.0.0/16"), a documentation address, or a version with leading zeros.
607
+ * An interface address with its prefix ("192.168.1.23/24") stays.
608
+ */
609
+ function suppressIpNotAHost(text, match) {
610
+ if (match.patternDef.entityType !== types_js_1.EntityType.IP_ADDRESS)
611
+ return false;
612
+ const ip = match.text;
613
+ if (IP_LEADING_ZERO.test(ip) || DOCUMENTATION_NETS.some((n) => ip.startsWith(n)))
614
+ return true;
615
+ // Part of a longer dotted run: "urn:oid:1.2.250.1.71.4.2.1" is an OID.
616
+ if (/\d\.$/.test(text.slice(Math.max(0, match.start - 2), match.start))
617
+ || /^\.\d/.test(text.slice(match.end, match.end + 2)))
618
+ return true;
619
+ const prefix = CIDR_PREFIX_AFTER.exec(text.slice(match.end, match.end + 4));
620
+ if (prefix !== null && Number(prefix[1]) < 32) {
621
+ const value = ip.split(".").reduce((acc, o) => acc * 256 + Number(o), 0);
622
+ return value % 2 ** (32 - Number(prefix[1])) === 0;
623
+ }
624
+ return false;
625
+ }
626
+ /**
627
+ * The specimen of a form: "das Muster „C01X00T47“". Only "Muster": "z. B." and
628
+ * "e.g." introduce real values in prose as often as samples (rules-engine#126).
629
+ */
630
+ const EXAMPLE_BEFORE = /(?<![A-Za-z])muster\s*[:,]?\s*[„"«']?$/i;
631
+ /** A dummy value in placeholder glyphs ("XX0000000", "999 999 9999") or MAC
632
+ * groups of one repeated letter. Not "111222333", the standard test BSN. */
633
+ const DUMMY_VALUE = /^(?=(?:[\s:\-./]*[X09]){5})(?:[X\s:\-./]*[X][X0\s:\-./]*|[X9\s:\-./]*X[X9\s:\-./]*|[0\s:\-./]+|[9\s:\-./]+)$|^(?:([a-fA-F])\1[:\-]){2,}([a-fA-F])\2$/;
634
+ /** Algorithm names in a key or signature line: "ED25519", "SHA256". */
635
+ const ALGORITHM = /^(?:ED25519|ED448|SHA-?(?:1|224|256|384|512)|MD5|AES-?\d{3}|RSA-?\d{3,4}|[HRE]S(?:256|384|512))$/i;
636
+ /** A date exactly, which no national number is written as. */
637
+ const EXACT_DOTTED_DATE = /^(?:0[1-9]|[12]\d|3[01])\.(?:0[1-9]|1[0-2])\.(?:19|20)\d\d$/;
638
+ /** The identifier types an example marker or a dummy value rules out. */
639
+ const EXAMPLE_TYPES = new Set([
640
+ types_js_1.EntityType.NATIONAL_ID, types_js_1.EntityType.PASSPORT, types_js_1.EntityType.LICENSE_PLATE,
641
+ types_js_1.EntityType.HEALTH_INSURANCE, types_js_1.EntityType.MAC_ADDRESS,
642
+ types_js_1.EntityType.DRIVERS_LICENSE, types_js_1.EntityType.SSN, types_js_1.EntityType.TAX_ID,
643
+ ]);
644
+ /** A placeholder in the shape of a value, for any type (#126). */
645
+ function suppressDummyValue(_text, match) {
646
+ return DUMMY_VALUE.test(match.text.trim());
647
+ }
648
+ /** An identifier given as a form's specimen, not anyone's (#126). */
649
+ function suppressExampleValue(text, match) {
650
+ if (!EXAMPLE_TYPES.has(match.patternDef.entityType))
651
+ return false;
652
+ return EXAMPLE_BEFORE.test(text.slice(Math.max(0, match.start - 24), match.start));
653
+ }
654
+ /**
655
+ * Shapes that fit an identifier pattern and are something else (#126): a
656
+ * passport without a digit, an algorithm or a law citation read as a plate,
657
+ * a date read as a dotted national number.
658
+ */
659
+ function suppressNotAnIdentifier(text, match) {
660
+ const type = match.patternDef.entityType;
661
+ const value = match.text.trim();
662
+ if (type === types_js_1.EntityType.PASSPORT)
663
+ return !/\d/.test(value);
664
+ if (type === types_js_1.EntityType.LICENSE_PLATE) {
665
+ return ALGORITHM.test(value) || /§\s*$/.test(text.slice(Math.max(0, match.start - 4), match.start));
666
+ }
667
+ if (type === types_js_1.EntityType.NATIONAL_ID)
668
+ return EXACT_DOTTED_DATE.test(value);
669
+ return false;
670
+ }
475
671
  function suppressPhoneServiceNumber(_text, match) {
476
672
  if (match.patternDef.entityType !== types_js_1.EntityType.PHONE)
477
673
  return false;
478
674
  return SERVICE_NUMBER.test(match.text);
479
675
  }
480
- function suppressPhoneDateOverlap(_text, match) {
676
+ // A date and the hour of a time, "07.10.2026 08" out of "07.10.2026 08:42":
677
+ // the French phone pattern took it as five pairs (rules-engine#101). Only when
678
+ // the minutes follow, so the stamp is certain.
679
+ const DATE_THEN_HOUR = /^\d{2}([-/.])\d{2}\1\d{4}\s+\d{1,2}$/;
680
+ const MINUTES_AFTER = /^:\d{2}(?!\d)/;
681
+ function suppressPhoneDateOverlap(text, match) {
481
682
  if (match.patternDef.entityType !== types_js_1.EntityType.PHONE)
482
683
  return false;
483
- return DATE_PATTERN_FULL.test(match.text.trim());
684
+ const candidate = match.text.trim();
685
+ if (DATE_PATTERN_FULL.test(candidate))
686
+ return true;
687
+ return DATE_THEN_HOUR.test(candidate) && MINUTES_AFTER.test(text.slice(match.end, match.end + 3));
484
688
  }
485
689
  // Standards and classification prefixes that are plate-shaped once a letter and
486
690
  // digits follow: "ATC-N06", "ICD-O3". None is a German district code, but the
@@ -493,6 +697,16 @@ const NOT_CITY_CODES = new Set([
493
697
  "BE", "DE", "EU", "NL", "LU", "WS", "SS", "IP",
494
698
  ]);
495
699
  const CURRENCY_PLATE = /^(?:EUR|USD|GBP|CHF|SEK|NOK|DKK|ISK|CZK|PLN|HUF|RON|BGN|HRK)\s/i;
700
+ const ALNUM = /[\p{L}\p{N}]/u;
701
+ /** Characters that join a token to more of an identifier (rules-engine#50, #81). */
702
+ const ID_CONNECTORS = "-/._+";
703
+ /** A reference marker directly before the token: "#FR-S2", "№ …", "n° …". */
704
+ const REFERENCE_MARK_BEFORE = /(?:#|№|(?<![A-Za-z])[nN][°º])\s?$/;
705
+ /** A connector glues [start, end) to a letter or digit outside it. */
706
+ function joinedToMore(text, start, end) {
707
+ return ((end + 1 < text.length && ID_CONNECTORS.includes(text[end]) && ALNUM.test(text[end + 1])) ||
708
+ (start >= 2 && ID_CONNECTORS.includes(text[start - 1]) && ALNUM.test(text[start - 2])));
709
+ }
496
710
  function suppressPlateInCompound(text, match) {
497
711
  if (match.patternDef.entityType !== types_js_1.EntityType.LICENSE_PLATE)
498
712
  return false;
@@ -504,6 +718,18 @@ function suppressPlateInCompound(text, match) {
504
718
  if (/[A-Za-zÄÖÜäöüß]{2,}-$/.test(before))
505
719
  return true;
506
720
  }
721
+ // A plate is a token of its own: joined by a connector to more of an
722
+ // identifier ("TF-284-KL-00874", "FR-S2/2026") it is a segment of a reference
723
+ // (rules-engine#50, #81). A spaced dash or slash does not join.
724
+ if (joinedToMore(text, match.start, match.end))
725
+ return true;
726
+ // After a reference marker ("Ref #FR-S2") it is a reference, unless a plate
727
+ // cue says otherwise (rules-engine#81).
728
+ if (REFERENCE_MARK_BEFORE.test(text.slice(Math.max(0, match.start - 4), match.start))) {
729
+ const [b, a] = getContext(text, match.start, match.end);
730
+ if (!PLATE_CUE_NEAR.test(b + a))
731
+ return true;
732
+ }
507
733
  const matched = match.text.trim();
508
734
  const parts = matched.split(/[\s\-]+/);
509
735
  // A standards or classification reference, not a plate — unless a plate cue
@@ -553,7 +779,11 @@ function suppressDePlateUnknownDistrict(text, match) {
553
779
  const parts = match.text.trim().split(/[\s\-]+/);
554
780
  if (parts.length === 0 || !parts[0])
555
781
  return false;
556
- if (deDistricts_js_1.DE_DISTRICT_CODES.has(parts[0].toUpperCase()))
782
+ // A German plate number never starts with 0: without a plate cue,
783
+ // "GR-NB-0412" is an asset tag and "HR-IT-03" a form (rules-engine#126).
784
+ const digits = /\d+/.exec(match.text);
785
+ const leadingZero = digits !== null && digits[0].startsWith("0");
786
+ if (deDistricts_js_1.DE_DISTRICT_CODES.has(parts[0].toUpperCase()) && !leadingZero)
557
787
  return false;
558
788
  const [before, after] = getContext(text, match.start, match.end);
559
789
  if (PLATE_CUE_NEAR.test(before + after))
@@ -834,10 +1064,34 @@ function suppressBicWithoutEvidence(text, match, scratch) {
834
1064
  // The token itself is blanked out so it cannot vouch for itself.
835
1065
  const unit = structuralUnit(text, match.start, match.end).split(token).join(" ".repeat(token.length));
836
1066
  if (BIC_KEYWORD.test(unit) || IBAN_SHAPE.test(unit) || BANK_BLOCK.test(unit)) {
1067
+ if (bicShapedWord(text, match.start, match.end, token))
1068
+ return true;
837
1069
  return occursAsLowercaseWord(text, token, scratch);
838
1070
  }
839
1071
  return true;
840
1072
  }
1073
+ /** A personal title right before the token: "Dr. HOFFMANN", "Mme JANSSENS". */
1074
+ const TITLE_BEFORE = /(?<![A-Za-z])(?:dr|prof|mr|mrs|ms|mme|mlle|m|herr|frau|dhr|mevr|sig|sra?)\.?\s+$/i;
1075
+ const LETTER = /\p{L}/u;
1076
+ /** A BIC/SWIFT label touching the token: "BIC: ", "SWIFT-Code:\n", "Code SWIFT : ", "BIC Bank A: ". */
1077
+ const BIC_LABEL_BEFORE = /(?<![A-Za-z])(?:bic|swift)(?:[\s/\-]*(?:code|swift|bic))?(?:\s+bank\s+\w{1,3})?[\s:.\-()]*$/i;
1078
+ /**
1079
+ * A registry miss sitting where only a word or a name can (rules-engine#57):
1080
+ * joined to a word by a hyphen ("NGUYEN-HOFFMANN"), right after a personal
1081
+ * title ("Dr. HOFFMANN"), or letters only (eight, or eleven without an `XXX`
1082
+ * branch: "BETALING", "MAANDELIJKS") that the GLEIF mapping does not know and
1083
+ * no BIC/SWIFT label touches.
1084
+ */
1085
+ function bicShapedWord(text, start, end, token) {
1086
+ if ((start >= 2 && text[start - 1] === "-" && LETTER.test(text[start - 2])) ||
1087
+ (text[end] === "-" && end + 1 < text.length && LETTER.test(text[end + 1])))
1088
+ return true;
1089
+ if (TITLE_BEFORE.test(text.slice(Math.max(0, start - 8), start)))
1090
+ return true;
1091
+ return /^[A-Za-z]+$/.test(token) && !(token.length === 11 && token.endsWith("XXX"))
1092
+ && !(0, bicRegistry_js_1.isKnownInstitution)(token)
1093
+ && !BIC_LABEL_BEFORE.test(text.slice(Math.max(0, start - 30), start));
1094
+ }
841
1095
  // ── Postal code: digits belonging to a longer identifier ────────────────
842
1096
  // An identifier label immediately before the digits. A postal code is never
843
1097
  // introduced this way; an SVNr, policy number or service number always is.
@@ -857,7 +1111,14 @@ const DIALLING_PREFIX_BEFORE = /\+\d{1,3}[\d\s\-().]*$/;
857
1111
  // read as a country-prefixed address: in "PV-2026-LU-09143" the `LU` is
858
1112
  // preceded by `-` and the German five-digit postal pattern kept `09143`
859
1113
  // (rules-engine#31).
860
- const COUNTRY_PREFIXED = /(?:^|[^A-Za-z0-9\-])[A-Z]{1,2}$/;
1114
+ const COUNTRY_PREFIXED = /(?:^|[^A-Za-z0-9\-])([A-Z]{1,3})$/;
1115
+ // Postal country prefixes and the length of that country's postcode: any one or two capitals
1116
+ // used to pass, so "K-40817" and "B-51823" were country-prefixed postcodes (rules-engine#111).
1117
+ const POSTAL_PREFIX_DIGITS = { A: 4, AT: 4, B: 4, BE: 4, BG: 4, CH: 4, CY: 4, CZ: 5, D: 5, DE: 5, DK: 4, E: 5, EE: 5, ES: 5, F: 5, FI: 5, FIN: 5, FR: 5, GR: 5, H: 4, HR: 5, I: 5, IT: 5, L: 4, LT: 5, LU: 4, LV: 4, N: 4, NL: 4, NO: 4, P: 4, PL: 5, PT: 4, RO: 6, S: 5, SE: 5, SI: 4, SK: 5, SLO: 4 };
1118
+ function countryPrefixed(before, digits) {
1119
+ const m = COUNTRY_PREFIXED.exec(before);
1120
+ return m !== null && POSTAL_PREFIX_DIGITS[m[1]] === digits.length;
1121
+ }
861
1122
  /**
862
1123
  * Suppress bare digit runs that belong to a longer number, not an address.
863
1124
  *
@@ -873,8 +1134,15 @@ function suppressPostalInLongerIdentifier(text, match) {
873
1134
  if (match.patternDef.entityType !== types_js_1.EntityType.POSTAL_CODE)
874
1135
  return false;
875
1136
  const clean = match.text.trim();
876
- if (!/^\d+$/.test(clean))
1137
+ if (!/^\d+$/.test(clean)) {
1138
+ // A structured form glued on by a hyphen is the tail of a reference: "CPS-HR-2026-019" (#111).
1139
+ const glued = text.slice(Math.max(0, match.start - 2), match.start);
1140
+ if (glued.length === 2 && glued[1] === "-" && /[A-Za-z0-9]/.test(glued[0])) {
1141
+ const m = COUNTRY_PREFIXED.exec(text.slice(0, match.start - 1));
1142
+ return !(m && m[1] in POSTAL_PREFIX_DIGITS);
1143
+ }
877
1144
  return false;
1145
+ }
878
1146
  const beforeChar = match.start > 0 ? text[match.start - 1] : "";
879
1147
  const afterChar = match.end < text.length ? text[match.end] : "";
880
1148
  const prevPrev = match.start >= 2 ? text[match.start - 2] : "";
@@ -893,7 +1161,10 @@ function suppressPostalInLongerIdentifier(text, match) {
893
1161
  return true;
894
1162
  if (afterChar === "-" && /\d/.test(nextNext))
895
1163
  return true;
896
- if (beforeChar === "-" && !COUNTRY_PREFIXED.test(text.slice(0, match.start - 1)))
1164
+ // A hyphenated chain goes on after it: "réf. : L-2025-HD-0412" is a reference (#111).
1165
+ if (/^-[A-Za-z0-9]+-/.test(text.slice(match.end, match.end + 12)))
1166
+ return true;
1167
+ if (beforeChar === "-" && !countryPrefixed(text.slice(0, match.start - 1), clean))
897
1168
  return true;
898
1169
  // A further digit group on the same line: "1268 040390", "1234 5678 925",
899
1170
  // "+43 664 8213 907". Horizontal whitespace only — a digit on the *next*
@@ -954,12 +1225,186 @@ function suppressRequiresContext(text, match) {
954
1225
  return false;
955
1226
  // A value filling an entire field of a delimited row is introduced by its
956
1227
  // column, not by a word, so that is context — but only for narrow shapes.
1228
+ // A date's column decides when the table names it: under "début" or
1229
+ // "last_day" a date is not a birth date (rules-engine#126).
957
1230
  if (DELIMITED_FIELD_TYPES.has(match.patternDef.entityType) &&
958
- fillsADelimitedField(text, match.start, match.end))
1231
+ fillsADelimitedField(text, match.start, match.end) &&
1232
+ !(DATE_LABEL_TYPES.has(match.patternDef.entityType) && columnHeader(text, match.start) !== null))
959
1233
  return false;
1234
+ if (DATE_LABEL_TYPES.has(match.patternDef.entityType)) {
1235
+ return !labelsThisDate(text, match.start, match.end, match.patternDef.contextKeywords, match.patternDef.entityType);
1236
+ }
960
1237
  const [before, after] = getContext(text, match.start, match.end);
961
1238
  const context = (before + " " + after).toLowerCase();
962
- return !match.patternDef.contextKeywords.some(kw => context.includes(kw.toLowerCase()));
1239
+ if (match.patternDef.contextKeywords.some(kw => context.includes(kw.toLowerCase())))
1240
+ return false;
1241
+ // The column names it: a passport under "Passport" rows below the header (#130).
1242
+ const header = columnHeader(text, match.start);
1243
+ return header === null || !match.patternDef.contextKeywords.some(kw => keywordAt(header, kw, false) >= 0);
1244
+ }
1245
+ /** Date types decided by a label: DOB and DATE_OF_DEATH share one date shape. */
1246
+ const DATE_LABEL_TYPES = new Set([types_js_1.EntityType.DOB, types_js_1.EntityType.DATE_OF_DEATH]);
1247
+ /** Any common numeric date, to tell whether a label belongs to another date. */
1248
+ // A masked date counts as one: the gateway's rules pass reads SDK-masked text (rules-engine#111).
1249
+ // ... and a written-month date, now a DOB shape of its own (rules-engine#115).
1250
+ const ANY_DATE = new RegExp(String.raw `\d{1,4}[/.\-]\d{1,2}[/.\-]\d{2,4}|\[(?:dob|date_of_death)\]|(?<![A-Za-z0-9_])\d{1,2}\.?\s+(?:` + exports.MONTH_NAMES + String.raw `)\.?\s+\d{4}(?![A-Za-z0-9_])`, "i");
1251
+ /**
1252
+ * True when one of `keywords` is this date's own label: before the date with no
1253
+ * other date between, or after it inside the date's own sentence. Anywhere in
1254
+ * the window typed every date from whichever date label the document carried
1255
+ * (rules-engine#52).
1256
+ */
1257
+ /** A date followed by a list separator: an item of the list a label introduces. */
1258
+ const LIST_ITEM = /\d{1,4}[/.\-]\d{1,2}[/.\-]\d{2,4}\s*(?:,|;|–|&|\band\b|\ben\b|\bet\b|\bund\b|\bor\b|\bof\b|\bou\b|\boder\b|\brespectievelijk\b|\bbzw\.?)\s*/g;
1259
+ /** Punctuation and at most two short words ("e.g.", "bv.", "op") between label and date. */
1260
+ const LABEL_TAIL = /^[\s:.,()\-]*(?:\p{L}{1,4}(?:\.\p{L}{1,2})*\.?(?:[\s:.,()\-]+|$)){0,2}$/u;
1261
+ /** The birth sign "°" directly before the date, but not the number sign "n°". */
1262
+ const BIRTH_SIGN = /(?<![a-z])°\s?$/;
1263
+ /** No other date between a label and the date, except items of its list. */
1264
+ function nothingButAListBetween(gap) {
1265
+ if (!ANY_DATE.test(gap))
1266
+ return true;
1267
+ const rest = gap.replace(LIST_ITEM, " ");
1268
+ return !ANY_DATE.test(rest) && LABEL_TAIL.test(rest);
1269
+ }
1270
+ /**
1271
+ * Where `keyword` occurs in the lower-cased `haystack`, or -1. An all-capitals
1272
+ * keyword is an abbreviation and counts only as a word of its own: "DOB" is a
1273
+ * label, the "dob" in Polish "dobrowolnej" is not (rules-engine#100); a plural
1274
+ * "s" may follow ("DOBs e.g. …"). Other
1275
+ * keywords still match inside a longer word, which inflected forms need.
1276
+ */
1277
+ function keywordAt(haystack, keyword, last) {
1278
+ const kw = keyword.toLowerCase();
1279
+ const isLetter = (c) => c !== "" && c.toLowerCase() !== c.toUpperCase();
1280
+ if (keyword !== keyword.toUpperCase() || keyword === kw) {
1281
+ // It must start a word, though it may run on into a longer one: "née le"
1282
+ // in "signée le" and "born" in "Newborn" are other words (rules-engine#126).
1283
+ let i = last ? haystack.lastIndexOf(kw) : haystack.indexOf(kw);
1284
+ while (i > 0 && isLetter(haystack[i - 1])) {
1285
+ i = last ? (i === 0 ? -1 : haystack.lastIndexOf(kw, i - 1)) : haystack.indexOf(kw, i + 1);
1286
+ }
1287
+ return i;
1288
+ }
1289
+ const hits = [];
1290
+ for (let i = haystack.indexOf(kw); i !== -1; i = haystack.indexOf(kw, i + 1)) {
1291
+ let next = i + kw.length;
1292
+ if (haystack[i - 1] === "[" && haystack[next] === "]")
1293
+ continue; // a placeholder ("[DOB]"), not a label (#111)
1294
+ if (haystack[next] === "s")
1295
+ next++;
1296
+ if (!isLetter(haystack.slice(i - 1, i)) && !isLetter(haystack.slice(next, next + 1))) {
1297
+ hits.push(i);
1298
+ }
1299
+ }
1300
+ if (hits.length === 0)
1301
+ return -1;
1302
+ return last ? hits[hits.length - 1] : hits[0];
1303
+ }
1304
+ function labelsThisDate(text, start, end, keywords, entityType) {
1305
+ const [before, after] = getContext(text, start, end);
1306
+ const lowerBefore = before.toLowerCase();
1307
+ const lowerAfter = after.toLowerCase();
1308
+ // A label on an earlier line counts only when the date opens its own line or
1309
+ // the label was asked as a question (a call transcript's answer turn);
1310
+ // a column header is a fallback and never overrules a label of the date's own.
1311
+ const lineStart = lowerBefore.lastIndexOf("\n") + 1;
1312
+ const opensLine = lowerBefore.slice(lineStart).replace(/[ \t:|;*\-•(\[]/g, "") === "";
1313
+ if (entityType === types_js_1.EntityType.DOB && BIRTH_SIGN.test(lowerBefore))
1314
+ return true;
1315
+ for (const keyword of keywords) {
1316
+ const kw = keyword.toLowerCase();
1317
+ const i = keywordAt(lowerBefore, keyword, true);
1318
+ if (i >= 0 && nothingButAListBetween(lowerBefore.slice(i + kw.length))
1319
+ && (i >= lineStart || opensLine
1320
+ || lowerBefore.slice(i + kw.length, lineStart).includes("?")))
1321
+ return true;
1322
+ const j = keywordAt(lowerAfter, keyword, false);
1323
+ if (j >= 0) {
1324
+ const gap = lowerAfter.slice(0, j);
1325
+ if (!ANY_DATE.test(gap) && !SENTENCE_BREAK.test(gap)
1326
+ && !LEADS_TO_A_DATE.test(lowerAfter.slice(j + kw.length)))
1327
+ return true;
1328
+ }
1329
+ }
1330
+ const header = columnHeader(text, start);
1331
+ return header !== null && keywords.some(kw => keywordAt(header, kw, false) >= 0);
1332
+ }
1333
+ /**
1334
+ * A label running straight into a date of its own introduces that date, not the
1335
+ * one before it ("Verstorben am 01.02.2020, geboren am 12.03.1940").
1336
+ */
1337
+ // ... or into a value already masked ("né le [DOB]"): the label is that value's (rules-engine#111).
1338
+ const LEADS_TO_A_DATE = new RegExp(String.raw `^[\s:.\-]*(?:[a-zà-ÿ]{1,3}[\s:.]+)?(?:\d{1,4}[/.\-]\d{1,2}[/.\-]\d{2,4}|\[[a-z_]+\]|\d{1,2}\.?\s+(?:` + exports.MONTH_NAMES + String.raw `)\.?\s+\d{4})`, "i");
1339
+ /** Field separators a table row may use. */
1340
+ const COLUMN_SEPARATORS = [";", "|", "\t", ","];
1341
+ /**
1342
+ * The lower-cased header cell above the value, if it sits in a table: in a table
1343
+ * the column decides the label, not proximity. A header is one of the three
1344
+ * lines above, split by the row's separator into the same number of cells.
1345
+ */
1346
+ function columnHeader(text, start) {
1347
+ const lineStart = text.lastIndexOf("\n", start - 1) + 1;
1348
+ const lineEndAt = text.indexOf("\n", start);
1349
+ const line = text.slice(lineStart, lineEndAt >= 0 ? lineEndAt : text.length);
1350
+ for (const sep of COLUMN_SEPARATORS) {
1351
+ if (!line.includes(sep))
1352
+ continue;
1353
+ const cells = line.split(sep);
1354
+ const column = text.slice(lineStart, start).split(sep).length - 1;
1355
+ // Lines above, one lastIndexOf at a time: one of the three lines above, or
1356
+ // further up through rows of the same table (rules-engine#126).
1357
+ let end = lineStart - 1;
1358
+ let seen = 0;
1359
+ while (end >= 0 && seen < HEADER_REACH) {
1360
+ const begin = end === 0 ? 0 : text.lastIndexOf("\n", end - 1) + 1;
1361
+ const header = text.slice(begin, end);
1362
+ seen++;
1363
+ // A rule line ("|---|---|", "----+----") is neither header nor row (#130).
1364
+ if (RULE_LINE.test(header)) {
1365
+ end = begin - 1;
1366
+ continue;
1367
+ }
1368
+ const heads = header.split(sep);
1369
+ const same = heads.length === cells.length;
1370
+ // A "label : value" field with a comma in its value is not a header
1371
+ // (#111); a commented header ("# Spalten: Name;von;bis") is (#126).
1372
+ // Nor is a data row without a date: a header has no cell with four digits or more (#130).
1373
+ if (same && (!header.includes(":") || header.trimStart().startsWith("#")) && !ANY_DATE.test(header)
1374
+ && !heads.some((h) => (h.match(/\d/g) ?? []).length >= 4)) {
1375
+ return heads[column].toLowerCase();
1376
+ }
1377
+ if (seen >= 3 && !same)
1378
+ break; // past the table this value sits in
1379
+ end = begin - 1;
1380
+ }
1381
+ }
1382
+ return null;
1383
+ }
1384
+ /** How many lines up a table's header is looked for. */
1385
+ const HEADER_REACH = 200;
1386
+ /** A table's rule line: only dashes, equals signs, plus signs, colons, pipes. */
1387
+ const RULE_LINE = /^[\s\-=+:|]*-[\s\-=+:|]*$/;
1388
+ /** A dotted MAC shape glued to more of a reference is part of it: the tail of
1389
+ * "WSO-II.6151.4471.2025" (rules-engine#105). Dotted form only. */
1390
+ function suppressMacInReference(text, match) {
1391
+ if (match.patternDef.entityType !== types_js_1.EntityType.MAC_ADDRESS || !match.text.includes("."))
1392
+ return false;
1393
+ return joinedToMore(text, match.start, match.end);
1394
+ }
1395
+ /** A card number is never carved out of a longer run of digit groups: a
1396
+ * four-digit group joined by the card's own separator on either side means the
1397
+ * run is longer, e.g. a Polish account number (rules-engine#106). */
1398
+ function suppressCardInsideDigitRun(text, match) {
1399
+ if (match.patternDef.entityType !== types_js_1.EntityType.CREDIT_CARD)
1400
+ return false;
1401
+ const sep = [...match.text].find(c => !/\d/.test(c)) ?? "";
1402
+ if (!sep)
1403
+ return false;
1404
+ const s = sep.replace(/[-\\^$.*+?()[\]{}|]/g, "\\$&");
1405
+ const before = text.slice(Math.max(0, match.start - 6), match.start);
1406
+ const after = text.slice(match.end, match.end + 6);
1407
+ return new RegExp(`(?<!\\d)\\d{4}${s}$`).test(before) || new RegExp(`^${s}\\d{4}(?!\\d)`).test(after);
963
1408
  }
964
1409
  const TYPE_SUPPRESSORS = {
965
1410
  [types_js_1.EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneInsideAccountRun, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
@@ -972,12 +1417,20 @@ const TYPE_SUPPRESSORS = {
972
1417
  [types_js_1.EntityType.LICENSE_PLATE]: [suppressPlateInCompound, suppressDePlateUnknownDistrict, suppressPlateAsCurrencyAmount],
973
1418
  [types_js_1.EntityType.SECRET]: [suppressSecretOverStructured, suppressSecretNotASecret],
974
1419
  [types_js_1.EntityType.CHAMBER_OF_COMMERCE]: [suppressReference],
1420
+ [types_js_1.EntityType.MAC_ADDRESS]: [suppressMacInReference],
1421
+ [types_js_1.EntityType.CREDIT_CARD]: [suppressCardInsideDigitRun],
1422
+ [types_js_1.EntityType.IP_ADDRESS]: [suppressIpNotAHost],
975
1423
  };
1424
+ for (const type of EXAMPLE_TYPES) {
1425
+ TYPE_SUPPRESSORS[type] = [...(TYPE_SUPPRESSORS[type] ?? []), suppressExampleValue, suppressNotAnIdentifier];
1426
+ }
976
1427
  function shouldSuppress(text, match, scratch) {
977
1428
  if (suppressSequential(text, match))
978
1429
  return true;
979
1430
  if (suppressRedactionPlaceholder(text, match))
980
1431
  return true;
1432
+ if (suppressDummyValue(text, match))
1433
+ return true;
981
1434
  const typeSups = TYPE_SUPPRESSORS[match.patternDef.entityType];
982
1435
  if (typeSups) {
983
1436
  for (const s of typeSups) {