@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.
@@ -0,0 +1,55 @@
1
+ import { failure } from './detection.js';
2
+ import { personalRegex } from './personal-variants.js';
3
+ import { projectText } from './projection.js';
4
+
5
+ // Caller-owned frozen arrays remain compatible with maps, streams and fakes.
6
+ // Weak keys allow the caller to release all values and compiled expressions.
7
+ const compiled = new WeakMap();
8
+ export const isCompiledKnown = (entries) => compiled.has(entries);
9
+ export const compiledPatterns = (entries) => compiled.get(entries)?.patterns;
10
+ export const projectedKnown = (entries) => compiled.get(entries)?.projected;
11
+
12
+ export function compileKnown(...lists) {
13
+ if (lists.some((list) => !Array.isArray(list))) {
14
+ throw failure('ERR_CONFIG');
15
+ }
16
+ const values = new Map();
17
+ for (const entry of lists.flat()) {
18
+ if (
19
+ !entry ||
20
+ typeof entry.value !== 'string' ||
21
+ !entry.value ||
22
+ !/^[A-Z][A-Z0-9_]{0,63}$/.test(entry.type) ||
23
+ (entry.properCase !== undefined && typeof entry.properCase !== 'boolean')
24
+ ) {
25
+ throw failure('ERR_CONFIG');
26
+ }
27
+ const copy = Object.freeze({
28
+ type: entry.type,
29
+ value: entry.value,
30
+ ...(entry.properCase === undefined
31
+ ? {}
32
+ : { properCase: entry.properCase }),
33
+ });
34
+ values.set(
35
+ `${entry.type}\0${entry.value}\0${entry.properCase ?? false}`,
36
+ copy
37
+ );
38
+ }
39
+ const entries = Object.freeze([...values.values()]);
40
+ const patterns = entries.map((entry) =>
41
+ personalRegex(entry.value, entry.type)
42
+ );
43
+ const projected = entries.map((entry) => ({
44
+ ...entry,
45
+ value: projectText(entry.value).text,
46
+ }));
47
+ const changed = projected.some(
48
+ (entry, i) => entry.value !== entries[i].value
49
+ );
50
+ compiled.set(entries, {
51
+ patterns,
52
+ projected: changed ? compileKnown(projected) : entries,
53
+ });
54
+ return entries;
55
+ }
package/src/context.js CHANGED
@@ -257,6 +257,11 @@ function operationalValue(value, type) {
257
257
  );
258
258
  }
259
259
 
260
+ function operationalLocationPath(value) {
261
+ return (
262
+ servicePath(value) || /^(?:\.?\.?\/)?[\w.-]+(?:\/[\w.-]+)+\/?$/.test(value)
263
+ );
264
+ }
260
265
  // Shared by native labels and the vendor catalogs. Code expressions and
261
266
  // line escapes are not personal field values, even when a key is familiar.
262
267
  export function personalValueAllowed(text, range, type, labelStart) {
@@ -264,7 +269,10 @@ export function personalValueAllowed(text, range, type, labelStart) {
264
269
  if (!plausibleLabel(value, type)) {
265
270
  return false;
266
271
  }
267
- if (['LOCATION', 'ADDRESS'].includes(type) && servicePath(value)) {
272
+ if (
273
+ ['LOCATION', 'ADDRESS'].includes(type) &&
274
+ operationalLocationPath(value)
275
+ ) {
268
276
  return false;
269
277
  }
270
278
  if (type === 'PHONE') {
package/src/detection.js CHANGED
@@ -15,6 +15,9 @@ import {
15
15
  import { localAddress } from './public.js';
16
16
  import { detectKnownPersonal } from './personal-variants.js';
17
17
  import { detectDocumentEntities } from './document-entities.js';
18
+ import { detectProseAndDocuments } from './proper-names.js';
19
+ import { builtInEnabled } from './policy.js';
20
+ import { detectStrict } from './strict.js';
18
21
 
19
22
  export const REDACTED = '[REDACTED]';
20
23
  export const failure = (code = 'ERR_SANITIZATION', location = {}) =>
@@ -136,6 +139,12 @@ function detectCookies(text, emit) {
136
139
  }
137
140
 
138
141
  export function nativeDetect(text, options, emit) {
142
+ const output = emit;
143
+ emit = (finding) => {
144
+ if (builtInEnabled(finding, options)) {
145
+ output(finding);
146
+ }
147
+ };
139
148
  const credential = { type: 'SECRET', category: 'credential' };
140
149
  for (const value of options.knownSecrets ?? []) {
141
150
  collectMatches(text, new RegExp(escapePattern(value), 'g'), emit, {
@@ -173,11 +182,12 @@ export function nativeDetect(text, options, emit) {
173
182
  detectContext(text, emit, options.credentialsOnly);
174
183
  if (!options.credentialsOnly) {
175
184
  detectPersonal(text, emit);
176
- detectDocumentEntities(text, emit, options);
185
+ detectProseAndDocuments(text, emit, options, detectDocumentEntities);
177
186
  detectCatalog(text, emit);
178
187
  detectIdentity(text, emit);
179
188
  detectNational(text, emit);
180
189
  detectPersonalContext(text, emit);
190
+ detectStrict(text, emit, options);
181
191
  }
182
192
  detectCredentialFormats(text, emit);
183
193
  // detect-secrets-style quoted entropy detection; bare commit hashes and
@@ -0,0 +1,246 @@
1
+ import { collectMatches } from './detection.js';
2
+ import { isDocumentTechnology } from './document-experience.js';
3
+
4
+ const b = '[\\p{L}\\p{N}_]';
5
+ const proper = '[\\p{Lu}][\\p{L}\\p{M}.-]{1,47}';
6
+ const rules = [
7
+ [
8
+ 'PHONE',
9
+ 'document-local-phone',
10
+ /(?<![\p{L}\p{N}_])(?:тел\.?|телефон|phone)[ \t]*:?[ \t]*(\d{7})(?!\d)/giu,
11
+ 1,
12
+ ],
13
+ [
14
+ 'PHONE',
15
+ 'document-spaced-mobile',
16
+ /(?<![\p{L}\p{N}_])8[ \t]+9(?:[ \t]+\d){9}(?!\d)/gu,
17
+ 0,
18
+ ],
19
+ [
20
+ 'EMAIL',
21
+ 'document-email-obfuscation',
22
+ /(?<![\p{L}\p{N}_])[\p{L}\p{N}_.+-]{1,64}\(собака\)[\p{L}\p{N}-]{1,63}(?:\(точка\)[\p{L}\p{N}-]{1,63}){1,6}(?![\p{L}\p{N}_])/giu,
23
+ 0,
24
+ ],
25
+ [
26
+ 'EMAIL',
27
+ 'document-email-spaces',
28
+ /(?<![\p{L}\p{N}_])(?:[a-z][ \t]+){1,63}[a-z][ \t]*@[ \t]*[a-z\d-]{1,63}[ \t]*\.[ \t]*[a-z]{2,20}(?![\p{L}\p{N}_])/giu,
29
+ 0,
30
+ ],
31
+ [
32
+ 'USERNAME',
33
+ 'document-messenger',
34
+ /(?<![\p{L}\p{N}_])(?:discord|max)[ \t]*:[ \t]*([a-z][a-z\d_.-]{1,63}(?:#\d{4})?)(?![\p{L}\p{N}_])/giu,
35
+ 1,
36
+ ],
37
+ [
38
+ 'URL',
39
+ 'document-social-link',
40
+ /(?<![\p{L}\p{N}_./-])(?:https?:\/\/)?(?:www\.)?(?:ok\.ru\/profile|gitlab\.com|habr\.com\/ru\/users|facebook\.com)\/[\p{L}\p{N}_.-]{2,100}(?:\/)?(?:[?#][^\s"<>\\]*)?/giu,
41
+ 0,
42
+ ],
43
+ [
44
+ 'URL',
45
+ 'document-resume-link',
46
+ /https?:\/\/[^\s/"<>\\]+\/(?:cv\.pdf(?:[?#][^\s"<>\\]*)?|cv(?:\/[^\s"<>\\]*)?|~[\p{L}\p{N}_.-]{1,100}\/?)/giu,
47
+ 0,
48
+ ],
49
+ [
50
+ 'DATE_OF_BIRTH',
51
+ 'document-birthday',
52
+ /(?<![\p{L}\p{N}_])(?:д\.р\.|день рождения)[ \t]*:?[ \t]*(\d{1,2}(?:[/.]\d{1,2}[/.]\d{2,4}|[ \t]+(?:января|февраля|марта|апреля|мая|июня|июля|августа|сентября|октября|ноября|декабря)(?:[ \t]+\d{4})?))(?!\d)/giu,
53
+ 1,
54
+ ],
55
+ [
56
+ 'AGE',
57
+ 'document-age-unit',
58
+ /(?<![\p{L}\p{N}_])\d{1,3}[ \t]*(?:y\.o\.|лет[ \t]+от[ \t]+роду)(?![\p{L}\p{N}_])/giu,
59
+ 0,
60
+ ],
61
+ [
62
+ 'ID',
63
+ 'document-military-id',
64
+ /(?<![\p{L}\p{N}_])военный билет[ \t]+([А-ЯЁ]{2}[ \t]+\d{7})(?!\d)/giu,
65
+ 1,
66
+ ],
67
+ [
68
+ 'VIN',
69
+ 'document-vin',
70
+ /(?<![\p{L}\p{N}_])VIN[ \t]*:?[ \t]*([A-HJ-NPR-Z\d]{17})(?![\p{L}\p{N}_])/giu,
71
+ 1,
72
+ ],
73
+ [
74
+ 'ORGANIZATION',
75
+ 'document-legal-lowercase',
76
+ /(?<![\p{L}\p{N}_])(?:ооо|зао|пао|ао)[ \t]+[а-яё][а-яё-]{1,47}(?![\p{L}\p{N}_])/gu,
77
+ 0,
78
+ ],
79
+ [
80
+ 'ORGANIZATION',
81
+ 'document-employer-dash',
82
+ new RegExp(
83
+ `(?<!${b})(?:Компания|Место работы)[ \\t]+[-–—][ \\t]+(${proper})(?!${b})`,
84
+ 'gu'
85
+ ),
86
+ 1,
87
+ ],
88
+ [
89
+ 'ORGANIZATION',
90
+ 'document-employer-prefix',
91
+ new RegExp(`(?<!${b})(?:Группа|стартап)[ \\t]+(${proper})(?!${b})`, 'gu'),
92
+ 1,
93
+ ],
94
+ [
95
+ 'ORGANIZATION',
96
+ 'document-bank-suffix',
97
+ new RegExp(`(?<!${b})${proper}[ \\t]+Банк(?!${b})`, 'gu'),
98
+ 0,
99
+ ],
100
+ [
101
+ 'ORGANIZATION',
102
+ 'document-industry',
103
+ new RegExp(
104
+ `(?<!${b})(${proper})(?=[ \\t]*\\((?:ритейл|retail|финансы|finance|IT)\\))`,
105
+ 'gu'
106
+ ),
107
+ 1,
108
+ ],
109
+ [
110
+ 'ORGANIZATION',
111
+ 'document-reversed-role',
112
+ new RegExp(
113
+ `(?<!${b})(${proper})[ \\t]*(?:,|[-–—])[ \\t]*(?:менеджер|manager|директор|developer|инженер)(?!${b})`,
114
+ 'gu'
115
+ ),
116
+ 1,
117
+ ],
118
+ [
119
+ 'ORGANIZATION',
120
+ 'document-role-employer',
121
+ new RegExp(
122
+ `(?<!${b})(?:менеджер|директор|инженер)[ \\t]+(?:в[ \\t]+)?(${proper})(?!${b})`,
123
+ 'gu'
124
+ ),
125
+ 1,
126
+ ],
127
+ [
128
+ 'ORGANIZATION',
129
+ 'document-at-employer',
130
+ /(?<![\p{L}\p{N}_])at[ \t]+([A-Z][a-z][\p{L}.-]{0,45})(?![\p{L}\p{N}_])/gu,
131
+ 1,
132
+ ],
133
+ [
134
+ 'EDUCATION',
135
+ 'document-school-context',
136
+ /(?<![\p{L}\p{N}_])(?:graduated from|бакалавр,?|диплом|колледж|[Кк]урсы|курс на)[ \t]+([\p{Lu}][\p{L}.-]{1,47})(?![\p{L}\p{N}_])/gu,
137
+ 1,
138
+ ],
139
+ [
140
+ 'EDUCATION',
141
+ 'document-school-year',
142
+ /(?<![\p{L}\p{N}_])[А-ЯЁA-Z]{2,12}(?=,[ \t]*(?:19|20)\d{2}(?!\d))/gu,
143
+ 0,
144
+ ],
145
+ [
146
+ 'EDUCATION',
147
+ 'document-msu',
148
+ /(?<![\p{L}\p{N}_])МГУ(?:[ \t]+им\.[ \t]+[А-ЯЁ][а-яё-]{1,40})?(?![\p{L}\p{N}_])/gu,
149
+ 0,
150
+ ],
151
+ [
152
+ 'PERSON',
153
+ 'document-patronymic',
154
+ /(?<![\p{L}\p{N}_])[А-ЯЁ][а-яё]{2,32}(?:овна|евна|ична|ович|евич)(?![\p{L}\p{N}_])/gu,
155
+ 0,
156
+ ],
157
+ [
158
+ 'PERSON',
159
+ 'document-uppercase-initials',
160
+ /(?<![\p{L}\p{N}_])[А-ЯЁ]{2,40}[ \t]+(?:[А-ЯЁA-Z]\.[ \t]*){1,2}(?![\p{L}\p{N}_])/gu,
161
+ 0,
162
+ ],
163
+ [
164
+ 'PERSON',
165
+ 'document-unfamiliar-name',
166
+ /(?<![\p{L}\p{N}_])(?:[А-ЯЁ][а-яё]{1,30}[ \t]+(?:Ким|[А-ЯЁ][а-яё]{1,25}(?:чук|енко|швили|дзе))|Ли[ \t]+[А-ЯЁ][а-яё]{1,20})(?![\p{L}\p{N}_])/gu,
167
+ 0,
168
+ ],
169
+ [
170
+ 'ADDRESS',
171
+ 'document-street-complete',
172
+ /(?<![\p{L}\p{N}_])(?:(?:ул\.?|улица|пр-т)[ \t]+[А-ЯЁ][а-яё-]{1,40},?[ \t]+(?:д\.[ \t]*)?\d{1,5}(?:-\d{1,5})?|[А-ЯЁ][а-яё-]{2,40}[ \t]+шоссе,[ \t]*\d{1,5})(?:,[ \t]*кв\.?[ \t]*\d{1,5})?(?!\d)/gu,
173
+ 0,
174
+ ],
175
+ [
176
+ 'LOCATION',
177
+ 'document-region',
178
+ /(?<![\p{L}\p{N}_])[А-ЯЁ][а-яё-]{2,40}[ \t]+(?:область|район)(?![\p{L}\p{N}_])/gu,
179
+ 0,
180
+ ],
181
+ [
182
+ 'PERSONAL_ATTRIBUTE',
183
+ 'document-citizenship',
184
+ /(?<![\p{L}\p{N}_])(?:гражданство|гражданин|гражданка)[ \t]+([А-ЯЁ][А-ЯЁа-яё-]{1,40})(?![\p{L}\p{N}_])/gu,
185
+ 1,
186
+ ],
187
+ [
188
+ 'LICENSE_PLATE',
189
+ 'document-vehicle-plate',
190
+ /(?<![\p{L}\p{N}_])[АВЕКМНОРСТУХABEKMHOPCTYX]\d{3}[АВЕКМНОРСТУХABEKMHOPCTYX]{2}[ \t]*\d{2,3}(?:[ \t]+RUS)?(?![\p{L}\p{N}_])/gu,
191
+ 0,
192
+ ],
193
+ ];
194
+
195
+ export function detectDocumentCatalog(text, emit) {
196
+ for (const [type, rule, pattern, group] of rules) {
197
+ collectMatches(
198
+ text,
199
+ pattern,
200
+ emit,
201
+ { type, rule, category: 'personal', confidence: 0.85 },
202
+ group,
203
+ (value) => type !== 'ORGANIZATION' || !isDocumentTechnology(value)
204
+ );
205
+ }
206
+ // Lookalike substitution preserves UTF-16 offsets and is limited to words
207
+ // containing Cyrillic plus Latin letters. Ordinary English is untouched.
208
+ const lookalikes = {
209
+ A: 'А',
210
+ a: 'а',
211
+ B: 'В',
212
+ C: 'С',
213
+ c: 'с',
214
+ E: 'Е',
215
+ e: 'е',
216
+ H: 'Н',
217
+ K: 'К',
218
+ M: 'М',
219
+ O: 'О',
220
+ o: 'о',
221
+ P: 'Р',
222
+ p: 'р',
223
+ T: 'Т',
224
+ X: 'Х',
225
+ x: 'х',
226
+ y: 'у',
227
+ };
228
+ const folded = text.replace(/[\p{L}]+/gu, (word) =>
229
+ /[А-Яа-яЁё]/u.test(word)
230
+ ? word.replace(/[AaBCcEeHKMOoPpTXxy]/g, (c) => lookalikes[c])
231
+ : word
232
+ );
233
+ if (folded !== text) {
234
+ collectMatches(
235
+ folded,
236
+ /(?<![\p{L}\p{N}_])[А-ЯЁ][а-яё]{1,30}[ \t]+[А-ЯЁ][а-яё]{1,28}(?:ова|ева|ина|ов|ев|ин|ский|ская)(?![\p{L}\p{N}_])/gu,
237
+ emit,
238
+ {
239
+ type: 'PERSON',
240
+ rule: 'document-mixed-name',
241
+ category: 'personal',
242
+ confidence: 0.9,
243
+ }
244
+ );
245
+ }
246
+ }
@@ -0,0 +1,15 @@
1
+ import { escapePattern } from './detection.js';
2
+ import { months } from './personal-context.js';
3
+
4
+ const boundary = '[\\p{L}\\p{M}\\p{N}_]';
5
+ const capitalized = (word) => word[0].toUpperCase() + word.slice(1);
6
+ const month = [
7
+ ...new Set(months.split('|').flatMap((word) => [word, capitalized(word)])),
8
+ ].join('|');
9
+ const rangeStart = `(?:(?:19|20)\\d{2}(?:-\\d{2}){0,2}|\\d{2}[./](?:\\d{2}\\.)?\\d{4}|(?:${month})\\.?[ \\t]+(?:19|20)\\d{2})`;
10
+ const rangeOpen = ['по настоящее время', 'настоящее время', 'сейчас', 'по н.в.']
11
+ .concat(['н.в.', 'по н/в', 'н/в', 'present', 'now', 'current'])
12
+ .flatMap((word) => [word, capitalized(word)])
13
+ .map(escapePattern)
14
+ .join('|');
15
+ export const documentDateRange = `${rangeStart}(?:[ \\t]*[-–—][ \\t]*|[ \\t]+(?:по|to)[ \\t]+)(?:${rangeStart}|${rangeOpen}|null|(?=\\)))(?!${boundary})`;
@@ -2,9 +2,12 @@ import { collectMatches, escapePattern } from './detection.js';
2
2
  import { firstNames } from './names.js';
3
3
  import { organizationCore, personalRegex } from './personal-variants.js';
4
4
  import { documentVocabulary } from './document-vocabulary.js';
5
- import { months } from './personal-context.js';
5
+ import { documentDateRange as range } from './document-dates.js';
6
6
  import { detectDocumentExtras } from './document-extras.js';
7
7
  import { detectDocumentPatterns } from './document-patterns.js';
8
+ import { isDocumentTechnology } from './document-experience.js';
9
+ import { detectVocabulary } from './vocabulary.js';
10
+ import { detectDocumentCatalog } from './document-catalog.js';
8
11
 
9
12
  const words = (column) =>
10
13
  [...new Set(documentVocabulary.flatMap((row) => row[column].split('|')))]
@@ -58,17 +61,6 @@ const groupOrganization = new RegExp(
58
61
  // CV experience lines: the date range anchors the employer slot, so prose with
59
62
  // a comma is untouched. Titles are not required to be lowercase because English
60
63
  // ones are often title case (`Senior QA Engineer`).
61
- const capitalized = (word) => word[0].toUpperCase() + word.slice(1);
62
- const month = [
63
- ...new Set(months.split('|').flatMap((word) => [word, capitalized(word)])),
64
- ].join('|');
65
- const rangeStart = `(?:(?:19|20)\\d{2}(?:-\\d{2}){0,2}|\\d{2}[./](?:\\d{2}\\.)?\\d{4}|(?:${month})\\.?[ \\t]+(?:19|20)\\d{2})`;
66
- const rangeOpen = ['по настоящее время', 'настоящее время', 'сейчас', 'по н.в.']
67
- .concat(['н.в.', 'по н/в', 'н/в', 'present', 'now', 'current'])
68
- .flatMap((word) => [word, capitalized(word)])
69
- .map(escapePattern)
70
- .join('|');
71
- const range = `${rangeStart}(?:[ \\t]*[-–—][ \\t]*|[ \\t]+(?:по|to)[ \\t]+)(?:${rangeStart}|${rangeOpen}|null|(?=\\)))(?!${boundary})`;
72
64
  const titleWord = '[\\p{L}\\p{N}][\\p{L}\\p{N}/+#.&-]{0,39}';
73
65
  // A segment starts a line or follows `|`. `Опыт:` or `Опыт -` before the title
74
66
  // reads like a list marker: the date range remains the anchor of the rule.
@@ -89,8 +81,26 @@ export function detectDocumentEntities(text, emit, options) {
89
81
  if (!options.documentEntities) {
90
82
  return;
91
83
  }
84
+ detectVocabulary(text, emit, options);
85
+ detectDocumentCatalog(text, emit);
92
86
  detectDocumentPatterns(text, emit);
93
- detectDocumentExtras(text, emit, range);
87
+ detectDocumentExtras(text, emit, range, options.debug, options);
88
+ collectMatches(
89
+ text,
90
+ new RegExp(
91
+ `(?:^|[\\r\\n])[ \\t]*[^|\\r\\n]{1,120}\\|[ \\t]*(${name})[ \\t]*\\|[ \\t]*${range}`,
92
+ 'gu'
93
+ ),
94
+ emit,
95
+ {
96
+ type: 'ORGANIZATION',
97
+ rule: 'organization-experience',
98
+ category: 'personal',
99
+ confidence: 0.85,
100
+ },
101
+ 1,
102
+ (value) => !isDocumentTechnology(value)
103
+ );
94
104
  const found = [];
95
105
  const capture = (finding) => {
96
106
  found.push(finding);
@@ -113,7 +123,10 @@ export function detectDocumentEntities(text, emit, options) {
113
123
  pattern,
114
124
  capture,
115
125
  { type, rule, category: 'personal', confidence },
116
- group
126
+ group,
127
+ (value) =>
128
+ type !== 'ORGANIZATION' ||
129
+ (!isDocumentTechnology(value) && value !== 'ОГРНИП')
117
130
  );
118
131
  }
119
132
  for (const match of text.matchAll(ruResidence)) {
@@ -0,0 +1,115 @@
1
+ import { compiledVocabulary } from './vocabulary.js';
2
+
3
+ const technology = new Set(
4
+ [
5
+ 'Python',
6
+ 'Go',
7
+ 'SQL',
8
+ 'Excel',
9
+ 'Power BI',
10
+ 'Kubernetes',
11
+ 'PostgreSQL',
12
+ 'Telegram',
13
+ 'Scrum',
14
+ ].map((s) => s.toLowerCase())
15
+ );
16
+ export const isDocumentTechnology = (value) =>
17
+ technology.has(value.trim().toLowerCase());
18
+
19
+ // Used only to disambiguate the reversed `Company, manager` layout. Forward
20
+ // entries deliberately accept any title, without a title vocabulary.
21
+ const reversedRole =
22
+ /^(?:менеджер|директор|руководитель|инженер|аналитик|разработчик|manager|director|engineer|developer|senior|qa)(?!\p{L})/iu;
23
+
24
+ function commaParts(text) {
25
+ const parts = [];
26
+ let start = 0,
27
+ depth = 0,
28
+ quote;
29
+ for (let i = 0; i < text.length; i++) {
30
+ const char = text[i];
31
+ if (quote) {
32
+ if (char === quote) {
33
+ quote = undefined;
34
+ }
35
+ } else if ('«“"'.includes(char)) {
36
+ quote = { '«': '»', '“': '”', '"': '"' }[char];
37
+ } else if (char === '(') {
38
+ depth++;
39
+ } else if (char === ')') {
40
+ depth = Math.max(0, depth - 1);
41
+ } else if (char === ',' && !depth) {
42
+ parts.push({ start, text: text.slice(start, i).trim() });
43
+ start = i + 1;
44
+ }
45
+ }
46
+ parts.push({ start, text: text.slice(start).trim() });
47
+ return parts;
48
+ }
49
+
50
+ function employerSpan(prefix, roles) {
51
+ // An industry suffix belongs to the entry, but is not an employer. A pipe
52
+ // before the title remains a section separator, as in `Опыт | Менеджер, ...`.
53
+ const comma = prefix.indexOf(',');
54
+ const pipe = comma < 0 ? -1 : prefix.indexOf('|', comma);
55
+ const content = pipe < 0 ? prefix : prefix.slice(0, pipe);
56
+ const parts = commaParts(content);
57
+ let index = 1;
58
+ if (parts.length === 1) {
59
+ return undefined;
60
+ }
61
+ if (/^[=\s\d]/u.test(parts[0].text)) {
62
+ index++;
63
+ }
64
+ if (parts.length === 2 && roles.test(parts[1].text)) {
65
+ index = 0;
66
+ } else {
67
+ while (index < parts.length - 1 && /^\p{Ll}/u.test(parts[index].text)) {
68
+ index++;
69
+ }
70
+ }
71
+ const part = parts[index];
72
+ if (!part || !/^(?:[\p{Lu}«“"]|\d{1,4}-?\p{L})/u.test(part.text)) {
73
+ return undefined;
74
+ }
75
+ const end = index === 0 ? content.indexOf(',') : content.trimEnd().length;
76
+ const start = part.start + content.slice(part.start).search(/\S/u);
77
+ return isDocumentTechnology(content.slice(start, end))
78
+ ? undefined
79
+ : { start, end };
80
+ }
81
+
82
+ export function detectDatedEmployers(text, emit, range, debug, options = {}) {
83
+ let cursor = 0,
84
+ count = 0;
85
+ // Match only the anchor, then visit each preceding segment once. No lazy
86
+ // prefix search restarts at every position on a long nonmatching line.
87
+ for (const match of text.matchAll(new RegExp(`\\(${range}\\)`, 'gu'))) {
88
+ const segment = text.slice(cursor, match.index);
89
+ const boundary =
90
+ Math.max(
91
+ segment.lastIndexOf('\n'),
92
+ segment.lastIndexOf('\r'),
93
+ segment.lastIndexOf(';')
94
+ ) + 1;
95
+ const prefix = segment.slice(boundary).replace(/^[., \t]+/u, '');
96
+ const span = employerSpan(
97
+ prefix,
98
+ compiledVocabulary(options.vocabulary)?.roles ?? reversedRole
99
+ );
100
+ if (span) {
101
+ const offset = match.index - prefix.length;
102
+ emit({
103
+ start: offset + span.start,
104
+ end: offset + span.end,
105
+ type: 'ORGANIZATION',
106
+ category: 'personal',
107
+ rule: 'organization-experience',
108
+ confidence: 0.85,
109
+ });
110
+ count++;
111
+ }
112
+ cursor = match.index + match[0].length;
113
+ }
114
+ debug?.({ event: 'document-experience', count });
115
+ }