@link-foundation/sensitive-data-sanitizer 0.15.0 → 0.16.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 75fd2d9: Match a reused restore map PERSON entry like an explicit `knownPersonal` value, so a bare or lower-case part no longer merges people or fails with `ERR_RESIDUAL`, keep parts listed as their own values in an optional `standalone` entry field, cover the adjacent given name or patronymic when a bare part matches, and read labelled CV experience lines (`Опыт: Менеджер, Ромашка (2018 – 2020)`), titles of up to nine words and the `по н.в.` open range in the `documents` profile.
8
+
3
9
  ## 0.15.0
4
10
 
5
11
  ### Minor Changes
package/docs/API.md CHANGED
@@ -105,6 +105,24 @@ forms consume whole Unicode words; minimum stem lengths limit overmatching.
105
105
  This is conservative heuristic identity linking, not general coreference:
106
106
  ambiguous surnames/initials and unrelated people are not intentionally merged.
107
107
 
108
+ A PERSON entry of a reused map matches the same forms as an explicit
109
+ `knownPersonal` value: the full value and its contiguous parts together, all
110
+ cases, both orders and initials. A bare part does not match in the next document
111
+ unless it is stored as its own value, so `Петров Данил` from one CV leaves
112
+ `петров день` and `Петров пришёл.` in the next one untouched. A single stored word
113
+ (`Мороз`) matches its capitalized and upper-case forms but not lower-case text
114
+ (`сильный мороз`); a multi-word value also matches in lower case (`петров данил`).
115
+ A part listed in `knownPersonal` is kept in the entry's `standalone` list once it
116
+ has been replaced, and bare surfaces recorded by earlier versions are ignored on
117
+ reuse. The evidence of the current document is separate: when its text names
118
+ `Петров Данил`, a bare `Петров` or `петрову` in that document, including its stream
119
+ batches and decoded values, links to that person exactly as with a fresh map.
120
+
121
+ When a bare part matches, the span in placeholder mode takes the capitalized
122
+ given name or patronymic next to it on the same line, so `Алина Мороз` becomes one
123
+ new token instead of `Алина Person-1`. Given names come from a bounded list with
124
+ Russian case endings, so an unlisted name can still stay beside the placeholder.
125
+
108
126
  A listed Russian PERSON value also matches all six grammatical cases, including
109
127
  the instrumental (`Петровым Данилом Сергеевичем`), every word order, `ё`/`е`
110
128
  spelling differences in either direction, upper case, and initials with or
package/docs/COVERAGE.md CHANGED
@@ -59,9 +59,15 @@ missed. Legal-form words and uncased scripts can also yield false positives.
59
59
  The `documents` profile also reads employers from CV experience lines anchored on
60
60
  a date range: `Title, Employer (2019-05 – 2021-03)`, `Title — Employer, 2019–2021`
61
61
  and `Title (Employer), 2019–2021`. Ranges use years, `YYYY-MM`, `MM.YYYY`, full
62
- dates or `month YYYY`, with `по настоящее время`, `н.в.`, `present` and similar
63
- open ends. Confidence is 0.60, and the employer then propagates through the
64
- document like other organizations. The default profile ignores these lines. Lines
62
+ dates or `month YYYY`, with `по настоящее время`, `н.в.`, `по н.в.`, `present` and
63
+ similar open ends. Titles have one to nine words. A label of one or two words
64
+ before the title (`Опыт: `, `Опыт работы - `, `Опыт — `) is read like a list marker,
65
+ and a segment after `|` is read like a line start, so
66
+ `Должность: Менеджер | Опыт: Аналитик, Ромашка (2018 – 2020)` also yields the
67
+ employer. The date range stays the anchor and the employer must still start with a
68
+ capital letter, a digit or a quote. Confidence is 0.60, and the employer then
69
+ propagates through the document like other organizations. The default profile
70
+ ignores these lines. Lines
65
71
  with more fields (`Title, Employer, City (dates)`) and titles containing a comma
66
72
  are not targeted and can leave the employer in place; date-anchored prose with
67
73
  the same shape can be flagged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-foundation/sensitive-data-sanitizer",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Offline sensitive-text sanitization with credential, multilingual personal-data and scanner union detection",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -61,14 +61,17 @@ const month = [
61
61
  ...new Set(months.split('|').flatMap((word) => [word, capitalized(word)])),
62
62
  ].join('|');
63
63
  const rangeStart = `(?:(?:19|20)\\d{2}(?:-\\d{2}){0,2}|\\d{2}[./](?:\\d{2}\\.)?\\d{4}|(?:${month})\\.?[ \\t]+(?:19|20)\\d{2})`;
64
- const rangeOpen = ['по настоящее время', 'настоящее время', 'сейчас', 'н.в.']
65
- .concat(['н/в', 'present', 'now', 'current'])
64
+ const rangeOpen = ['по настоящее время', 'настоящее время', 'сейчас', 'по н.в.']
65
+ .concat(['н.в.', 'по н/в', 'н/в', 'present', 'now', 'current'])
66
66
  .flatMap((word) => [word, capitalized(word)])
67
67
  .map(escapePattern)
68
68
  .join('|');
69
69
  const range = `${rangeStart}(?:[ \\t]*[-–—][ \\t]*|[ \\t]+(?:по|to)[ \\t]+)(?:${rangeStart}|${rangeOpen})(?!${boundary})`;
70
70
  const titleWord = '[\\p{L}\\p{N}][\\p{L}\\p{N}/+#.&-]{0,39}';
71
- const lineTitle = `^[ \\t]*(?:[-–—•*·][ \\t]+|\\d{1,2}[.)][ \\t]+)?${titleWord}(?:[ \\t]+${titleWord}){0,5}`;
71
+ // A segment starts a line or follows `|`. `Опыт:` or `Опыт -` before the title
72
+ // reads like a list marker: the date range remains the anchor of the rule.
73
+ const lineLabel = `\\p{L}[\\p{L}\\p{N}/+#.&-]{0,39}(?:[ \\t]+${titleWord})?(?:[ \\t]*:[ \\t]*|[ \\t]+[-–—][ \\t]+)`;
74
+ const lineTitle = `(?:^|(?<=\\|))[ \\t]*(?:[-–—•*·][ \\t]+|\\d{1,2}[.)][ \\t]+)?(?:${lineLabel}){0,2}${titleWord}(?:[ \\t]+${titleWord}){0,8}`;
72
75
  const employerWord = "[\\p{L}\\p{M}\\p{N}&'’.+-]{1,40}";
73
76
  const employer = `«[^«»\\r\\n]{1,160}»|"[^"\\r\\n]{1,160}"|“[^“”\\r\\n]{1,160}”|(?:\\p{Lu}|\\d{1,4}-?\\p{L})${employerWord}?(?:[ \\t]+${employerWord}){0,5}`;
74
77
  const experienceLine = new RegExp(
package/src/index.d.ts CHANGED
@@ -269,6 +269,8 @@ export interface RestoreMap {
269
269
  number: number;
270
270
  values: string[];
271
271
  aliases: string[];
272
+ /** PERSON parts listed by the caller, which also match alone on reuse. */
273
+ standalone?: string[];
272
274
  }>;
273
275
  documents: Array<{
274
276
  digest: string;
@@ -167,6 +167,10 @@ export function detectKnownPersonal(text, entries, rule, emit, options) {
167
167
  ...(family ? [family.pattern] : []),
168
168
  ]) {
169
169
  for (const match of text.matchAll(pattern)) {
170
+ // A stored capitalized word in lower case is ordinary text: `Мороз`, `мороз`.
171
+ if (entry.properCase && /^\p{Ll}/u.test(match[0])) {
172
+ continue;
173
+ }
170
174
  if (++count > (options.maxFindings ?? 100000)) {
171
175
  throw failure('ERR_LIMIT');
172
176
  }
@@ -1,6 +1,7 @@
1
1
  import { failure, REDACTED, escapePattern } from './detection.js';
2
2
  import { reservedPlaceholderTypes } from './placeholder-types.js';
3
3
  import { samePersonal } from './personal-variants.js';
4
+ import { isNameForm } from './russian-names.js';
4
5
  import { nameAliases } from './transliteration.js';
5
6
  import { createHash } from 'node:crypto';
6
7
  import { jsonStringLiterals } from './json-string-runs.js';
@@ -85,13 +86,50 @@ export function placeholderKnown(options) {
85
86
  ...map.entries
86
87
  .filter((entry) => entry.type !== 'CREDENTIAL')
87
88
  .flatMap((entry) =>
88
- entry.values
89
+ (entry.type === 'PERSON' ? reusablePersonValues(entry) : entry.values)
89
90
  .filter((value) => value.length <= 256)
90
- .map((value) => ({ type: entry.type, value }))
91
+ .map((value) => ({
92
+ type: entry.type,
93
+ value,
94
+ ...(entry.type === 'PERSON' && properCaseWord(value)
95
+ ? { properCase: true }
96
+ : {}),
97
+ }))
91
98
  ),
92
99
  ],
93
100
  };
94
101
  }
102
+ const singleWord = (value) => !/\s/u.test(value.trim());
103
+ const properCaseWord = (value) =>
104
+ singleWord(value) && /^\p{Lu}/u.test(value.trim());
105
+ // A bare part recorded while replacing a fuller name in its own document is not
106
+ // evidence on its own in the next one, so a reused map offers only the full
107
+ // values, parts listed by the caller and values that were stored alone.
108
+ function reusablePersonValues(entry) {
109
+ const longer = entry.values.filter((value) => !singleWord(value));
110
+ return [
111
+ ...new Set([
112
+ ...entry.values.filter(
113
+ (value) =>
114
+ !singleWord(value) ||
115
+ !longer.some((full) => partOfPerson(value, full))
116
+ ),
117
+ ...(entry.standalone ?? []),
118
+ ]),
119
+ ];
120
+ }
121
+ function partOfPerson(value, full) {
122
+ return (
123
+ related(
124
+ value,
125
+ { type: 'PERSON', aliases: aliases(full, 'PERSON') },
126
+ true
127
+ ) ||
128
+ full
129
+ .split(/\s+/u)
130
+ .some((word) => word && samePersonal(value, word, 'PERSON'))
131
+ );
132
+ }
95
133
  const stem = (word) =>
96
134
  word.replace(
97
135
  /(?:овым|евым|овой|евой|ова|ева|ову|еву|ове|еве|ов|ев|ovym|evym|ovoi|evoi|ova|eva|ovu|evu|ove|eve|ov|ev)$/u,
@@ -115,7 +153,22 @@ function numberedOrganization(value, entry) {
115
153
  return base !== original && samePersonal(value, base, entry.type);
116
154
  });
117
155
  }
118
- function related(value, entry) {
156
+ function wordsMatchAlias(words, full, parts) {
157
+ if (words.length === 1) {
158
+ return parts && words[0].length > 1 && full.includes(words[0]);
159
+ }
160
+ return (
161
+ words.length > 1 &&
162
+ words.length === full.length &&
163
+ words.every(
164
+ (part, i) =>
165
+ part === full[i] || (part.length === 1 && full[i].startsWith(part))
166
+ )
167
+ );
168
+ }
169
+ // `parts` links a bare word to a fuller alias. Only callers that hold evidence
170
+ // from the current document may ask for it; a stored entry alone is not enough.
171
+ function related(value, entry, parts = false) {
119
172
  if (
120
173
  (entry.values ?? []).some((original) =>
121
174
  samePersonal(value, original, entry.type)
@@ -133,46 +186,35 @@ function related(value, entry) {
133
186
  if (entry.type !== 'PERSON') {
134
187
  return false;
135
188
  }
136
- for (const candidate of candidates) {
137
- const parts = candidate.match(/[\p{L}\p{M}]+/gu) ?? [];
138
- for (const alias of entry.aliases) {
139
- const full = alias.match(/[\p{L}\p{M}]+/gu) ?? [];
140
- if (
141
- parts.length === 1 &&
142
- parts[0].length > 1 &&
143
- full.includes(parts[0])
144
- ) {
145
- return true;
146
- }
147
- if (
148
- parts.length === full.length &&
149
- parts.length > 1 &&
150
- parts.every(
151
- (part, i) =>
152
- part === full[i] || (part.length === 1 && full[i].startsWith(part))
153
- )
154
- ) {
155
- return true;
156
- }
157
- }
158
- }
159
- return false;
189
+ return candidates.some((candidate) => {
190
+ const words = candidate.match(/[\p{L}\p{M}]+/gu) ?? [];
191
+ return entry.aliases.some((alias) =>
192
+ wordsMatchAlias(words, alias.match(/[\p{L}\p{M}]+/gu) ?? [], parts)
193
+ );
194
+ });
160
195
  }
161
196
  export function placeholderNameFindings(text, findings, options) {
162
197
  if (!hasPlaceholders(options) || options.knownOnly) {
163
198
  return findings;
164
199
  }
165
- const people = [
200
+ const state = options[placeholderState];
201
+ const patterns = new Set();
202
+ // Bare surname forms need the full name in this document. A stored person
203
+ // keeps only the initial form, which is itself a full mention.
204
+ for (const person of [
166
205
  ...findings
167
206
  .filter((f) => f.type === 'PERSON')
168
207
  .map((f) => text.slice(f.start, f.end)),
169
- ...(options[placeholderState]?.map.entries ?? [])
170
- .filter((e) => e.type === 'PERSON')
171
- .flatMap((e) => e.values),
172
- ];
173
- const patterns = new Set();
174
- for (const person of people) {
175
- addNamePatterns(person, patterns);
208
+ ...earlierPeople(options),
209
+ ]) {
210
+ addNamePatterns(person, patterns, true);
211
+ }
212
+ for (const entry of state?.map.entries ?? []) {
213
+ if (entry.type === 'PERSON') {
214
+ for (const person of entry.values) {
215
+ addNamePatterns(person, patterns, false);
216
+ }
217
+ }
176
218
  }
177
219
  const result = [...findings];
178
220
  for (const pattern of patterns) {
@@ -194,7 +236,56 @@ export function placeholderNameFindings(text, findings, options) {
194
236
  }
195
237
  return result;
196
238
  }
197
- function addNamePatterns(person, patterns) {
239
+ // Stream batches and decoded layers are pieces of the document being replaced.
240
+ function earlierPeople(options) {
241
+ const state = options[placeholderState];
242
+ return state?.stream || options[placeholderNested]
243
+ ? (state?.people ?? [])
244
+ : [];
245
+ }
246
+ const nameNeighbour = "\\p{Lu}[\\p{L}\\p{M}'’-]{1,31}";
247
+ const nameBefore = new RegExp(
248
+ `(?<![\\p{L}\\p{M}\\p{N}_-])(${nameNeighbour})[ \\t]+$`,
249
+ 'u'
250
+ );
251
+ const nameAfter = new RegExp(
252
+ `^[ \\t]+(${nameNeighbour})(?![\\p{L}\\p{M}\\p{N}_])`,
253
+ 'u'
254
+ );
255
+ const neighbourWindow = 64;
256
+ // `Алина` beside a bare `петрову` is the rest of that mention: replacing only the
257
+ // surname would leave that given name beside the token of the surname's owner.
258
+ export function coverAdjacentNames(text, findings, options) {
259
+ if (!hasPlaceholders(options)) {
260
+ return findings;
261
+ }
262
+ return findings.map((finding) => {
263
+ if (
264
+ finding.type !== 'PERSON' ||
265
+ !singleWord(text.slice(finding.start, finding.end))
266
+ ) {
267
+ return finding;
268
+ }
269
+ let { start, end } = finding;
270
+ // A given name and a patronymic can both stand on one side of a surname.
271
+ for (let step = 0; step < 2; step++) {
272
+ const before = nameBefore.exec(
273
+ text.slice(Math.max(0, start - neighbourWindow), start)
274
+ );
275
+ if (before && isNameForm(before[1])) {
276
+ start -= before[0].length;
277
+ }
278
+ const after = nameAfter.exec(text.slice(end, end + neighbourWindow));
279
+ if (after && isNameForm(after[1])) {
280
+ end += after[0].length;
281
+ }
282
+ }
283
+ return start === finding.start && end === finding.end
284
+ ? finding
285
+ : { ...finding, start, end, rule: 'name-variant' };
286
+ });
287
+ }
288
+ function addNamePatterns(person, patterns, bareForms) {
198
289
  const words = person.match(/[\p{L}\p{M}]+/gu) ?? [];
199
290
  if (words.length < 2 || words.some((word) => word.length < 2)) {
200
291
  return;
@@ -207,7 +298,7 @@ function addNamePatterns(person, patterns) {
207
298
  patterns.add(
208
299
  `${escapePattern(parts[0][0])}\\.[ \\t]+${parts.slice(1).map(escapePattern).join('[ \\t]+')}`
209
300
  );
210
- for (const word of parts) {
301
+ for (const word of bareForms ? parts : []) {
211
302
  if (/^[а-яё]+(?:ов|ев)$/u.test(word)) {
212
303
  patterns.add(`${escapePattern(word)}(?:ым|ой|а|у|е|ы|ых|ами)?`);
213
304
  }
@@ -241,14 +332,16 @@ export function preparePlaceholderText(text, findings, options) {
241
332
  if (namespaceCollision(text, pattern)) {
242
333
  throw failure('ERR_PLACEHOLDER_COLLISION');
243
334
  }
244
- const people = findings
245
- .filter((f) => f.type === 'PERSON')
246
- .map((f) => text.slice(f.start, f.end));
247
335
  state.people = [
248
- ...people,
249
- ...state.map.entries
250
- .filter((e) => e.type === 'PERSON')
251
- .flatMap((e) => e.values),
336
+ ...new Set([
337
+ ...earlierPeople(options),
338
+ ...findings
339
+ .filter((f) => f.type === 'PERSON')
340
+ .map((f) => text.slice(f.start, f.end)),
341
+ ...(options.knownPersonal ?? [])
342
+ .filter((e) => e.type === 'PERSON')
343
+ .map((e) => e.value),
344
+ ]),
252
345
  ];
253
346
  }
254
347
  function namespaceCollision(text, pattern) {
@@ -275,7 +368,11 @@ export function canonicalPerson(value, people) {
275
368
  return (
276
369
  words.length > 1 &&
277
370
  words.every((word) => word.length > 1) &&
278
- related(value, { type: 'PERSON', aliases: aliases(person, 'PERSON') })
371
+ related(
372
+ value,
373
+ { type: 'PERSON', aliases: aliases(person, 'PERSON') },
374
+ true
375
+ )
279
376
  );
280
377
  });
281
378
  const distinct = new Map(
@@ -293,10 +390,33 @@ function canonicalKnown(value, type, options, state) {
293
390
  // `Петров Данил` and `Петров Данил Сергеевич` are one person: the fullest wins.
294
391
  const longest = Math.max(0, ...known.map((e) => e.value.length));
295
392
  const fullest = known.filter((e) => e.value.length === longest);
296
- if (fullest.length === 1) {
297
- return fullest[0].value;
393
+ const listed = fullest.length === 1 ? fullest[0].value : value;
394
+ if (type !== 'PERSON') {
395
+ return listed;
396
+ }
397
+ // The caller's list names each person once, while the document may hold the
398
+ // same full name in several cases, so the list settles a listed part first.
399
+ const named = canonicalPerson(
400
+ listed,
401
+ (options.knownPersonal ?? [])
402
+ .filter((e) => e.type === 'PERSON')
403
+ .map((e) => e.value)
404
+ );
405
+ return named === listed ? canonicalPerson(listed, state.people ?? []) : named;
406
+ }
407
+ // A part the caller listed is stored as its own value, unlike a part that was
408
+ // only recorded as a surface of a longer name.
409
+ function rememberListedParts(entry, value, options) {
410
+ for (const known of options.knownPersonal ?? []) {
411
+ if (
412
+ known.type === 'PERSON' &&
413
+ singleWord(known.value) &&
414
+ samePersonal(value, known.value, 'PERSON') &&
415
+ !entry.standalone?.includes(known.value)
416
+ ) {
417
+ (entry.standalone ??= []).push(known.value);
418
+ }
298
419
  }
299
- return type === 'PERSON' ? canonicalPerson(value, state.people ?? []) : value;
300
420
  }
301
421
  export function placeholderReplacement(value, finding, transform, options) {
302
422
  if (finding.category === 'credential' && !transform.credentials) {
@@ -352,6 +472,9 @@ export function placeholderReplacement(value, finding, transform, options) {
352
472
  if (!entry.values.includes(value)) {
353
473
  entry.values.push(value);
354
474
  }
475
+ if (type === 'PERSON') {
476
+ rememberListedParts(entry, value, options);
477
+ }
355
478
  return entry.token;
356
479
  }
357
480
  export function finishPlaceholders(result, occurrences, options) {
@@ -85,7 +85,8 @@ function validateEntries(map) {
85
85
  tokens.has(entry.token) ||
86
86
  !nonemptyStrings(entry.values) ||
87
87
  !Array.isArray(entry.aliases) ||
88
- entry.aliases.some((v) => typeof v !== 'string' || !v)
88
+ entry.aliases.some((v) => typeof v !== 'string' || !v) ||
89
+ (entry.standalone !== undefined && !nonemptyStrings(entry.standalone))
89
90
  ) {
90
91
  throw failure('ERR_MAP');
91
92
  }
@@ -9,6 +9,34 @@ const givenNames = new Set(firstNames.ru.split(' ').map(fold));
9
9
  export const isPatronymic = (word) => patronymic.test(word);
10
10
  export const isGivenName = (word) => givenNames.has(fold(word));
11
11
 
12
+ // Only decides whether a word beside a known person belongs to that mention, so
13
+ // it stays apart from the detector's list, which would match these names anywhere.
14
+ const mentionGivenNames =
15
+ 'Роман Антон Илья Артём Никита Егор Глеб Денис Игорь Константин Кирилл Леонид Матвей Тимофей Фёдор Руслан Станислав Вячеслав Вадим Валерий Геннадий Григорий Степан Тимур Ярослав Юлия Дарья Полина Ксения Виктория Анастасия Вероника Валерия Алёна Оксана Людмила Галина Лариса Надежда Вера Любовь Кристина Софья Диана Яна Лилия Альбина Жанна Регина Инна Карина Милана Арина Варвара Елизавета Маргарита';
16
+ const anyGivenName = new Set(
17
+ [...Object.values(firstNames), mentionGivenNames]
18
+ .flatMap((list) => list.split(' '))
19
+ .map(fold)
20
+ );
21
+ const inflectedPatronymic =
22
+ /(?:[оеь]вич|[оеь]ич|ич)(?:а|у|ем|е)?$|(?:овн|евн|ичн)(?:а|ы|е|у|ой|ою)$/iu;
23
+ const nameCase = /(?:ом|ем|ой|ей|ою|ею|а|я|у|ю|е|ы|и)$/u;
24
+ // A capitalized given name or patronymic in any case, for deciding whether a
25
+ // neighbouring word belongs to the same mention. Lemmas are recovered by a
26
+ // finite ending list, so this stays a lookup and never parses the sentence.
27
+ export function isNameForm(word) {
28
+ const lower = fold(word);
29
+ if (anyGivenName.has(lower) || inflectedPatronymic.test(lower)) {
30
+ return true;
31
+ }
32
+ const stem = lower.replace(nameCase, '');
33
+ return (
34
+ stem !== lower &&
35
+ stem.length >= 2 &&
36
+ ['', 'а', 'я', 'й', 'ь'].some((ending) => anyGivenName.has(stem + ending))
37
+ );
38
+ }
39
+
12
40
  // Returns word indexes by role, or undefined when the roles are not evident.
13
41
  // `Имя Отчество Фамилия` is checked first so that surnames in -ич, as in
14
42
  // `Шостакович Дмитрий Дмитриевич`, are not mistaken for patronymics.
package/src/sanitizer.js CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  hasPlaceholders,
27
27
  placeholderValues,
28
28
  placeholderNameFindings,
29
+ coverAdjacentNames,
29
30
  nestedPlaceholderOptions,
30
31
  } from './placeholders.js';
31
32
  import {
@@ -466,7 +467,11 @@ export function reviewFindings(text, findings, options) {
466
467
  text,
467
468
  markFake(
468
469
  text,
469
- propagate(text, markFake(text, [...unique.values()], options), options),
470
+ coverAdjacentNames(
471
+ text,
472
+ propagate(text, markFake(text, [...unique.values()], options), options),
473
+ options
474
+ ),
470
475
  options
471
476
  ).sort(
472
477
  (a, b) =>