@doccloak/core 0.8.0 → 0.9.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.
@@ -275,6 +275,175 @@ export const RULES_DATA = [
275
275
  "75008 Paris"
276
276
  ],
277
277
  "falsePositiveNotes": "May match non-address number+word sequences"
278
+ },
279
+ {
280
+ "id": "regex:universal:aws_access_key",
281
+ "entityType": "API_KEY",
282
+ "pattern": "\\b(?:AKIA|ASIA|ABIA|ACCA|AGPA|AIDA|AIPA|ANPA|ANVA|AROA)[0-9A-Z]{16}\\b",
283
+ "flags": "g",
284
+ "confidence": 0.95,
285
+ "domains": [
286
+ "technical"
287
+ ],
288
+ "description": "AWS access key ID (AKIA/ASIA + 16 uppercase alphanumeric)",
289
+ "examples": [
290
+ "AKIAIOSFODNN7EXAMPLE",
291
+ "ASIAJEXAMPLEKEY12345"
292
+ ]
293
+ },
294
+ {
295
+ "id": "regex:universal:github_token",
296
+ "entityType": "API_KEY",
297
+ "pattern": "\\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59})\\b",
298
+ "flags": "g",
299
+ "confidence": 0.95,
300
+ "domains": [
301
+ "technical"
302
+ ],
303
+ "description": "GitHub token (classic ghp_/gho_/ghu_/ghs_/ghr_ or fine-grained github_pat_)",
304
+ "examples": [
305
+ "ghp_16C7e42F292c6912E7710c838347Ae178B4a",
306
+ "github_pat_11ABCDEFG0123456789abc_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456"
307
+ ]
308
+ },
309
+ {
310
+ "id": "regex:universal:slack_token",
311
+ "entityType": "API_KEY",
312
+ "pattern": "\\bxox[abprs]-[A-Za-z0-9-]{10,64}\\b",
313
+ "flags": "g",
314
+ "confidence": 0.95,
315
+ "domains": [
316
+ "technical"
317
+ ],
318
+ "description": "Slack token (xoxb- bot, xoxp- user, xoxa-/xoxr-/xoxs- variants)",
319
+ "examples": [
320
+ "xoxb-NotARealBotToken-DocCloakTestFixture",
321
+ "xoxp-FakeUserToken-DocCloakTestFixture00"
322
+ ]
323
+ },
324
+ {
325
+ "id": "regex:universal:openai_api_key",
326
+ "entityType": "API_KEY",
327
+ "pattern": "\\bsk-(?:(?:proj|svcacct|admin)-[A-Za-z0-9_-]{40,}|[A-Za-z0-9]{40,})\\b",
328
+ "flags": "g",
329
+ "confidence": 0.92,
330
+ "domains": [
331
+ "technical"
332
+ ],
333
+ "description": "OpenAI API key (legacy sk- + 40+ alphanumeric, or sk-proj-/sk-svcacct-/sk-admin- project keys)",
334
+ "examples": [
335
+ "sk-proj-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEfGh",
336
+ "sk-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEfGhIjKl"
337
+ ],
338
+ "falsePositiveNotes": "Anthropic sk-ant- keys never reach the alphanumeric-only branch (hyphen after ant stops it); the dedicated anthropic rule wins overlap resolution via its longer prefix"
339
+ },
340
+ {
341
+ "id": "regex:universal:anthropic_api_key",
342
+ "entityType": "API_KEY",
343
+ "pattern": "\\bsk-ant-[A-Za-z0-9_-]{24,}\\b",
344
+ "flags": "g",
345
+ "confidence": 0.95,
346
+ "domains": [
347
+ "technical"
348
+ ],
349
+ "description": "Anthropic API key (sk-ant- prefix)",
350
+ "examples": [
351
+ "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-AbCdEfGh"
352
+ ]
353
+ },
354
+ {
355
+ "id": "regex:universal:google_api_key",
356
+ "entityType": "API_KEY",
357
+ "pattern": "\\bAIza[0-9A-Za-z_-]{35}\\b",
358
+ "flags": "g",
359
+ "confidence": 0.95,
360
+ "domains": [
361
+ "technical"
362
+ ],
363
+ "description": "Google API key (AIza + 35 characters)",
364
+ "examples": [
365
+ "AIzaSyD-9tSrke72PouQMnMX-a7eZSW0jkFMBWY"
366
+ ]
367
+ },
368
+ {
369
+ "id": "regex:universal:private_key_block",
370
+ "entityType": "SECRET",
371
+ "pattern": "-----BEGIN (?:[A-Z]+ )*PRIVATE KEY(?: BLOCK)?-----[\\s\\S]*?-----END (?:[A-Z]+ )*PRIVATE KEY(?: BLOCK)?-----",
372
+ "flags": "g",
373
+ "confidence": 0.99,
374
+ "domains": [
375
+ "technical"
376
+ ],
377
+ "description": "PEM private key block (RSA/EC/DSA/OPENSSH/ENCRYPTED/PGP), including SSH private keys",
378
+ "examples": [
379
+ "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0Z3VS5JJcds3xfn\n-----END RSA PRIVATE KEY-----",
380
+ "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmU\n-----END OPENSSH PRIVATE KEY-----"
381
+ ]
382
+ },
383
+ {
384
+ "id": "regex:universal:jwt",
385
+ "entityType": "SECRET",
386
+ "pattern": "\\beyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\b",
387
+ "flags": "g",
388
+ "confidence": 0.95,
389
+ "domains": [
390
+ "technical"
391
+ ],
392
+ "description": "JSON Web Token (three base64url segments, header starts with eyJ = base64 of '{\")",
393
+ "examples": [
394
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
395
+ ]
396
+ },
397
+ {
398
+ "id": "regex:universal:connection_string_creds",
399
+ "entityType": "SECRET",
400
+ "pattern": "\\b[a-zA-Z][a-zA-Z0-9+.-]{1,30}:\\/\\/[^\\s:@\\/]{1,64}:[^\\s@]{1,128}@[^\\s\"']{1,256}",
401
+ "flags": "g",
402
+ "confidence": 0.9,
403
+ "domains": [
404
+ "technical"
405
+ ],
406
+ "description": "Connection string with inline credentials (scheme://user:password@host)",
407
+ "examples": [
408
+ "postgres://admin:S3cr3tPass@db.example.com:5432/mydb",
409
+ "mongodb+srv://appuser:hunter2pass@cluster0.abc.mongodb.net/prod"
410
+ ],
411
+ "falsePositiveNotes": "Plain URLs without a user:password pair never match (the colon between user and password is required before the @)"
412
+ },
413
+ {
414
+ "id": "regex:universal:secret_assignment",
415
+ "entityType": "SECRET",
416
+ "pattern": "\\b[a-zA-Z0-9_-]{0,32}?(?:api[_-]?key|apikey|secret|token|passwd|password|credential|auth|access[_-]?key|client[_-]?secret|private[_-]?key|signing[_-]?key)[a-zA-Z0-9_-]{0,32}[\"']?\\s*[:=]>?\\s*[\"']?[A-Za-z0-9+\\/_=-]{16,}",
417
+ "flags": "gi",
418
+ "confidence": 0.75,
419
+ "domains": [
420
+ "technical"
421
+ ],
422
+ "description": "Secret-ish keyword assignment (api_key = ..., password: ..., aws_secret_access_key: ...) with a 16+ char value; the value is Shannon-entropy gated by the secretAssignment validator",
423
+ "examples": [
424
+ "api_key = \"9fQz3kX7Lm2Pw8Rt5Vy1Jh6Nd4Bg0Cs\"",
425
+ "aws_secret_access_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
426
+ "session_token=a3f8c2d914b7e605d2c8a1f4b9e7d305"
427
+ ],
428
+ "falsePositiveNotes": "The keyword and separator are part of the match (lookbehind is banned for new rules), so the config key name is redacted along with the value. Low-entropy placeholder values (your_api_key_here, changeme) are rejected by the validator.",
429
+ "validate": "secretAssignment"
430
+ },
431
+ {
432
+ "id": "regex:universal:high_entropy_token",
433
+ "entityType": "SECRET",
434
+ "pattern": "\\b[A-Za-z0-9][A-Za-z0-9+\\/]{39,}={0,2}",
435
+ "flags": "g",
436
+ "confidence": 0.6,
437
+ "domains": [
438
+ "technical"
439
+ ],
440
+ "description": "Bare high-entropy base64/alphanumeric token (40+ chars); the highEntropyToken validator requires mixed character classes and Shannon entropy >= 4.5 bits/char",
441
+ "examples": [
442
+ "Zq7PmX2vR9kLtY4wNb8QcJ3fHd6GsA1uEi5oT0xVrKp",
443
+ "kM3nP8qR2sT7uV1wX5yZ9aB4cD6eF0gH1jK2lM3nP4qR5sT6"
444
+ ],
445
+ "falsePositiveNotes": "Low confidence by design. Git SHAs and UUID fragments are single-case hex (rejected by the class-mix check); base64 data blobs exceed the 256 char cap; identifiers and prose fall below the entropy threshold.",
446
+ "validate": "highEntropyToken"
278
447
  }
279
448
  ]
280
449
  },
@@ -469,6 +638,23 @@ export const RULES_DATA = [
469
638
  "Tesco Ltd",
470
639
  "Deloitte LLP"
471
640
  ]
641
+ },
642
+ {
643
+ "id": "regex:gb:driving_licence",
644
+ "entityType": "SSN",
645
+ "pattern": "\\b[A-Z9]{5}\\s?\\d{6}\\s?[A-Z9]{2}\\d[A-Z]{2}\\b",
646
+ "flags": "g",
647
+ "confidence": 0.8,
648
+ "domains": [
649
+ "identity"
650
+ ],
651
+ "description": "UK driving licence number (16 chars: surname block, encoded date of birth, initials, check chars)",
652
+ "examples": [
653
+ "MORGA657054SM9IJ",
654
+ "SMITH 751125 JM9AB"
655
+ ],
656
+ "falsePositiveNotes": "Best-effort: other 16-char uppercase codes can match the shape; the validator checks the embedded date-of-birth digit block",
657
+ "validate": "ukDrivingLicence"
472
658
  }
473
659
  ]
474
660
  },
@@ -10,6 +10,13 @@
10
10
  * luhn_check -> luhn, nir_check -> nir, ipv4_check -> ipOctets.
11
11
  */
12
12
  export type RuleValidator = (match: string) => boolean;
13
+ /**
14
+ * Shannon entropy of a string in bits per character. Exported for tests
15
+ * and threshold tuning; the secrets-tier validators below gate the
16
+ * generic candidate rules on it (detect-secrets / Prompt Armour
17
+ * precedent). Named patterns (AKIA..., ghp_...) need no entropy gate.
18
+ */
19
+ export declare function shannonEntropy(value: string): number;
13
20
  /**
14
21
  * The registry. Keys are the names allowed in the `validate` field of
15
22
  * rules/*.json; loader.ts throws at module init on any unknown name.
@@ -56,15 +56,20 @@ function ipOctets(ip) {
56
56
  return n >= 0 && n <= 255;
57
57
  });
58
58
  }
59
- /** UK National Insurance Number structural rules (prefix letter exclusions). */
59
+ /**
60
+ * UK National Insurance Number structural rules (HMRC prefix spec):
61
+ * first letter is never D, F, I, Q, U, V; second letter is never
62
+ * D, F, I, O, Q, U, V; the prefixes BG, GB, NK, KN, TN, NT, ZZ are
63
+ * never allocated. T100 fixed the second-letter class, which was
64
+ * missing V (the Python twin in DocCloak.Cli validators.py still has
65
+ * the pre-fix class until the packs are re-vendored there).
66
+ */
60
67
  function nino(match) {
61
68
  const clean = match.replace(/\s/g, '').toUpperCase();
62
- // NINO cannot start with D, F, I, Q, U, V; second letter cannot be D, F, I, O, Q, U, V
63
69
  if (/^[DFIQUV]/.test(clean))
64
70
  return false;
65
- if (/^.[DFIOQU]/.test(clean))
71
+ if (/^.[DFIOQUV]/.test(clean))
66
72
  return false;
67
- // Cannot be BG, GB, NK, KN, TN, NT, ZZ prefix
68
73
  if (/^(?:BG|GB|NK|KN|TN|NT|ZZ)/.test(clean))
69
74
  return false;
70
75
  return true;
@@ -85,6 +90,29 @@ function nhs(match) {
85
90
  return false; // invalid
86
91
  return checkDigit === parseInt(digits[9], 10);
87
92
  }
93
+ /**
94
+ * UK driving licence driver number (T100, best-effort): after stripping
95
+ * spaces the 16-char code carries a 6-digit encoded date of birth at
96
+ * positions 6-11 (decade digit, month 01-12 or 51-62 for women, day
97
+ * 01-31, year-in-decade digit). There is no public checksum for the
98
+ * final check characters, so only the date block is validated.
99
+ */
100
+ function ukDrivingLicence(match) {
101
+ const clean = match.replace(/\s/g, '').toUpperCase();
102
+ if (clean.length !== 16)
103
+ return false;
104
+ const dob = clean.slice(5, 11);
105
+ if (!/^\d{6}$/.test(dob))
106
+ return false;
107
+ const rawMonth = parseInt(dob.slice(1, 3), 10);
108
+ const month = rawMonth > 50 ? rawMonth - 50 : rawMonth;
109
+ if (month < 1 || month > 12)
110
+ return false;
111
+ const day = parseInt(dob.slice(3, 5), 10);
112
+ if (day < 1 || day > 31)
113
+ return false;
114
+ return true;
115
+ }
88
116
  /** Polish PESEL weighted checksum. */
89
117
  function pesel(match) {
90
118
  const digits = match.replace(/\D/g, '');
@@ -456,6 +484,66 @@ function pps(match) {
456
484
  const expected = remainder === 0 ? 'W' : String.fromCharCode(64 + remainder);
457
485
  return checkChar === expected;
458
486
  }
487
+ /**
488
+ * Shannon entropy of a string in bits per character. Exported for tests
489
+ * and threshold tuning; the secrets-tier validators below gate the
490
+ * generic candidate rules on it (detect-secrets / Prompt Armour
491
+ * precedent). Named patterns (AKIA..., ghp_...) need no entropy gate.
492
+ */
493
+ export function shannonEntropy(value) {
494
+ if (value.length === 0)
495
+ return 0;
496
+ const counts = new Map();
497
+ for (const ch of value)
498
+ counts.set(ch, (counts.get(ch) ?? 0) + 1);
499
+ let entropy = 0;
500
+ for (const count of counts.values()) {
501
+ const p = count / value.length;
502
+ entropy -= p * Math.log2(p);
503
+ }
504
+ return entropy;
505
+ }
506
+ /**
507
+ * Generic "keyword = value" secret assignment. The rule includes the
508
+ * keyword and separator in the match (lookbehind is banned for new
509
+ * rules), so extract the value: everything after the first ':' or '=',
510
+ * with quotes/whitespace and base64 '=' padding stripped. Gate it on
511
+ * Shannon entropy: pure-hex values pass at >= 3.0 bits/char (random hex
512
+ * tops out at 4.0), anything else needs >= 4.0. Pure-digit values are
513
+ * rejected outright (IDs and phone numbers, not credentials).
514
+ * Thresholds tuned in tests/regex/secrets.test.ts.
515
+ */
516
+ function secretAssignment(match) {
517
+ const sep = match.search(/[:=]/);
518
+ if (sep === -1)
519
+ return false;
520
+ const value = match.slice(sep + 1).replace(/^[>\s"']+/, '').replace(/=+$/, '');
521
+ if (value.length < 16)
522
+ return false;
523
+ if (/^\d+$/.test(value))
524
+ return false;
525
+ if (/^[0-9a-f]+$/i.test(value))
526
+ return shannonEntropy(value) >= 3.0;
527
+ return shannonEntropy(value) >= 4.0;
528
+ }
529
+ /**
530
+ * Bare high-entropy token gate for the generic base64/alphanumeric
531
+ * candidate rule. Requires the shape of machine-generated key material:
532
+ * 40-256 chars after stripping '=' padding (longer runs are data blobs
533
+ * like base64 images, not credentials), a mix of lowercase, uppercase,
534
+ * and digits (rejects git SHAs and UUIDs, which are single-case hex),
535
+ * and Shannon entropy >= 4.5 bits/char (rejects identifiers and prose;
536
+ * random 40+ char base64 sits near 4.8). Thresholds tuned in
537
+ * tests/regex/secrets.test.ts.
538
+ */
539
+ function highEntropyToken(match) {
540
+ const value = match.replace(/=+$/, '');
541
+ if (value.length < 40 || value.length > 256)
542
+ return false;
543
+ if (!/[a-z]/.test(value) || !/[A-Z]/.test(value) || !/\d/.test(value))
544
+ return false;
545
+ return shannonEntropy(value) >= 4.5;
546
+ }
459
547
  /**
460
548
  * The registry. Keys are the names allowed in the `validate` field of
461
549
  * rules/*.json; loader.ts throws at module init on any unknown name.
@@ -471,6 +559,7 @@ export const VALIDATORS = {
471
559
  dni,
472
560
  fodselsnummer,
473
561
  hetu,
562
+ highEntropyToken,
474
563
  ibanMod97,
475
564
  ipOctets,
476
565
  luhn,
@@ -487,6 +576,8 @@ export const VALIDATORS = {
487
576
  pps,
488
577
  regon,
489
578
  samordningsnummer,
579
+ secretAssignment,
490
580
  steuerId,
491
581
  svnr,
582
+ ukDrivingLicence,
492
583
  };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Tolerant restore-token matching (T098), extracted to a side-effect-free
3
+ * leaf module so the extension content bundle can import these VALUES
4
+ * without pulling in the session/surrogate machinery (see the T036 alias
5
+ * note in DocCloak.Extension/vite.content.config.ts).
6
+ */
7
+ /**
8
+ * Bare typed token as an LLM writes it after dropping the brackets:
9
+ * STRICTLY uppercase segments ending in an underscore-digit run, on
10
+ * non-wordish boundaries (underscore excluded on both sides so an
11
+ * embedded "SOME_PERSON_1" identifier never yields a partial match).
12
+ * Case is deliberately NOT tolerated here: without brackets, only the
13
+ * exact uppercase shape is distinctive enough to touch ("person 1" in
14
+ * lowercase prose must stay prose). An immediately preceding '[' or '<'
15
+ * is also excluded: a dangling "[PERSON_1" (a token split mid-stream,
16
+ * possibly a truncated "[PERSON_11]") must never be partially restored -
17
+ * the complete bracketed form is the bracket scan's job.
18
+ * Global: callers reset lastIndex.
19
+ */
20
+ export declare const BARE_TYPED_TOKEN_RE: RegExp;
21
+ /**
22
+ * Canonical comparison form of a placeholder candidate or map key:
23
+ * markdown emphasis stripped, edge junk (brackets, punctuation, space)
24
+ * stripped, lowercased, separator runs collapsed to single underscores.
25
+ * "[PERSON_1]", "[person 1]", "**[PERSON-1]**" and "PERSON_1" all yield
26
+ * "person_1". Returns null when nothing substantive remains.
27
+ */
28
+ export declare function canonicalPlaceholderForm(candidate: string): string | null;
29
+ /**
30
+ * The type-family prefix of a canonical typed token ("person_1" ->
31
+ * "person", "credit_card_2" -> "credit_card"), or null for canonical
32
+ * forms that are not typed-token shaped (renamed labels like "ceo").
33
+ */
34
+ export declare function canonicalTypePrefix(canonical: string): string | null;
35
+ export interface TolerantTokenIndex {
36
+ /**
37
+ * Canonical form -> the ONE exact map key it identifies. Forms claimed
38
+ * by two different keys are ambiguous and absent by construction, so a
39
+ * lookup hit is always safe to restore.
40
+ */
41
+ byCanonical: ReadonlyMap<string, string>;
42
+ /**
43
+ * Canonical type prefixes of the typed keys in the map ("person",
44
+ * "redacted"). A candidate of a KNOWN family that resolves to no key is
45
+ * a DocCloak token the model mangled beyond recovery - callers may
46
+ * surface it as could-not-restore. Unknown families (site prose like
47
+ * "[citation_1]") are never DocCloak's to flag.
48
+ */
49
+ typePrefixes: ReadonlySet<string>;
50
+ }
51
+ /**
52
+ * Build the tolerant lookup for a map's keys. Only token-shaped keys
53
+ * participate: literal surrogate keys are natural-language text whose
54
+ * case/spacing variants collide with genuine prose, so they keep the
55
+ * exact-literal path only. Canonical collisions between two different
56
+ * keys are dropped entirely (never guess between [PERSON_1] and a
57
+ * renamed "[Person 1]"); exact matching still restores both.
58
+ */
59
+ export declare function buildTolerantTokenIndex(keys: Iterable<string>): TolerantTokenIndex;
60
+ /**
61
+ * Resolve one mangled candidate to its exact map key, or null when the
62
+ * candidate identifies no key or more than one (ambiguity never
63
+ * restores). Candidates come from bracketed/legacy token scans or from
64
+ * BARE_TYPED_TOKEN_RE; arbitrary prose canonicalizes to forms no key
65
+ * owns and falls through to null.
66
+ */
67
+ export declare function resolveMangledToken(candidate: string, index: TolerantTokenIndex): string | null;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Tolerant restore-token matching (T098), extracted to a side-effect-free
3
+ * leaf module so the extension content bundle can import these VALUES
4
+ * without pulling in the session/surrogate machinery (see the T036 alias
5
+ * note in DocCloak.Extension/vite.content.config.ts).
6
+ */
7
+ // ── Tolerant restore matching (T098) ───────────────────────
8
+ //
9
+ // LLM replies mangle placeholders: case changes ("[person_1]"), separator
10
+ // swaps ("[PERSON 1]", "[PERSON-1]"), markdown wrapping ("**[PERSON_1]**",
11
+ // "[`PERSON_1`]", "`PERSON_1`"), dropped brackets ("PERSON_1") and stray
12
+ // edge punctuation ("[PERSON_1.]"). The tolerant matcher reduces mapping
13
+ // keys and reply candidates to one canonical form and restores only on an
14
+ // UNAMBIGUOUS canonical hit: a canonical form claimed by two different map
15
+ // keys is never matched, and a candidate that resolves to nothing is left
16
+ // exactly as the model wrote it. A wrong restore silently corrupts the
17
+ // user's document; a missed restore is visible and recoverable.
18
+ /** A key shaped like one full token ([PERSON_1], <<REDACTED_1>>). */
19
+ const TOKEN_SHAPED_KEY_RE = /^(?:\[[^[\]\n]+\]|<<[^<>\n]+>>)$/;
20
+ /** Markdown emphasis characters LLMs wrap tokens in (bold/code/strike). */
21
+ const MARKDOWN_EMPHASIS_RE = /[*`~]/g;
22
+ /**
23
+ * Non-alphanumeric runs at either edge of a candidate: bracket/angle
24
+ * delimiters, stray punctuation and whitespace. Stripping edges (never
25
+ * the middle) keeps "[PERSON_1.]" and "PERSON_1" comparable while inner
26
+ * punctuation still distinguishes genuinely different labels.
27
+ */
28
+ const EDGE_JUNK_RE = /^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu;
29
+ /** Separator runs unified to one '_': whitespace, underscores, hyphens. */
30
+ const SEPARATOR_RUN_RE = /[\s_-]+/g;
31
+ /**
32
+ * Bare typed token as an LLM writes it after dropping the brackets:
33
+ * STRICTLY uppercase segments ending in an underscore-digit run, on
34
+ * non-wordish boundaries (underscore excluded on both sides so an
35
+ * embedded "SOME_PERSON_1" identifier never yields a partial match).
36
+ * Case is deliberately NOT tolerated here: without brackets, only the
37
+ * exact uppercase shape is distinctive enough to touch ("person 1" in
38
+ * lowercase prose must stay prose). An immediately preceding '[' or '<'
39
+ * is also excluded: a dangling "[PERSON_1" (a token split mid-stream,
40
+ * possibly a truncated "[PERSON_11]") must never be partially restored -
41
+ * the complete bracketed form is the bracket scan's job.
42
+ * Global: callers reset lastIndex.
43
+ */
44
+ export const BARE_TYPED_TOKEN_RE = /(?<![\p{L}\p{N}_[<])[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*_\d+(?![\p{L}\p{N}_])/gu;
45
+ /**
46
+ * Canonical comparison form of a placeholder candidate or map key:
47
+ * markdown emphasis stripped, edge junk (brackets, punctuation, space)
48
+ * stripped, lowercased, separator runs collapsed to single underscores.
49
+ * "[PERSON_1]", "[person 1]", "**[PERSON-1]**" and "PERSON_1" all yield
50
+ * "person_1". Returns null when nothing substantive remains.
51
+ */
52
+ export function canonicalPlaceholderForm(candidate) {
53
+ const stripped = candidate
54
+ .replace(MARKDOWN_EMPHASIS_RE, '')
55
+ .replace(EDGE_JUNK_RE, '')
56
+ .toLowerCase();
57
+ if (stripped.length === 0)
58
+ return null;
59
+ const parts = stripped.split(SEPARATOR_RUN_RE).filter((p) => p.length > 0);
60
+ if (parts.length === 0)
61
+ return null;
62
+ return parts.join('_');
63
+ }
64
+ /** Canonical typed-token shape: type prefix + '_' + counter digits. */
65
+ const CANONICAL_TYPED_RE = /^([a-z][a-z0-9_]*)_\d+$/;
66
+ /**
67
+ * The type-family prefix of a canonical typed token ("person_1" ->
68
+ * "person", "credit_card_2" -> "credit_card"), or null for canonical
69
+ * forms that are not typed-token shaped (renamed labels like "ceo").
70
+ */
71
+ export function canonicalTypePrefix(canonical) {
72
+ const match = CANONICAL_TYPED_RE.exec(canonical);
73
+ return match ? match[1] : null;
74
+ }
75
+ /**
76
+ * Build the tolerant lookup for a map's keys. Only token-shaped keys
77
+ * participate: literal surrogate keys are natural-language text whose
78
+ * case/spacing variants collide with genuine prose, so they keep the
79
+ * exact-literal path only. Canonical collisions between two different
80
+ * keys are dropped entirely (never guess between [PERSON_1] and a
81
+ * renamed "[Person 1]"); exact matching still restores both.
82
+ */
83
+ export function buildTolerantTokenIndex(keys) {
84
+ const byCanonical = new Map();
85
+ const ambiguous = new Set();
86
+ const typePrefixes = new Set();
87
+ for (const key of keys) {
88
+ if (!TOKEN_SHAPED_KEY_RE.test(key))
89
+ continue;
90
+ const canonical = canonicalPlaceholderForm(key);
91
+ if (canonical === null)
92
+ continue;
93
+ const prefix = canonicalTypePrefix(canonical);
94
+ if (prefix !== null)
95
+ typePrefixes.add(prefix);
96
+ if (ambiguous.has(canonical))
97
+ continue;
98
+ const existing = byCanonical.get(canonical);
99
+ if (existing !== undefined && existing !== key) {
100
+ byCanonical.delete(canonical);
101
+ ambiguous.add(canonical);
102
+ continue;
103
+ }
104
+ byCanonical.set(canonical, key);
105
+ }
106
+ return { byCanonical, typePrefixes };
107
+ }
108
+ /**
109
+ * Resolve one mangled candidate to its exact map key, or null when the
110
+ * candidate identifies no key or more than one (ambiguity never
111
+ * restores). Candidates come from bracketed/legacy token scans or from
112
+ * BARE_TYPED_TOKEN_RE; arbitrary prose canonicalizes to forms no key
113
+ * owns and falls through to null.
114
+ */
115
+ export function resolveMangledToken(candidate, index) {
116
+ const canonical = canonicalPlaceholderForm(candidate);
117
+ if (canonical === null)
118
+ return null;
119
+ return index.byCanonical.get(canonical) ?? null;
120
+ }