@link-foundation/sensitive-data-sanitizer 0.17.1 → 0.19.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.
package/src/index.d.ts CHANGED
@@ -21,7 +21,20 @@ export interface Finding {
21
21
  export interface PersonalValue {
22
22
  type: string;
23
23
  value: string;
24
+ properCase?: boolean;
24
25
  }
26
+ export type VocabularyList =
27
+ readonly string[] | { values: readonly string[]; replace: true };
28
+ export type Vocabulary = Partial<
29
+ Record<
30
+ | 'roles'
31
+ | 'organizationCues'
32
+ | 'educationCues'
33
+ | 'personalLabels'
34
+ | 'personalFields',
35
+ VocabularyList
36
+ >
37
+ >;
25
38
  export interface PublicEntity {
26
39
  type: 'PERSON' | 'ORGANIZATION' | 'EDUCATION' | 'LOCATION' | 'EMAIL';
27
40
  value: string;
@@ -38,14 +51,28 @@ export interface Detector {
38
51
  export interface SanitizerOptions {
39
52
  /** Preserve all personal/account data; credential findings still redact. */
40
53
  credentialsOnly?: boolean;
41
- /** Replace only knownPersonal/knownSecrets and reused map values; skip automatic detectors. */
54
+ /** Use explicit findings, knownPersonal/knownSecrets and reused map values; skip automatic detectors. */
42
55
  knownOnly?: boolean;
43
56
  profile?: 'publication' | 'documents';
44
57
  /** Opt in to bounded employer, school, city/region heuristics. */
45
58
  documentEntities?: boolean;
59
+ /** Opt-in aggressive stored-log attributes, ages, dates and numeric shapes. */
60
+ strict?: boolean;
61
+ vocabulary?: Vocabulary;
62
+ /** Stable ids from builtInRules. Unknown ids fail with ERR_CONFIG. */
63
+ rules?: Record<string, boolean>;
64
+ types?: Record<string, boolean>;
65
+ /** Required to disable any built-in credential rule or type. */
66
+ allowCredentialDisable?: boolean;
67
+ /** Treat a built-in property as ordinary scanned text. */
68
+ fields?: Record<string, 'scan'>;
69
+ /** Aggressive structural proper names in prose values; opt-in, off by default. */
70
+ properNames?: 'capitalized';
71
+ /** Unicode Script aliases that activate prose detection; default ['Cyrillic']. */
72
+ scripts?: string[];
46
73
  confirmedPersonal?: Array<{ value: string; type: string }>;
47
74
  knownSecrets?: string[];
48
- knownPersonal?: PersonalValue[];
75
+ knownPersonal?: readonly PersonalValue[];
49
76
  publicEntities?: PublicEntity[];
50
77
  /** Built-in exact public entities/resolvers and role mail on known domains. */
51
78
  publicKnowledge?: boolean;
@@ -63,8 +90,12 @@ export interface SanitizerOptions {
63
90
  structured?: 'json' | 'jsonl';
64
91
  /** Property names whose metadata heuristics may be suppressed. */
65
92
  structuralFields?: string[];
66
- /** Exact, case-sensitive property names whose entire values are redacted in JSON/JSONL. */
93
+ /** Normalized property names or dotted paths with *; entire JSON/JSONL values redact. */
67
94
  sensitiveFields?: string[];
95
+ /** Match sensitiveFields/allowedFields with exact case and punctuation. */
96
+ exactFields?: boolean;
97
+ /** Only these string leaves remain eligible to keep; listed strings are still scanned. */
98
+ allowedFields?: string[];
68
99
  /** Personal replacement threshold, default 0.5. Inspect retains lower scores. */
69
100
  minConfidence?: number;
70
101
  /** Alias for minConfidence; specifying both requires equal values. */
@@ -80,7 +111,7 @@ export interface SanitizerOptions {
80
111
  maxInputLength?: number;
81
112
  maxFindings?: number;
82
113
  debug?: (event: DebugEvent) => void;
83
- /** Used by createSanitizer; synchronous functions use native rules only. */
114
+ /** Required callbacks; synchronous functions reject Promise-returning detectors. */
84
115
  detectors?: Detector[];
85
116
  /** Required by default in createSanitizer. */
86
117
  secretlint?: boolean;
@@ -88,6 +119,7 @@ export interface SanitizerOptions {
88
119
  /** Value-free diagnostics; enabled only when a debug callback is supplied. */
89
120
  export type DebugEvent =
90
121
  | { event: 'detected'; count: number }
122
+ | { event: 'document-experience' | 'proper-name-prose'; count: number }
91
123
  | { event: 'residual-redaction'; round: number }
92
124
  | { event: 'two-pass-confirmation'; type: string }
93
125
  | { event: 'propagation-skipped'; reason: 'length'; type: string }
@@ -106,6 +138,22 @@ export interface Sanitizer {
106
138
  inspect(text: string): Promise<Finding[]>;
107
139
  sanitize(text: string): Promise<SanitizedText>;
108
140
  }
141
+ export interface SyncSanitizer {
142
+ inspect(text: string): Finding[];
143
+ sanitize(text: string): SanitizedText;
144
+ }
145
+ export declare function createSyncSanitizer(
146
+ options?: SanitizerOptions
147
+ ): SyncSanitizer;
148
+ /** Copies and freezes caller values, merging lists and caching matching/projection. */
149
+ export declare function compileKnown(
150
+ ...lists: Array<readonly PersonalValue[]>
151
+ ): readonly Readonly<PersonalValue>[];
152
+ export declare const builtInRules: readonly string[];
153
+ export declare const defaultVocabulary: Readonly<
154
+ Record<keyof Vocabulary, readonly string[]>
155
+ >;
156
+ export declare const documentVocabulary: readonly (readonly string[])[];
109
157
  export interface JsonlSanitizer extends Sanitizer {
110
158
  sanitizeJsonl(text: string): Promise<SanitizedText>;
111
159
  }
@@ -224,7 +272,12 @@ export declare function auditGitHistory(
224
272
  maxTotalBytes?: number;
225
273
  }
226
274
  ): Promise<HistoryAudit>;
275
+ /** Callback receives private matched data. Sync APIs require an immediate string. */
276
+ export type TransformationCallback = (
277
+ finding: Readonly<Finding & { value: string }>
278
+ ) => string | Promise<string>;
227
279
  export type Transformation =
280
+ | TransformationCallback
228
281
  | { mode: 'redact' | 'hive-mask' }
229
282
  | {
230
283
  mode: 'placeholder';
package/src/index.js CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  export { inspect, sanitize, redact } from './sanitizer.js';
4
4
  export { createSanitizer } from './engines.js';
5
+ export { compileKnown } from './compiled-known.js';
6
+ export { builtInRules } from './rule-catalog.js';
7
+ export { defaultVocabulary } from './vocabulary.js';
8
+ export { documentVocabulary } from './document-vocabulary.js';
9
+ export { createSyncSanitizer } from './sync-sanitizer.js';
5
10
  export { REDACTED, entropy, luhn } from './detection.js';
6
11
  export { codePointRange } from './projection.js';
7
12
  export {
@@ -3,7 +3,7 @@ import { Worker } from 'node:worker_threads';
3
3
  import { URL } from 'node:url';
4
4
  import { failure } from './detection.js';
5
5
  import { encodingFailure } from './utf8.js';
6
- import { workerExecArgv } from './worker-options.js';
6
+ import { workerExecArgv, hasWorkerCallbacks } from './worker-options.js';
7
7
  import { prepareStreamEngine } from './engines.js';
8
8
  import { inspect } from './sanitizer.js';
9
9
  import { hasPlaceholders } from './placeholders.js';
@@ -129,10 +129,7 @@ function workerCount(options, config) {
129
129
  return 1;
130
130
  }
131
131
  const callbacks =
132
- options.sanitizer ||
133
- config.sanitizerOptions.debug ||
134
- config.sanitizerOptions.verifyPublic ||
135
- config.sanitizerOptions.detectors?.length;
132
+ options.sanitizer || hasWorkerCallbacks(config.sanitizerOptions);
136
133
  const count =
137
134
  options.workers ?? (callbacks || typeof Deno !== 'undefined' ? 1 : 2);
138
135
  if (
package/src/ner.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { Finding } from './index.d.ts';
2
+ export declare function createNerDetector(options?: {
3
+ languages?: Array<'ru' | 'en'>;
4
+ debug?: (event: { event: 'offline-ner'; count: number }) => void;
5
+ }): Promise<{ id: string; detect(text: string): Finding[] }>;
package/src/ner.js ADDED
@@ -0,0 +1,96 @@
1
+ // Optional entry: no core module imports NLP.js or this recognizer.
2
+ import { firstNames } from './names.js';
3
+ import { documentVocabulary } from './document-vocabulary.js';
4
+
5
+ const boundary = '[\\p{L}\\p{M}\\p{N}_]';
6
+ const proper = "\\p{Lu}[\\p{L}\\p{M}’'-]{1,47}";
7
+ const name = `${proper}(?:[ \\t]+${proper}){0,3}`;
8
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
9
+ const blocked =
10
+ /^(?:Kubernetes|PostgreSQL|Python|JavaScript|TypeScript|Node|Docker|Redis|Linux|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday|REDACTED)$/iu;
11
+ const configError = () =>
12
+ Object.assign(new Error('Invalid offline recognizer configuration.'), {
13
+ code: 'ERR_CONFIG',
14
+ });
15
+
16
+ function rulesFor(language) {
17
+ const names = firstNames[language]
18
+ .split(' ')
19
+ .filter((s) => !/^(?:May|Will|Mark|Rose)$/u.test(s));
20
+ const places = documentVocabulary
21
+ .filter((row) => row[0] === language)
22
+ .flatMap((row) => row[3].split('|'));
23
+ const rules = [
24
+ ['PERSON', `(?:${names.map(escape).join('|')})(?:[ \\t]+${proper})?`],
25
+ ['LOCATION', `(?:${places.map(escape).join('|') || '(?!)'})`],
26
+ ];
27
+ if (language === 'ru') {
28
+ rules.push(
29
+ ['ORGANIZATION', `(?<=(?:[Рр]аботал[аи]?|[Сс]лужил[аи]?) в )${name}`],
30
+ [
31
+ 'ORGANIZATION',
32
+ `${name}(?=:[ \\t]*(?:менеджер|директор|разработчик|инженер|аналитик|маркетолог)(?!\\p{L}))`,
33
+ ],
34
+ ['EDUCATION', '(?:МГУ|СПбГУ|МГТУ|РГГУ|ВШЭ|МФТИ|ИТМО)'],
35
+ ['EDUCATION', `${name}[ \\t]+(?:университет|институт|академия)`],
36
+ ['LOCATION', `(?<=(?:[Жж]иву|[Пп]роживаю) в )${name}`]
37
+ );
38
+ } else {
39
+ rules.push(
40
+ ['ORGANIZATION', `(?<=(?:[Aa]t|[Jj]oined|[Ee]mployer:) )${name}`],
41
+ ['EDUCATION', `(?<=(?:[Gg]raduated from|[Ss]tudied at) )${name}`],
42
+ [
43
+ 'EDUCATION',
44
+ '(?:MIT|MSU|UCLA|UCL|NYU|Harvard|Stanford|Oxford|Cambridge)',
45
+ ],
46
+ ['EDUCATION', `${name}[ \\t]+(?:University|College|Institute)`],
47
+ ['LOCATION', `(?<=(?:[Ll]ive in|[Ll]ocated in|[Bb]ased in) )${name}`]
48
+ );
49
+ }
50
+ return rules.map(([type, source]) => ({
51
+ name: type,
52
+ type: 'regex',
53
+ rules: [new RegExp(`(?<!${boundary})(?:${source})(?!${boundary})`, 'gu')],
54
+ }));
55
+ }
56
+
57
+ /** Prepare an offline recognizer with synchronous detection. */
58
+ export async function createNerDetector(options = {}) {
59
+ const languages = options.languages ?? ['ru', 'en'];
60
+ if (
61
+ !Array.isArray(languages) ||
62
+ !languages.length ||
63
+ languages.length > 2 ||
64
+ languages.some((s) => !['ru', 'en'].includes(s)) ||
65
+ Object.keys(options).some((key) => !['languages', 'debug'].includes(key)) ||
66
+ (options.debug !== undefined && typeof options.debug !== 'function')
67
+ ) {
68
+ throw configError();
69
+ }
70
+ const { ExtractorRegex } = await import('@nlpjs/ner');
71
+ const extractor = new ExtractorRegex();
72
+ const nerRules = [...new Set(languages)].flatMap(rulesFor);
73
+ return Object.freeze({
74
+ id: 'offline-ner',
75
+ detect(text) {
76
+ if (typeof text !== 'string' || text.length > 1024 * 1024) {
77
+ throw Object.assign(new Error('Offline recognizer input limit.'), {
78
+ code: 'ERR_LIMIT',
79
+ });
80
+ }
81
+ const findings = extractor
82
+ .extract({ text, nerRules })
83
+ .edges.filter((edge) => !blocked.test(edge.sourceText))
84
+ .map((edge) => ({
85
+ start: edge.start,
86
+ end: edge.end + 1,
87
+ type: edge.entity,
88
+ category: 'personal',
89
+ rule: 'ner/offline',
90
+ confidence: 0.8,
91
+ }));
92
+ options.debug?.({ event: 'offline-ner', count: findings.length });
93
+ return findings;
94
+ },
95
+ });
96
+ }
@@ -1,5 +1,6 @@
1
1
  import { escapePattern, failure } from './detection.js';
2
2
  import { russianNameRoles } from './russian-names.js';
3
+ import { compiledPatterns } from './compiled-known.js';
3
4
 
4
5
  const boundary = '[\\p{L}\\p{M}\\p{N}_]';
5
6
  export function organizationCore(value) {
@@ -143,6 +144,22 @@ export function personalPatterns(value, type) {
143
144
  return [...patterns];
144
145
  }
145
146
  export function personalRegex(value, type) {
147
+ if (type === 'URL') {
148
+ let core = value.replace(/^https?:\/\//iu, '');
149
+ const suffix = core.search(/[?#]/u);
150
+ if (suffix >= 0) {
151
+ core = core.slice(0, suffix);
152
+ }
153
+ let end = core.length;
154
+ while (end > 0 && core[end - 1] === '/') {
155
+ end--;
156
+ }
157
+ core = core.slice(0, end);
158
+ return new RegExp(
159
+ `(?<![\\p{L}\\p{N}_./-])(?:https?:\\/\\/)?${escapePattern(core)}(?:\\/)?(?:[?#][^\\s"<>\\\\]*)?(?![\\p{L}\\p{N}_-])`,
160
+ 'giu'
161
+ );
162
+ }
146
163
  return new RegExp(
147
164
  `(?<!${boundary})(?:${personalPatterns(value, type).join('|')})(?!${boundary})`,
148
165
  'giu'
@@ -153,17 +170,21 @@ export function samePersonal(value, original, type) {
153
170
  new RegExp(`^(?:${pattern})$`, 'iu').test(value)
154
171
  );
155
172
  }
173
+ function confirmedFamily(entry, rule, options) {
174
+ return rule === 'confirmed-personal' &&
175
+ options.documentEntities &&
176
+ !options.knownOnly &&
177
+ entry.type === 'ORGANIZATION'
178
+ ? organizationFamily(organizationCore(entry.value))
179
+ : undefined;
180
+ }
156
181
  export function detectKnownPersonal(text, entries, rule, emit, options) {
157
182
  let count = 0;
158
- for (const entry of entries ?? []) {
159
- const family =
160
- rule === 'confirmed-personal' &&
161
- options.documentEntities &&
162
- !options.knownOnly &&
163
- entry.type === 'ORGANIZATION' &&
164
- organizationFamily(organizationCore(entry.value));
183
+ for (const [index, entry] of (entries ?? []).entries()) {
184
+ const family = confirmedFamily(entry, rule, options);
165
185
  for (const pattern of [
166
- personalRegex(entry.value, entry.type),
186
+ compiledPatterns(entries)?.[index] ??
187
+ personalRegex(entry.value, entry.type),
167
188
  ...(family ? [family.pattern] : []),
168
189
  ]) {
169
190
  for (const match of text.matchAll(pattern)) {
@@ -22,6 +22,7 @@ export const reservedPlaceholderTypes = () => [
22
22
  'PASSPORT_MRZ',
23
23
  'PASSPORT_NUMBER',
24
24
  'PERSON',
25
+ 'PROPER_NAME',
25
26
  'PHONE',
26
27
  'PL_PESEL',
27
28
  'RU_INN',
package/src/policy.js ADDED
@@ -0,0 +1,107 @@
1
+ import { failure } from './detection.js';
2
+ import { builtInRules, credentialRules } from './rule-catalog.js';
3
+ import { normalizeFieldName } from './document-fields.js';
4
+
5
+ const ruleIds = new Set(builtInRules);
6
+ const credentialType = (type) =>
7
+ /SECRET|TOKEN|PASSWORD|AUTH|CREDENTIAL|PRIVATE_KEY|ACCESS_KEY|^PIN$|CVV|CVC|CONNECTION.*STRING/.test(
8
+ type
9
+ );
10
+ const record = (value) =>
11
+ value && typeof value === 'object' && !Array.isArray(value);
12
+ // These recognizers consume the replaceable cue lists. Independent evidence
13
+ // (legal forms, dates, full names, identifiers) remains available.
14
+ const vocabularyRules = {
15
+ roles: ['document-reversed-role', 'document-role-employer'],
16
+ organizationCues: [
17
+ 'organization-context',
18
+ 'employer-label',
19
+ 'employer-prose',
20
+ 'organization-cue',
21
+ 'organization-group-cue',
22
+ 'document-employer-prefix',
23
+ 'document-employer-dash',
24
+ ],
25
+ educationCues: [
26
+ 'school-acronym-context',
27
+ 'education-label',
28
+ 'education-training',
29
+ 'document-school-context',
30
+ 'numbered-school',
31
+ 'education-type',
32
+ 'education-russian',
33
+ ],
34
+ personalLabels: ['document-role'],
35
+ };
36
+ export function validatePolicyControls(options) {
37
+ for (const key of ['allowCredentialDisable', 'strict', 'exactFields']) {
38
+ if (options[key] !== undefined && typeof options[key] !== 'boolean') {
39
+ throw failure('ERR_CONFIG');
40
+ }
41
+ }
42
+ for (const key of ['rules', 'types', 'fields']) {
43
+ if (options[key] === undefined) {
44
+ continue;
45
+ }
46
+ if (!record(options[key])) {
47
+ throw failure('ERR_CONFIG');
48
+ }
49
+ for (const [id, value] of Object.entries(options[key])) {
50
+ validateControl(key, id, value, options);
51
+ }
52
+ }
53
+ }
54
+ function validateControl(key, id, value, options) {
55
+ const validId =
56
+ key === 'rules'
57
+ ? ruleIds.has(id)
58
+ : key === 'types'
59
+ ? /^[A-Z][A-Z0-9_]{0,63}$/.test(id)
60
+ : id.length > 0;
61
+ if (
62
+ !validId ||
63
+ (key === 'fields' ? value !== 'scan' : typeof value !== 'boolean')
64
+ ) {
65
+ throw failure('ERR_CONFIG');
66
+ }
67
+ const credential =
68
+ key === 'rules' ? credentialRules.has(id) : credentialType(id);
69
+ if (value === false && !options.allowCredentialDisable && credential) {
70
+ throw failure('ERR_CONFIG');
71
+ }
72
+ }
73
+ export function builtInEnabled(finding, options) {
74
+ if (finding.category === 'credential' && !options.allowCredentialDisable) {
75
+ return true;
76
+ }
77
+ for (const [list, rules] of Object.entries(vocabularyRules)) {
78
+ if (
79
+ options.vocabulary?.[list]?.replace === true &&
80
+ rules.includes(finding.rule)
81
+ ) {
82
+ return false;
83
+ }
84
+ }
85
+ return (
86
+ options.rules?.[finding.rule] !== false &&
87
+ options.types?.[finding.type] !== false
88
+ );
89
+ }
90
+ export function scanField(field, options) {
91
+ return Object.keys(options.fields ?? {}).some(
92
+ (name) => normalizeFieldName(name) === normalizeFieldName(field)
93
+ );
94
+ }
95
+ export function fieldMatches(patterns, field, path, exact = false) {
96
+ const normalize = exact ? (s) => s : normalizeFieldName;
97
+ return (patterns ?? []).some((pattern) => {
98
+ const parts = pattern.split('.');
99
+ const values = parts.length === 1 ? [field] : path;
100
+ return (
101
+ parts.length === values.length &&
102
+ parts.every(
103
+ (part, i) => part === '*' || normalize(part) === normalize(values[i])
104
+ )
105
+ );
106
+ });
107
+ }
@@ -36,7 +36,11 @@ function eligibleSeed(finding, options) {
36
36
  !options.knownOnly &&
37
37
  finding.category === 'personal' &&
38
38
  finding.type !== 'ENCODED_SENSITIVE' &&
39
- !['confirmed-personal', 'document-propagation'].includes(finding.rule) &&
39
+ ![
40
+ 'confirmed-personal',
41
+ 'document-propagation',
42
+ 'proper-name-prose',
43
+ ].includes(finding.rule) &&
40
44
  actionable(finding, options) &&
41
45
  ((options.documentEntities && finding.type === 'ORGANIZATION') ||
42
46
  finding.confidence >= 0.85 ||
@@ -0,0 +1,222 @@
1
+ import { failure } from './detection.js';
2
+ import { documentDateRange } from './document-dates.js';
3
+
4
+ const scriptPatterns = new Map();
5
+ const dateRange = new RegExp(`\\(${documentDateRange}\\)`, 'u');
6
+ const commaMobile = /,[ \t]*[78]9\d{9}(?![\p{L}\p{N}_])/u;
7
+ const mobile = /^[78]9\d{9}$/u;
8
+ const tokenPattern = /[\p{L}\p{M}\p{N}_./:\\?&#%=@+'’~+-]+/gu;
9
+ const upper = /[\p{Lu}\p{Lt}]/u;
10
+ const quotePairs = new Map([
11
+ ['«', '»'],
12
+ ['“', '”'],
13
+ ['„', '“'],
14
+ ['‘', '’'],
15
+ ['「', '」'],
16
+ ['『', '』'],
17
+ ]);
18
+
19
+ function scriptPattern(scripts = ['Cyrillic']) {
20
+ if (
21
+ scripts.some(
22
+ (script) => typeof script !== 'string' || !/^[A-Za-z_]+$/.test(script)
23
+ )
24
+ ) {
25
+ throw failure('ERR_CONFIG');
26
+ }
27
+ const key = scripts.join('|');
28
+ let pattern = scriptPatterns.get(key);
29
+ if (!pattern) {
30
+ // The alias grammar prevents regexp injection; constructing the Unicode
31
+ // property also validates aliases supported by this runtime.
32
+ try {
33
+ pattern = new RegExp(
34
+ scripts.length
35
+ ? `(?=\\p{L})(?:${scripts.map((s) => `\\p{Script=${s}}`).join('|')})`
36
+ : '(?!)',
37
+ 'u'
38
+ );
39
+ } catch {
40
+ throw failure('ERR_CONFIG');
41
+ }
42
+ // Bound cache growth when callers submit many different configurations.
43
+ if (scriptPatterns.size >= 64) {
44
+ scriptPatterns.clear();
45
+ }
46
+ scriptPatterns.set(key, pattern);
47
+ }
48
+ return pattern;
49
+ }
50
+ export function validateProperNames(options) {
51
+ if (
52
+ (options.properNames !== undefined &&
53
+ options.properNames !== 'capitalized') ||
54
+ (options.scripts !== undefined && !Array.isArray(options.scripts))
55
+ ) {
56
+ throw failure('ERR_CONFIG');
57
+ }
58
+ scriptPattern(options.scripts);
59
+ }
60
+
61
+ export function isProseLink(token) {
62
+ return (
63
+ /^https?:\/\/[^/]+/iu.test(token) ||
64
+ /^[\p{L}\p{N}](?:[\p{L}\p{N}-]*\.)+[\p{L}]{2,63}(?:[/:?#]|$)/u.test(token)
65
+ );
66
+ }
67
+ export function isDocumentShareLink(token) {
68
+ return isProseLink(token) && /\/d\/[^/?#]+/u.test(token);
69
+ }
70
+ function proseData(text, options) {
71
+ if (
72
+ scriptPattern(options.scripts).test(text) ||
73
+ dateRange.test(text) ||
74
+ commaMobile.test(text)
75
+ ) {
76
+ return true;
77
+ }
78
+ for (const match of text.matchAll(tokenPattern)) {
79
+ if (isDocumentShareLink(match[0])) {
80
+ return true;
81
+ }
82
+ }
83
+ return false;
84
+ }
85
+ function quotedSpans(text, emit) {
86
+ let start = -1,
87
+ closing;
88
+ for (let i = 0; i < text.length; i++) {
89
+ if (start >= 0) {
90
+ if (text[i] === closing) {
91
+ emit(start, i + 1, 'PROPER_NAME');
92
+ start = -1;
93
+ }
94
+ } else if (quotePairs.has(text[i])) {
95
+ start = i;
96
+ closing = quotePairs.get(text[i]);
97
+ }
98
+ }
99
+ }
100
+ function pathToken(value) {
101
+ return value.includes('/') || value.includes('\\');
102
+ }
103
+ // Keep existing document coverage outside the new replacements. Two naturally
104
+ // ordered runs (quotes, then tokens) provide an interval index without sorting.
105
+ // Overlapping broad document spans must not merge separate prose replacements.
106
+ export function detectProseAndDocuments(text, emit, options, detectDocuments) {
107
+ if (options.properNames !== 'capitalized') {
108
+ detectDocuments(text, emit, options);
109
+ return;
110
+ }
111
+ const runs = [[]];
112
+ detectProperNames(
113
+ text,
114
+ (finding) => {
115
+ if (runs.at(-1).at(-1)?.end > finding.start) {
116
+ runs.push([]);
117
+ }
118
+ runs.at(-1).push(finding);
119
+ emit(finding);
120
+ },
121
+ options
122
+ );
123
+ detectDocuments(
124
+ text,
125
+ (finding) => {
126
+ if (!runs.some((run) => overlapsRun(run, finding))) {
127
+ emit(finding);
128
+ }
129
+ },
130
+ options
131
+ );
132
+ }
133
+ function overlapsRun(run, finding) {
134
+ let low = 0,
135
+ high = run.length;
136
+ while (low < high) {
137
+ const mid = (low + high) >>> 1;
138
+ if (run[mid].end <= finding.start) {
139
+ low = mid + 1;
140
+ } else {
141
+ high = mid;
142
+ }
143
+ }
144
+ return low < run.length && run[low].start < finding.end;
145
+ }
146
+ export function detectProperNames(text, emit, options) {
147
+ if (options.properNames !== 'capitalized' || !proseData(text, options)) {
148
+ return false;
149
+ }
150
+ let count = 0;
151
+ const found = (start, end, type) => {
152
+ emit({
153
+ start,
154
+ end,
155
+ type,
156
+ rule: 'proper-name-prose',
157
+ category: 'personal',
158
+ confidence: 0.85,
159
+ });
160
+ count++;
161
+ };
162
+ quotedSpans(text, found);
163
+ const ranges = text.matchAll(new RegExp(dateRange, 'gu'));
164
+ let range = ranges.next().value;
165
+ for (const match of text.matchAll(tokenPattern)) {
166
+ const raw = match[0];
167
+ // Leave sentence punctuation outside the replacement. Classification runs
168
+ // once on a maximal token, including hostile tokens without spaces.
169
+ let length = raw.length;
170
+ while (length && (raw[length - 1] === '.' || raw[length - 1] === ':')) {
171
+ length--;
172
+ }
173
+ const value = raw.slice(0, length);
174
+ const start = match.index,
175
+ end = start + value.length;
176
+ while (range && range.index + range[0].length <= start) {
177
+ range = ranges.next().value;
178
+ }
179
+ if (preserveToken(text, raw, value, start, end, range)) {
180
+ continue;
181
+ }
182
+ const type = tokenType(value);
183
+ if (type) {
184
+ found(start, end, type);
185
+ }
186
+ }
187
+ options.debug?.({ event: 'proper-name-prose', count });
188
+ return true;
189
+ }
190
+
191
+ function preserveToken(text, raw, value, start, end, range) {
192
+ let next = start + raw.length;
193
+ while (text[next] === ' ' || text[next] === '\t') {
194
+ next++;
195
+ }
196
+ return (
197
+ !value ||
198
+ (range && start >= range.index && end <= range.index + range[0].length) ||
199
+ (value === 'REDACTED' && text[start - 1] === '[' && text[end] === ']') ||
200
+ raw.endsWith(':') ||
201
+ text[next] === ':'
202
+ );
203
+ }
204
+ function tokenType(value) {
205
+ if (isProseLink(value)) {
206
+ return 'URL';
207
+ }
208
+ if (pathToken(value)) {
209
+ return undefined;
210
+ }
211
+ if (mobile.test(value)) {
212
+ return 'PHONE';
213
+ }
214
+ if (
215
+ upper.test(value) &&
216
+ /\p{L}/u.test(value) &&
217
+ !/^\d{4}-\d{2}-\d{2}/u.test(value)
218
+ ) {
219
+ return 'PROPER_NAME';
220
+ }
221
+ return undefined;
222
+ }
@@ -1,5 +1,11 @@
1
1
  import { parentPort, workerData } from 'node:worker_threads';
2
2
  import { createSanitizer, prepareStreamEngine } from './engines.js';
3
+ import { compileKnown } from './compiled-known.js';
4
+
5
+ // Structured cloning discards the caller's weak compilation metadata.
6
+ if (workerData.knownPersonal) {
7
+ workerData.knownPersonal = compileKnown(workerData.knownPersonal);
8
+ }
3
9
 
4
10
  parentPort.on(
5
11
  'message',