paperlint 5.0.0 → 6.1.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.
Files changed (177) hide show
  1. package/CLAUDE.md +13 -0
  2. package/CONTRIBUTING.md +3 -0
  3. package/README.md +8 -8
  4. package/bin/paperlint.mjs +5 -1
  5. package/dist/adapters/latex/index.d.ts +2 -1
  6. package/dist/adapters/latex/index.d.ts.map +1 -1
  7. package/dist/adapters/latex/index.js +6 -2
  8. package/dist/adapters/latex/index.js.map +1 -1
  9. package/dist/adapters/latex/layout.d.ts +27 -0
  10. package/dist/adapters/latex/layout.d.ts.map +1 -0
  11. package/dist/adapters/latex/layout.js +62 -0
  12. package/dist/adapters/latex/layout.js.map +1 -0
  13. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  14. package/dist/adapters/latex/nodes.js +11 -1
  15. package/dist/adapters/latex/nodes.js.map +1 -1
  16. package/dist/adapters/latex/parse.d.ts.map +1 -1
  17. package/dist/adapters/latex/parse.js +5 -1
  18. package/dist/adapters/latex/parse.js.map +1 -1
  19. package/dist/adapters/latex/prose.d.ts +6 -1
  20. package/dist/adapters/latex/prose.d.ts.map +1 -1
  21. package/dist/adapters/latex/prose.js +142 -31
  22. package/dist/adapters/latex/prose.js.map +1 -1
  23. package/dist/cli.d.ts.map +1 -1
  24. package/dist/cli.js +35 -18
  25. package/dist/cli.js.map +1 -1
  26. package/dist/domain/anonymity.d.ts +68 -0
  27. package/dist/domain/anonymity.d.ts.map +1 -0
  28. package/dist/domain/anonymity.js +94 -0
  29. package/dist/domain/anonymity.js.map +1 -0
  30. package/dist/domain/body-pages.d.ts +69 -0
  31. package/dist/domain/body-pages.d.ts.map +1 -0
  32. package/dist/domain/body-pages.js +141 -0
  33. package/dist/domain/body-pages.js.map +1 -0
  34. package/dist/domain/register.d.ts +96 -0
  35. package/dist/domain/register.d.ts.map +1 -0
  36. package/dist/domain/register.js +136 -0
  37. package/dist/domain/register.js.map +1 -0
  38. package/dist/domain/tex-document.d.ts +28 -0
  39. package/dist/domain/tex-document.d.ts.map +1 -1
  40. package/dist/domain/tex-document.js +8 -3
  41. package/dist/domain/tex-document.js.map +1 -1
  42. package/dist/eslint-rules/pdf-last-page-balance.d.ts +1 -1
  43. package/dist/eslint-rules/pdf-last-page-balance.js +2 -2
  44. package/dist/facts-file.d.ts +28 -2
  45. package/dist/facts-file.d.ts.map +1 -1
  46. package/dist/facts-file.js +54 -9
  47. package/dist/facts-file.js.map +1 -1
  48. package/dist/lib/paper-config.d.ts.map +1 -1
  49. package/dist/lib/paper-config.js +2 -1
  50. package/dist/lib/paper-config.js.map +1 -1
  51. package/dist/paper-settings.d.ts +11 -0
  52. package/dist/paper-settings.d.ts.map +1 -1
  53. package/dist/paper-settings.js +38 -4
  54. package/dist/paper-settings.js.map +1 -1
  55. package/dist/pdf-content.d.ts +40 -0
  56. package/dist/pdf-content.d.ts.map +1 -0
  57. package/dist/pdf-content.js +97 -0
  58. package/dist/pdf-content.js.map +1 -0
  59. package/dist/pdf-facts.d.ts +30 -0
  60. package/dist/pdf-facts.d.ts.map +1 -1
  61. package/dist/pdf-facts.js +37 -1
  62. package/dist/pdf-facts.js.map +1 -1
  63. package/dist/ports/latex.d.ts +8 -1
  64. package/dist/ports/latex.d.ts.map +1 -1
  65. package/dist/presets.d.ts +5 -0
  66. package/dist/presets.d.ts.map +1 -1
  67. package/dist/presets.js +8 -1
  68. package/dist/presets.js.map +1 -1
  69. package/dist/register-bands.d.ts +37 -0
  70. package/dist/register-bands.d.ts.map +1 -0
  71. package/dist/register-bands.js +196 -0
  72. package/dist/register-bands.js.map +1 -0
  73. package/dist/register.d.ts +4 -2
  74. package/dist/register.d.ts.map +1 -1
  75. package/dist/register.js +6 -45
  76. package/dist/register.js.map +1 -1
  77. package/dist/tex-requirements.d.ts +10 -0
  78. package/dist/tex-requirements.d.ts.map +1 -1
  79. package/dist/tex-requirements.js +29 -6
  80. package/dist/tex-requirements.js.map +1 -1
  81. package/dist/tex-venue-rules.d.ts +8 -2
  82. package/dist/tex-venue-rules.d.ts.map +1 -1
  83. package/dist/tex-venue-rules.js +40 -0
  84. package/dist/tex-venue-rules.js.map +1 -1
  85. package/dist/types.d.ts +2 -0
  86. package/dist/types.d.ts.map +1 -1
  87. package/dist/venue-rules.d.ts +38 -8
  88. package/dist/venue-rules.d.ts.map +1 -1
  89. package/dist/venue-rules.js +179 -41
  90. package/dist/venue-rules.js.map +1 -1
  91. package/docs/configuration.md +18 -16
  92. package/docs/e2e.md +32 -11
  93. package/docs/rules/anonymity/identity.md +111 -0
  94. package/docs/rules/format/layout-override.md +61 -0
  95. package/docs/rules/format/page-limit.md +108 -0
  96. package/docs/rules/pdf/body-size.md +87 -0
  97. package/docs/rules/tex/claim-emphasis.md +71 -0
  98. package/docs/rules/tex/contrast-frames.md +100 -0
  99. package/docs/rules/tex/relation-markers.md +72 -0
  100. package/docs/rules.md +59 -50
  101. package/eslint-rules/pdf-last-page-balance.harness.mjs +6 -1
  102. package/eslint-rules/pdf-last-page-balance.ts +2 -2
  103. package/fixtures/build-e2e/aidc/expect.json +1 -1
  104. package/fixtures/build-e2e/aidc/paperlint.json +1 -1
  105. package/fixtures/build-e2e/anon-ack/PIPELINE-STATUS.md +3 -0
  106. package/fixtures/build-e2e/anon-ack/expect.json +19 -0
  107. package/fixtures/build-e2e/anon-ack/paper.tex +31 -0
  108. package/fixtures/build-e2e/anon-ack/paperlint.json +1 -0
  109. package/fixtures/build-e2e/anon-author/PIPELINE-STATUS.md +3 -0
  110. package/fixtures/build-e2e/anon-author/expect.json +19 -0
  111. package/fixtures/build-e2e/anon-author/paper.tex +29 -0
  112. package/fixtures/build-e2e/anon-author/paperlint.json +1 -0
  113. package/fixtures/build-e2e/anon-bib/PIPELINE-STATUS.md +3 -0
  114. package/fixtures/build-e2e/anon-bib/expect.json +20 -0
  115. package/fixtures/build-e2e/anon-bib/paper.tex +35 -0
  116. package/fixtures/build-e2e/anon-bib/paperlint.json +1 -0
  117. package/fixtures/build-e2e/anon-href/PIPELINE-STATUS.md +3 -0
  118. package/fixtures/build-e2e/anon-href/expect.json +19 -0
  119. package/fixtures/build-e2e/anon-href/paper.tex +30 -0
  120. package/fixtures/build-e2e/anon-href/paperlint.json +1 -0
  121. package/fixtures/build-e2e/anon-macro/PIPELINE-STATUS.md +3 -0
  122. package/fixtures/build-e2e/anon-macro/expect.json +19 -0
  123. package/fixtures/build-e2e/anon-macro/paper.tex +30 -0
  124. package/fixtures/build-e2e/anon-macro/paperlint.json +1 -0
  125. package/fixtures/build-e2e/anon-pdfauthor/PIPELINE-STATUS.md +3 -0
  126. package/fixtures/build-e2e/anon-pdfauthor/expect.json +19 -0
  127. package/fixtures/build-e2e/anon-pdfauthor/paper.tex +31 -0
  128. package/fixtures/build-e2e/anon-pdfauthor/paperlint.json +1 -0
  129. package/fixtures/build-e2e/unbalanced/expect.json +2 -2
  130. package/lib/paper-config.ts +3 -1
  131. package/package.json +1 -1
  132. package/presets/acm-sigconf.jsonc +3 -1
  133. package/presets/aidc.jsonc +66 -17
  134. package/presets/ieee-conference.jsonc +8 -0
  135. package/presets/venue-profile.schema.json +57 -1
  136. package/skills/grade-paper-writing/SKILL.md +59 -27
  137. package/skills/grade-paper-writing/SKILL.md.spec.ts +58 -26
  138. package/skills/render-paper/extract-pdf-facts.harness.ts +3 -3
  139. package/src/adapters/latex/index.test.ts +91 -0
  140. package/src/adapters/latex/index.ts +6 -2
  141. package/src/adapters/latex/layout.ts +106 -0
  142. package/src/adapters/latex/nodes.ts +11 -3
  143. package/src/adapters/latex/parse.ts +5 -1
  144. package/src/adapters/latex/prose.ts +188 -58
  145. package/src/build.harness.mjs +2 -2
  146. package/src/build.test.ts +5 -0
  147. package/src/cli.test.ts +13 -0
  148. package/src/cli.ts +50 -17
  149. package/src/domain/anonymity.test.ts +163 -0
  150. package/src/domain/anonymity.ts +129 -0
  151. package/src/domain/body-pages.test.ts +300 -0
  152. package/src/domain/body-pages.ts +223 -0
  153. package/src/domain/register.test.ts +153 -0
  154. package/src/domain/register.ts +261 -0
  155. package/src/domain/tex-document.ts +47 -3
  156. package/src/facts-file.test.ts +100 -4
  157. package/src/facts-file.ts +91 -9
  158. package/src/new-paper.test.ts +7 -1
  159. package/src/paper-settings.test.ts +63 -6
  160. package/src/paper-settings.ts +53 -5
  161. package/src/pdf-content.test.ts +71 -0
  162. package/src/pdf-content.ts +125 -0
  163. package/src/pdf-facts.test.ts +110 -2
  164. package/src/pdf-facts.ts +106 -8
  165. package/src/ports/latex.ts +9 -0
  166. package/src/presets.ts +15 -9
  167. package/src/register-bands.test.ts +327 -0
  168. package/src/register-bands.ts +291 -0
  169. package/src/register.ts +7 -37
  170. package/src/rule-cases.test.ts +96 -20
  171. package/src/rule-docs.test.ts +0 -2
  172. package/src/tex-requirements.ts +56 -8
  173. package/src/tex-venue-rules.test.ts +110 -5
  174. package/src/tex-venue-rules.ts +64 -3
  175. package/src/types.ts +2 -0
  176. package/src/venue-rules.test.ts +351 -39
  177. package/src/venue-rules.ts +247 -47
package/src/cli.ts CHANGED
@@ -49,12 +49,29 @@ import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
49
49
  import { curlDownload } from "./adapters/curl/index.ts";
50
50
  import { latexReader } from "./adapters/latex/index.ts";
51
51
  import { hostDirs, nodeAdapters, nodeFiles } from "./adapters/node/index.ts";
52
- import { VENUE_RULE_LEVELS, venueRules } from "./venue-rules.ts";
53
- import { TEX_VENUE_RULE_LEVELS, texVenueRules } from "./tex-venue-rules.ts";
52
+ import {
53
+ ANONYMITY_RULE_LEVELS,
54
+ anonymityRules,
55
+ PAGE_LIMIT_RULE_LEVELS,
56
+ pageLimitRules,
57
+ VENUE_RULE_LEVELS,
58
+ venueRules,
59
+ } from "./venue-rules.ts";
60
+ import {
61
+ FORMAT_RULE_LEVELS,
62
+ formatRules,
63
+ TEX_VENUE_RULE_LEVELS,
64
+ texVenueRules,
65
+ } from "./tex-venue-rules.ts";
54
66
  import { claimProvenanceRule } from "./claim-provenance.ts";
55
67
  import { registerRule } from "./register.ts";
68
+ import {
69
+ REGISTER_BAND_RULE_LEVELS,
70
+ registerBandRules,
71
+ } from "./register-bands.ts";
56
72
  import {
57
73
  paperRules,
74
+ identityField,
58
75
  stringFields,
59
76
  type PaperSettings,
60
77
  } from "./paper-settings.ts";
@@ -218,10 +235,18 @@ settings — paperlint.json, at two levels, one schema. Both are optional.
218
235
  blocks (files, ignores, rules) with globs relative to that file. Order, later wins: paperlint's
219
236
  own, the venue preset's, the root file's, the paper's. Optional rules (off unless turned on):
220
237
  pdf/last-page-balance. The venue rules (pdf/fresh, pdf/profile, pdf/fonts, pdf/geometry,
221
- pdf/limits, pdf/body-size, pdf/measured) are on for every paper with a venue preset; set one to
222
- "off" to skip it. An unknown key, in either file, is an error.
238
+ pdf/body-size, pdf/measured, format/page-limit, format/layout-override, anonymity/identity) are
239
+ on for every paper with a venue preset; set one to "off" to skip it. An unknown key, in either
240
+ file, is an error.
223
241
  `;
224
242
 
243
+ /** What the rules over a paper.tex read with: the disk, the shipped presets, the LaTeX reader. */
244
+ const TEX_RULE_DEPS = {
245
+ files: nodeFiles,
246
+ venuesDir: presetsDir(),
247
+ latex: latexReader,
248
+ };
249
+
225
250
  /** The config the user would otherwise write by hand. The data comes from `opts`, the mechanism is here. */
226
251
  export function buildConfig(
227
252
  opts: PaperlintConfig = {},
@@ -262,6 +287,9 @@ export function buildConfig(
262
287
  ...venueRules({ files: nodeFiles, venuesDir: presetsDir() }),
263
288
  },
264
289
  },
290
+ anonymity: {
291
+ rules: anonymityRules({ files: nodeFiles, venuesDir: presetsDir() }),
292
+ },
265
293
  },
266
294
  },
267
295
  {
@@ -308,23 +336,20 @@ export function buildConfig(
308
336
  languages: { latex: texLanguage },
309
337
  rules: {
310
338
  ...texBuild,
311
- ...texVenueRules({
312
- files: nodeFiles,
313
- venuesDir: presetsDir(),
314
- latex: latexReader,
315
- }),
316
- "claim-provenance": claimProvenanceRule({
317
- files: nodeFiles,
318
- latex: latexReader,
319
- }),
320
- register: registerRule({
321
- files: nodeFiles,
322
- latex: latexReader,
323
- }),
339
+ ...texVenueRules(TEX_RULE_DEPS),
340
+ "claim-provenance": claimProvenanceRule(TEX_RULE_DEPS),
341
+ register: registerRule(TEX_RULE_DEPS),
342
+ ...registerBandRules(TEX_RULE_DEPS),
324
343
  },
325
344
  },
326
345
  paper: { rules: texPaperRules },
327
346
  bib: bibReachable,
347
+ format: {
348
+ rules: {
349
+ ...formatRules(TEX_RULE_DEPS),
350
+ ...pageLimitRules({ files: nodeFiles, venuesDir: presetsDir() }),
351
+ },
352
+ },
328
353
  },
329
354
  language: "tex/latex",
330
355
  rules: {
@@ -337,10 +362,16 @@ export function buildConfig(
337
362
  "tex/claim-provenance": "warn",
338
363
  // Sentence-initial conjunctions over the whole body: register, not vocabulary.
339
364
  "tex/register": "warn",
365
+ // Contrast frames, claims in bold, relation markers: each against the band the venue
366
+ // preset's anchors set, silent for a preset without anchors (src/register-bands.ts).
367
+ ...REGISTER_BAND_RULE_LEVELS,
340
368
  "tex/acm-frontmatter-override": "error",
341
369
  // Silent for a paper whose paperlint.json names no venue (src/venue-rules.ts).
342
370
  ...VENUE_RULE_LEVELS,
371
+ ...ANONYMITY_RULE_LEVELS,
343
372
  ...TEX_VENUE_RULE_LEVELS,
373
+ ...FORMAT_RULE_LEVELS,
374
+ ...PAGE_LIMIT_RULE_LEVELS,
344
375
  },
345
376
  });
346
377
  // THE FILES A paper.tex INCLUDES FROM ITS BODY, each read on its own and reported at its own path
@@ -667,6 +698,8 @@ export function parseSettings(
667
698
  };
668
699
  const defaults = stringFields(raw);
669
700
  if (!defaults.ok) return { ok: false, error: `${where}: ${defaults.error}` };
701
+ const identity = identityField(raw);
702
+ if (!identity.ok) return { ok: false, error: `${where}: ${identity.error}` };
670
703
  const structure = Structure.safeParse(raw["structure"]);
671
704
  if (!structure.success)
672
705
  return {
@@ -0,0 +1,163 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ findLeaks,
4
+ spellingsOf,
5
+ tokenPattern,
6
+ type Searchable,
7
+ } from "./anonymity.ts";
8
+
9
+ const page = (text: string, n = 1): Searchable => ({
10
+ place: { kind: "page", page: n },
11
+ text,
12
+ });
13
+ const found = (token: string, text: string): string[] =>
14
+ findLeaks([token], [page(text)]).map((l) => l.found);
15
+
16
+ describe("findLeaks — the spellings of one name a PDF produces", () => {
17
+ it("plain, and in any case (the flags fold Unicode, no locale)", () => {
18
+ expect(found("Ada Example", "by Ada Example, 2026")).toEqual([
19
+ "Ada Example",
20
+ ]);
21
+ expect(found("Ada Example", "ADA EXAMPLE")).toEqual(["ADA EXAMPLE"]);
22
+ });
23
+
24
+ it("Cyrillic folds too", () => {
25
+ expect(found("Ада Пример", "АДА ПРИМЕР пишет")).toEqual(["АДА ПРИМЕР"]);
26
+ expect(found("АДА", "ада")).toEqual(["ада"]);
27
+ });
28
+
29
+ it("items joined without a space where kerning split a word", () => {
30
+ expect(found("Ada Example", "AdaExample")).toEqual(["AdaExample"]);
31
+ });
32
+
33
+ it("letter-spaced text", () => {
34
+ expect(found("Ada Example", "A d a E x a m p l e")).toEqual([
35
+ "A d a E x a m p l e",
36
+ ]);
37
+ });
38
+
39
+ it("a word hyphenated across a line break, and a name broken across lines", () => {
40
+ expect(found("Ada Example", "Ada Exam-\nple wrote")).toEqual([
41
+ "Ada Exam-\nple",
42
+ ]);
43
+ expect(found("Ada Example", "by Ada\nExample")).toEqual(["Ada\nExample"]);
44
+ expect(found("adaexample", "ada­example")).toEqual(["ada­example"]);
45
+ });
46
+
47
+ it("ligatures (NFKC on both sides)", () => {
48
+ expect(found("Fiona", "fiona")).toEqual(["fiona"]);
49
+ expect(found("fiona", "Fiona")).toEqual(["Fiona"]);
50
+ });
51
+
52
+ it("handles and addresses, as whole tokens", () => {
53
+ expect(found("adaexample", "https://github.com/adaexample/tool")).toEqual([
54
+ "adaexample",
55
+ ]);
56
+ expect(found("ada@example.org", "mail ada@example.org.")).toEqual([
57
+ "ada@example.org",
58
+ ]);
59
+ });
60
+
61
+ it("does not match inside another word", () => {
62
+ expect(found("Ada", "Canada and adamant")).toEqual([]);
63
+ expect(found("MIT", "submitted")).toEqual([]);
64
+ });
65
+
66
+ it("says nothing about a clean text", () => {
67
+ expect(found("Ada Example", "Anonymous Authors")).toEqual([]);
68
+ });
69
+ });
70
+
71
+ describe("findLeaks — places and tokens", () => {
72
+ it("reports each token once per place, with the place", () => {
73
+ const leaks = findLeaks(
74
+ ["Ada Example", "ada@example.org"],
75
+ [
76
+ page("Ada Example and Ada Example", 2),
77
+ {
78
+ place: { kind: "metadata", field: "Author" },
79
+ text: "ada@example.org",
80
+ },
81
+ {
82
+ place: {
83
+ kind: "link",
84
+ page: 3,
85
+ uri: "https://github.com/adaexample",
86
+ },
87
+ text: "https://github.com/adaexample",
88
+ },
89
+ ],
90
+ );
91
+ expect(leaks).toEqual([
92
+ {
93
+ token: "Ada Example",
94
+ place: { kind: "page", page: 2 },
95
+ found: "Ada Example",
96
+ },
97
+ {
98
+ token: "ada@example.org",
99
+ place: { kind: "metadata", field: "Author" },
100
+ found: "ada@example.org",
101
+ },
102
+ {
103
+ token: "Ada Example",
104
+ place: { kind: "link", page: 3, uri: "https://github.com/adaexample" },
105
+ found: "adaexample",
106
+ },
107
+ ]);
108
+ });
109
+
110
+ it("a token of separators only has no pattern and finds nothing", () => {
111
+ expect(tokenPattern(" - ")).toBeNull();
112
+ expect(findLeaks([" "], [page("anything at all")])).toEqual([]);
113
+ });
114
+ });
115
+
116
+ describe("findLeaks — one text, two tokens", () => {
117
+ it("is one leak, under the first token declared", () => {
118
+ expect(
119
+ findLeaks(["Ada Example", "adaexample"], [page("by AdaExample")]),
120
+ ).toEqual([
121
+ {
122
+ token: "Ada Example",
123
+ place: { kind: "page", page: 1 },
124
+ found: "AdaExample",
125
+ },
126
+ ]);
127
+ });
128
+ });
129
+
130
+ describe("spellingsOf — a personal name as a reference list prints it", () => {
131
+ it("adds initials and surname-first forms to a name of two to four words", () => {
132
+ expect(spellingsOf("Ada Example")).toEqual([
133
+ "Ada Example",
134
+ "A. Example",
135
+ "Example, Ada",
136
+ "Example, A.",
137
+ ]);
138
+ expect(spellingsOf("Ada Byron Example")).toEqual([
139
+ "Ada Byron Example",
140
+ "A. B. Example",
141
+ "Example, Ada Byron",
142
+ "Example, A. B.",
143
+ ]);
144
+ });
145
+
146
+ it("leaves a handle, an address, one word, or lowercase words alone", () => {
147
+ expect(spellingsOf("adaexample")).toEqual(["adaexample"]);
148
+ expect(spellingsOf("ada@example.org")).toEqual(["ada@example.org"]);
149
+ expect(spellingsOf("Example")).toEqual(["Example"]);
150
+ expect(spellingsOf("example tool")).toEqual(["example tool"]);
151
+ expect(spellingsOf("A B C D E")).toEqual(["A B C D E"]);
152
+ });
153
+
154
+ it("a self-citation's author list finds the declared name", () => {
155
+ expect(
156
+ found("Ada Example", "[3] A. Example and B. Other, “A tool,” 2025."),
157
+ ).toEqual(["A. Example"]);
158
+ expect(found("Ada Example", "Example, A., and Other, B. (2025)")).toEqual([
159
+ "Example, A.",
160
+ ]);
161
+ expect(found("Ada Example", "B. Example wrote")).toEqual([]);
162
+ });
163
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * WHO WROTE THIS PAPER, FOUND IN THE PAPER — the matching `anonymity/identity` judges a blind submission
3
+ * by. A rule cannot know who the author is, so the author DECLARES it (`identity` in
4
+ * `paperlint.json`); given that list, finding it is mechanical, and this module is the mechanism.
5
+ *
6
+ * ── WHAT A MATCH TOLERATES, AND WHY EACH ─────────────────────────────────────────
7
+ * PDF text is not the source text. pdf.js hands back runs of glyphs, and the same name comes out
8
+ * in several spellings of the same letters:
9
+ *
10
+ * ligatures `fi` for `fi` → both sides NFKC-normalized
11
+ * case `ADA EXAMPLE` in small caps → Unicode case folding (the `iu` flags: no
12
+ * locale is consulted, Cyrillic folds too)
13
+ * kerning splits `Ex` `ample` as two items → the page text joins items without a space
14
+ * letter-spacing `A d a E x a m p l e` → whitespace may stand between any two letters
15
+ * line breaks `Ada` ⏎ `Example`, `Exam-` ⏎ `ple` → so may a hyphen and a line break
16
+ * soft hyphens U+00AD inside a word → treated like a hyphen
17
+ * a reference list `A. Example`, `Example, A.` → a personal name's bibliographic forms
18
+ * are searched too (`spellingsOf`)
19
+ *
20
+ * Only the ENDS of a token are anchored: the letter or digit before its first character and after
21
+ * its last must be absent, so `Ada` does not match inside `Canada`. Between its characters anything
22
+ * of the above may stand. The token's own spaces and hyphens are separators too, so
23
+ * `Ada Example` also finds `Ada-Example` and `AdaExample` — the same person.
24
+ */
25
+
26
+ /** Where a declared string was found: a page's text, a metadata field, or a link's target. */
27
+ export type LeakPlace =
28
+ | { readonly kind: "page"; readonly page: number }
29
+ | { readonly kind: "metadata"; readonly field: string }
30
+ | { readonly kind: "link"; readonly page: number; readonly uri: string };
31
+
32
+ /** One text to search, and where it came from. */
33
+ export interface Searchable {
34
+ readonly place: LeakPlace;
35
+ readonly text: string;
36
+ }
37
+
38
+ /** One declared string found in one place, with the text it matched as it stands there. */
39
+ export interface Leak {
40
+ readonly token: string;
41
+ readonly place: LeakPlace;
42
+ readonly found: string;
43
+ }
44
+
45
+ /** What may stand between two characters of a token: whitespace, hyphens, soft hyphens. */
46
+ const SEPARATOR = "[\\s\\u00AD-]*";
47
+ const BEFORE = "(?<![\\p{L}\\p{N}])";
48
+ const AFTER = "(?![\\p{L}\\p{N}])";
49
+
50
+ /** `c` as a pattern that matches it literally. */
51
+ const literal = (c: string): string =>
52
+ c.replace(/[.*+?^${}()|[\]\\/]/gu, "\\$&");
53
+
54
+ /** The characters of a token that must appear: NFKC, without its own separators. */
55
+ const lettersOf = (token: string): readonly string[] =>
56
+ token
57
+ .normalize("NFKC")
58
+ .replace(/[\s\u00AD-]/gu, "")
59
+ .match(/./gsu) ?? [];
60
+
61
+ /** A word of a personal name: letters, with an apostrophe or a hyphen inside. */
62
+ const NAME_WORD = /^\p{Lu}[\p{L}'’-]*$/u;
63
+
64
+ /**
65
+ * The spellings a bibliography gives a personal name, besides the name itself. A token of two to
66
+ * four capitalized words (`Ada Example`, `Ada B. Example` is not one: an initial is not a word)
67
+ * is read as given names and a surname, and the reference list's forms are added: initials
68
+ * (`A. Example`), surname first (`Example, Ada`, `Example, A.`). A token that is not a name — a
69
+ * handle, an address, one word — has only itself.
70
+ */
71
+ export function spellingsOf(token: string): readonly string[] {
72
+ const words = token.trim().split(/\s+/u);
73
+ if (
74
+ words.length < 2 ||
75
+ words.length > 4 ||
76
+ !words.every((w) => NAME_WORD.test(w))
77
+ )
78
+ return [token];
79
+ const surname = words.slice(-1).join("");
80
+ const given = words.slice(0, -1);
81
+ const initials = given.map((w) => `${w.slice(0, 1)}.`).join(" ");
82
+ return [
83
+ token,
84
+ `${initials} ${surname}`,
85
+ `${surname}, ${given.join(" ")}`,
86
+ `${surname}, ${initials}`,
87
+ ];
88
+ }
89
+
90
+ /** One spelling as a pattern body: its characters, separators allowed between any two. */
91
+ const bodyOf = (spelling: string): string =>
92
+ lettersOf(spelling).map(literal).join(SEPARATOR);
93
+
94
+ /**
95
+ * A declared token as the pattern that finds it and its bibliographic spellings (see the module
96
+ * header), or null for a token with nothing but separators in it — which would match everywhere,
97
+ * and is refused where it is read.
98
+ */
99
+ export function tokenPattern(token: string): Readonly<RegExp> | null {
100
+ if (lettersOf(token).length === 0) return null;
101
+ const bodies = spellingsOf(token).map(bodyOf).join("|");
102
+ return new RegExp(`${BEFORE}(?:${bodies})${AFTER}`, "iu");
103
+ }
104
+
105
+ /**
106
+ * Every token found in the sources, once per place: its first match there. Two tokens that match
107
+ * the same text in the same place (`Ada Example` and `adaexample` both finding `AdaExample`) are one
108
+ * leak, reported under the first token declared. Pure.
109
+ */
110
+ export function findLeaks(
111
+ tokens: readonly string[],
112
+ sources: readonly Searchable[],
113
+ ): readonly Leak[] {
114
+ const patterns = tokens.flatMap((token) => {
115
+ const re = tokenPattern(token);
116
+ return re === null ? [] : [{ token, re }];
117
+ });
118
+ return sources.flatMap((s) => {
119
+ const text = s.text.normalize("NFKC");
120
+ const leaks = patterns.flatMap(({ token, re }) => {
121
+ const m = re.exec(text);
122
+ return m === null ? [] : [{ token, place: s.place, found: m[0] }];
123
+ });
124
+ const key = (l: Leak): string => l.found.toLowerCase();
125
+ return leaks.filter(
126
+ (l, i) => leaks.findIndex((x) => key(x) === key(l)) === i,
127
+ );
128
+ });
129
+ }