paperlint 4.2.0 → 6.0.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 (251) hide show
  1. package/CLAUDE.md +15 -2
  2. package/CONTRIBUTING.md +3 -0
  3. package/README.md +23 -12
  4. package/action.harness.mjs +54 -36
  5. package/action.yml +12 -27
  6. package/bin/paperlint.mjs +5 -1
  7. package/dist/adapters/latex/includes.d.ts +5 -1
  8. package/dist/adapters/latex/includes.d.ts.map +1 -1
  9. package/dist/adapters/latex/includes.js +8 -2
  10. package/dist/adapters/latex/includes.js.map +1 -1
  11. package/dist/adapters/latex/index.d.ts +1 -0
  12. package/dist/adapters/latex/index.d.ts.map +1 -1
  13. package/dist/adapters/latex/index.js +3 -0
  14. package/dist/adapters/latex/index.js.map +1 -1
  15. package/dist/adapters/latex/layout.d.ts +27 -0
  16. package/dist/adapters/latex/layout.d.ts.map +1 -0
  17. package/dist/adapters/latex/layout.js +62 -0
  18. package/dist/adapters/latex/layout.js.map +1 -0
  19. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  20. package/dist/adapters/latex/nodes.js +11 -1
  21. package/dist/adapters/latex/nodes.js.map +1 -1
  22. package/dist/adapters/latex/parse.d.ts.map +1 -1
  23. package/dist/adapters/latex/parse.js +5 -1
  24. package/dist/adapters/latex/parse.js.map +1 -1
  25. package/dist/build.d.ts.map +1 -1
  26. package/dist/build.js +7 -3
  27. package/dist/build.js.map +1 -1
  28. package/dist/cli.d.ts +2 -8
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +92 -42
  31. package/dist/cli.js.map +1 -1
  32. package/dist/domain/anonymity.d.ts +68 -0
  33. package/dist/domain/anonymity.d.ts.map +1 -0
  34. package/dist/domain/anonymity.js +94 -0
  35. package/dist/domain/anonymity.js.map +1 -0
  36. package/dist/domain/body-pages.d.ts +69 -0
  37. package/dist/domain/body-pages.d.ts.map +1 -0
  38. package/dist/domain/body-pages.js +141 -0
  39. package/dist/domain/body-pages.js.map +1 -0
  40. package/dist/domain/paper-source.js +2 -2
  41. package/dist/domain/tex-document.d.ts +9 -0
  42. package/dist/domain/tex-document.d.ts.map +1 -1
  43. package/dist/eslint-rules/latex-language.d.ts +5 -1
  44. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  45. package/dist/eslint-rules/latex-language.js +6 -3
  46. package/dist/eslint-rules/latex-language.js.map +1 -1
  47. package/dist/eslint-rules/paper-context.d.ts +20 -0
  48. package/dist/eslint-rules/paper-context.d.ts.map +1 -0
  49. package/dist/eslint-rules/paper-context.js +44 -0
  50. package/dist/eslint-rules/paper-context.js.map +1 -0
  51. package/dist/eslint-rules/paper-typography.d.ts +1 -1
  52. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  53. package/dist/eslint-rules/paper-typography.js +32 -18
  54. package/dist/eslint-rules/paper-typography.js.map +1 -1
  55. package/dist/eslint-rules/pdf-last-page-balance.d.ts +1 -1
  56. package/dist/eslint-rules/pdf-last-page-balance.js +2 -2
  57. package/dist/eslint-rules/tex-build.d.ts.map +1 -1
  58. package/dist/eslint-rules/tex-build.js +7 -1
  59. package/dist/eslint-rules/tex-build.js.map +1 -1
  60. package/dist/facts-file.d.ts +28 -2
  61. package/dist/facts-file.d.ts.map +1 -1
  62. package/dist/facts-file.js +54 -9
  63. package/dist/facts-file.js.map +1 -1
  64. package/dist/init.d.ts +4 -19
  65. package/dist/init.d.ts.map +1 -1
  66. package/dist/init.js +24 -29
  67. package/dist/init.js.map +1 -1
  68. package/dist/lib/paper-config.d.ts.map +1 -1
  69. package/dist/lib/paper-config.js +2 -1
  70. package/dist/lib/paper-config.js.map +1 -1
  71. package/dist/package-dirs.d.ts +2 -0
  72. package/dist/package-dirs.d.ts.map +1 -1
  73. package/dist/package-dirs.js +8 -0
  74. package/dist/package-dirs.js.map +1 -1
  75. package/dist/paper-files.d.ts +2 -2
  76. package/dist/paper-includes.d.ts +17 -0
  77. package/dist/paper-includes.d.ts.map +1 -0
  78. package/dist/paper-includes.js +51 -0
  79. package/dist/paper-includes.js.map +1 -0
  80. package/dist/paper-settings.d.ts +11 -0
  81. package/dist/paper-settings.d.ts.map +1 -1
  82. package/dist/paper-settings.js +38 -4
  83. package/dist/paper-settings.js.map +1 -1
  84. package/dist/pdf-content.d.ts +40 -0
  85. package/dist/pdf-content.d.ts.map +1 -0
  86. package/dist/pdf-content.js +97 -0
  87. package/dist/pdf-content.js.map +1 -0
  88. package/dist/pdf-facts.d.ts +30 -0
  89. package/dist/pdf-facts.d.ts.map +1 -1
  90. package/dist/pdf-facts.js +37 -1
  91. package/dist/pdf-facts.js.map +1 -1
  92. package/dist/ports/latex.d.ts +3 -1
  93. package/dist/ports/latex.d.ts.map +1 -1
  94. package/dist/presets.d.ts +2 -0
  95. package/dist/presets.d.ts.map +1 -1
  96. package/dist/presets.js +4 -0
  97. package/dist/presets.js.map +1 -1
  98. package/dist/rules-config.d.ts +1 -0
  99. package/dist/rules-config.d.ts.map +1 -1
  100. package/dist/rules-config.js.map +1 -1
  101. package/dist/structure.js +1 -1
  102. package/dist/structure.js.map +1 -1
  103. package/dist/tex-paper.d.ts +17 -3
  104. package/dist/tex-paper.d.ts.map +1 -1
  105. package/dist/tex-paper.js +36 -38
  106. package/dist/tex-paper.js.map +1 -1
  107. package/dist/tex-requirements.d.ts +4 -0
  108. package/dist/tex-requirements.d.ts.map +1 -1
  109. package/dist/tex-requirements.js +3 -0
  110. package/dist/tex-requirements.js.map +1 -1
  111. package/dist/tex-venue-rules.d.ts +7 -1
  112. package/dist/tex-venue-rules.d.ts.map +1 -1
  113. package/dist/tex-venue-rules.js +40 -0
  114. package/dist/tex-venue-rules.js.map +1 -1
  115. package/dist/types.d.ts +2 -0
  116. package/dist/types.d.ts.map +1 -1
  117. package/dist/venue-rules.d.ts +38 -8
  118. package/dist/venue-rules.d.ts.map +1 -1
  119. package/dist/venue-rules.js +179 -41
  120. package/dist/venue-rules.js.map +1 -1
  121. package/docs/configuration.md +23 -20
  122. package/docs/e2e.md +32 -11
  123. package/docs/install.md +8 -8
  124. package/docs/prior-art/paper-folder-scaffolding.md +4 -7
  125. package/docs/rules/anonymity/identity.md +111 -0
  126. package/docs/rules/format/layout-override.md +61 -0
  127. package/docs/rules/format/page-limit.md +108 -0
  128. package/docs/rules/pdf/body-size.md +87 -0
  129. package/docs/rules.md +55 -46
  130. package/eslint-rules/latex-language.ts +10 -4
  131. package/eslint-rules/paper-context.test.ts +47 -0
  132. package/eslint-rules/paper-context.ts +57 -0
  133. package/eslint-rules/paper-typography.ts +67 -23
  134. package/eslint-rules/pdf-last-page-balance.harness.mjs +6 -1
  135. package/eslint-rules/pdf-last-page-balance.ts +2 -2
  136. package/eslint-rules/tex-build.ts +8 -1
  137. package/fixtures/build-e2e/aidc/expect.json +1 -1
  138. package/fixtures/build-e2e/aidc/paperlint.json +1 -1
  139. package/fixtures/build-e2e/anon-ack/PIPELINE-STATUS.md +3 -0
  140. package/fixtures/build-e2e/anon-ack/expect.json +19 -0
  141. package/fixtures/build-e2e/anon-ack/paper.tex +31 -0
  142. package/fixtures/build-e2e/anon-ack/paperlint.json +1 -0
  143. package/fixtures/build-e2e/anon-author/PIPELINE-STATUS.md +3 -0
  144. package/fixtures/build-e2e/anon-author/expect.json +19 -0
  145. package/fixtures/build-e2e/anon-author/paper.tex +29 -0
  146. package/fixtures/build-e2e/anon-author/paperlint.json +1 -0
  147. package/fixtures/build-e2e/anon-bib/PIPELINE-STATUS.md +3 -0
  148. package/fixtures/build-e2e/anon-bib/expect.json +20 -0
  149. package/fixtures/build-e2e/anon-bib/paper.tex +35 -0
  150. package/fixtures/build-e2e/anon-bib/paperlint.json +1 -0
  151. package/fixtures/build-e2e/anon-href/PIPELINE-STATUS.md +3 -0
  152. package/fixtures/build-e2e/anon-href/expect.json +19 -0
  153. package/fixtures/build-e2e/anon-href/paper.tex +30 -0
  154. package/fixtures/build-e2e/anon-href/paperlint.json +1 -0
  155. package/fixtures/build-e2e/anon-macro/PIPELINE-STATUS.md +3 -0
  156. package/fixtures/build-e2e/anon-macro/expect.json +19 -0
  157. package/fixtures/build-e2e/anon-macro/paper.tex +30 -0
  158. package/fixtures/build-e2e/anon-macro/paperlint.json +1 -0
  159. package/fixtures/build-e2e/anon-pdfauthor/PIPELINE-STATUS.md +3 -0
  160. package/fixtures/build-e2e/anon-pdfauthor/expect.json +19 -0
  161. package/fixtures/build-e2e/anon-pdfauthor/paper.tex +31 -0
  162. package/fixtures/build-e2e/anon-pdfauthor/paperlint.json +1 -0
  163. package/fixtures/build-e2e/unbalanced/expect.json +2 -2
  164. package/fixtures/real-markdown-paper/baseline.json +1 -2
  165. package/lib/paper-config.ts +3 -1
  166. package/package.json +1 -1
  167. package/presets/acm-sigconf.jsonc +3 -1
  168. package/presets/aidc.jsonc +16 -17
  169. package/presets/ieee-conference.jsonc +8 -0
  170. package/presets/venue-profile.schema.json +9 -0
  171. package/scripts/check.ts +2 -2
  172. package/scripts/eslint-report-guard.mjs +5 -2
  173. package/scripts/harness-api.frozen.json +0 -1
  174. package/skills/paper-pipeline/references/pipeline-status-template.md +2 -16
  175. package/skills/render-paper/extract-pdf-facts.harness.ts +3 -3
  176. package/src/adapters/latex/includes.ts +10 -2
  177. package/src/adapters/latex/index.test.ts +8 -0
  178. package/src/adapters/latex/index.ts +3 -0
  179. package/src/adapters/latex/layout.ts +106 -0
  180. package/src/adapters/latex/nodes.ts +11 -3
  181. package/src/adapters/latex/parse.ts +5 -1
  182. package/src/build.harness.mjs +6 -4
  183. package/src/build.test.ts +23 -1
  184. package/src/build.ts +7 -3
  185. package/src/cli.harness.mjs +22 -54
  186. package/src/cli.test.ts +15 -18
  187. package/src/cli.ts +114 -46
  188. package/src/domain/anonymity.test.ts +163 -0
  189. package/src/domain/anonymity.ts +129 -0
  190. package/src/domain/body-pages.test.ts +300 -0
  191. package/src/domain/body-pages.ts +223 -0
  192. package/src/domain/paper-source.ts +2 -2
  193. package/src/domain/tex-document.ts +10 -0
  194. package/src/facts-file.test.ts +100 -4
  195. package/src/facts-file.ts +91 -9
  196. package/src/init.test.ts +33 -13
  197. package/src/init.ts +25 -48
  198. package/src/linted-set.test.ts +11 -1
  199. package/src/new-paper.harness.mjs +1 -5
  200. package/src/new-paper.test.ts +7 -1
  201. package/src/package-dirs.ts +9 -0
  202. package/src/paper-files.ts +2 -2
  203. package/src/paper-includes.test.ts +161 -0
  204. package/src/paper-includes.ts +77 -0
  205. package/src/paper-settings.test.ts +63 -6
  206. package/src/paper-settings.ts +53 -5
  207. package/src/pdf-content.test.ts +71 -0
  208. package/src/pdf-content.ts +125 -0
  209. package/src/pdf-facts.test.ts +110 -2
  210. package/src/pdf-facts.ts +106 -8
  211. package/src/ports/latex.ts +3 -0
  212. package/src/presets.ts +8 -8
  213. package/src/rule-cases.test.ts +29 -33
  214. package/src/rule-docs.test.ts +0 -3
  215. package/src/rules-config.ts +1 -0
  216. package/src/structure.harness.ts +1 -3
  217. package/src/structure.ts +1 -1
  218. package/src/tex-paper.test.ts +78 -61
  219. package/src/tex-paper.ts +57 -48
  220. package/src/tex-requirements.ts +9 -0
  221. package/src/tex-venue-rules.test.ts +111 -6
  222. package/src/tex-venue-rules.ts +57 -2
  223. package/src/types.ts +2 -0
  224. package/src/venue-rules.test.ts +351 -39
  225. package/src/venue-rules.ts +247 -47
  226. package/templates/paper/PIPELINE-STATUS.md +0 -4
  227. package/dist/action-ref.d.ts +0 -12
  228. package/dist/action-ref.d.ts.map +0 -1
  229. package/dist/action-ref.js +0 -16
  230. package/dist/action-ref.js.map +0 -1
  231. package/dist/eslint-rules/paper-research-question.d.ts +0 -31
  232. package/dist/eslint-rules/paper-research-question.d.ts.map +0 -1
  233. package/dist/eslint-rules/paper-research-question.js +0 -188
  234. package/dist/eslint-rules/paper-research-question.js.map +0 -1
  235. package/docs/rules/tex/missing-input.md +0 -58
  236. package/eslint-rules/paper-research-question.harness.mjs +0 -212
  237. package/eslint-rules/paper-research-question.ts +0 -199
  238. package/fixtures/paper-research-question/comment-only/PIPELINE-STATUS.md +0 -9
  239. package/fixtures/paper-research-question/comment-only/paper.tex +0 -7
  240. package/fixtures/paper-research-question/declared-not-in-paper/PIPELINE-STATUS.md +0 -10
  241. package/fixtures/paper-research-question/declared-not-in-paper/paper.tex +0 -6
  242. package/fixtures/paper-research-question/draft/PIPELINE-STATUS.md +0 -6
  243. package/fixtures/paper-research-question/draft/paper.tex +0 -2
  244. package/fixtures/paper-research-question/markdown-no-rq/PIPELINE-STATUS.md +0 -9
  245. package/fixtures/paper-research-question/markdown-no-rq/paper.md +0 -4
  246. package/fixtures/paper-research-question/shipped-no-rq/PIPELINE-STATUS.md +0 -12
  247. package/fixtures/paper-research-question/shipped-no-rq/paper.tex +0 -3
  248. package/fixtures/paper-research-question/shipped-with-rq/PIPELINE-STATUS.md +0 -10
  249. package/fixtures/paper-research-question/shipped-with-rq/paper.tex +0 -2
  250. package/src/action-ref.test.ts +0 -26
  251. package/src/action-ref.ts +0 -15
package/src/cli.ts CHANGED
@@ -9,8 +9,8 @@
9
9
  * but that is INTERNAL machinery, and you no longer need to know it in order to run them.
10
10
  *
11
11
  * Two entry points into the tool, and both are whole now:
12
- * npx paperlint lint ← here
13
- * uses: zernie/paperlint@<sha> ← action.yml
12
+ * npx paperlint lint ← here
13
+ * uses: ./node_modules/paperlint ← action.yml, after `npm ci`
14
14
  *
15
15
  * ⚠️ THE BOUNDARY THIS UTILITY HAS NO RIGHT TO ERASE: the consumer's data stays with the consumer.
16
16
  * Where the papers are and the project's own rule blocks — that is about ONE corpus, so it lives in
@@ -42,19 +42,32 @@ import {
42
42
  asEslintResults,
43
43
  } from "./structure.ts";
44
44
  import { buildPapers, papersIn, anyFailed, remedyFor, MAIN } from "./build.ts";
45
+ import { includeBlocks, paperBodies, unreadLines } from "./paper-includes.ts";
45
46
  import { prepareEngine } from "./build-engine.ts";
46
47
  import { cacheRoot, cachedTree, runToolchain } from "./toolchain.ts";
47
48
  import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
48
49
  import { curlDownload } from "./adapters/curl/index.ts";
49
50
  import { latexReader } from "./adapters/latex/index.ts";
50
51
  import { hostDirs, nodeAdapters, nodeFiles } from "./adapters/node/index.ts";
51
- import { VENUE_RULE_LEVELS, venueRules } from "./venue-rules.ts";
52
- 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";
53
66
  import { claimProvenanceRule } from "./claim-provenance.ts";
54
67
  import { registerRule } from "./register.ts";
55
- import { missingInputRule } from "./tex-paper.ts";
56
68
  import {
57
69
  paperRules,
70
+ identityField,
58
71
  stringFields,
59
72
  type PaperSettings,
60
73
  } from "./paper-settings.ts";
@@ -125,7 +138,6 @@ export { init };
125
138
  export { nextSteps } from "./init.ts";
126
139
 
127
140
  import paperStages from "#eslint-rules/paper-stages";
128
- import researchQuestion from "#eslint-rules/paper-research-question";
129
141
  import typography from "#eslint-rules/paper-typography";
130
142
  import texBuild from "#eslint-rules/tex-build";
131
143
  import bibReachable from "#eslint-rules/bib-reachable-entry";
@@ -219,8 +231,9 @@ settings — paperlint.json, at two levels, one schema. Both are optional.
219
231
  blocks (files, ignores, rules) with globs relative to that file. Order, later wins: paperlint's
220
232
  own, the venue preset's, the root file's, the paper's. Optional rules (off unless turned on):
221
233
  pdf/last-page-balance. The venue rules (pdf/fresh, pdf/profile, pdf/fonts, pdf/geometry,
222
- pdf/limits, pdf/body-size, pdf/measured) are on for every paper with a venue preset; set one to
223
- "off" to skip it. An unknown key, in either file, is an error.
234
+ pdf/body-size, pdf/measured, format/page-limit, format/layout-override, anonymity/identity) are
235
+ on for every paper with a venue preset; set one to "off" to skip it. An unknown key, in either
236
+ file, is an error.
224
237
  `;
225
238
 
226
239
  /** The config the user would otherwise write by hand. The data comes from `opts`, the mechanism is here. */
@@ -228,7 +241,7 @@ export function buildConfig(
228
241
  opts: PaperlintConfig = {},
229
242
  texLanguage: unknown,
230
243
  ): ConfigBlock[] {
231
- const paperRules = { ...researchQuestion.rules, ...typography.rules };
244
+ const paperRules = { ...typography.rules };
232
245
  // The reference rules judge `_build/references.json`, and only on `paper.tex`.
233
246
  const texPaperRules = {
234
247
  ...paperRules,
@@ -236,7 +249,6 @@ export function buildConfig(
236
249
  };
237
250
  // Each typography rule reports every occurrence where it is, and fixes it (`--fix`).
238
251
  const prose = {
239
- "paper/research-question": "warn",
240
252
  "paper/section-word": "warn",
241
253
  "paper/leading-zero": "warn",
242
254
  };
@@ -264,6 +276,9 @@ export function buildConfig(
264
276
  ...venueRules({ files: nodeFiles, venuesDir: presetsDir() }),
265
277
  },
266
278
  },
279
+ anonymity: {
280
+ rules: anonymityRules({ files: nodeFiles, venuesDir: presetsDir() }),
281
+ },
267
282
  },
268
283
  },
269
284
  {
@@ -319,10 +334,6 @@ export function buildConfig(
319
334
  files: nodeFiles,
320
335
  latex: latexReader,
321
336
  }),
322
- "missing-input": missingInputRule({
323
- files: nodeFiles,
324
- latex: latexReader,
325
- }),
326
337
  register: registerRule({
327
338
  files: nodeFiles,
328
339
  latex: latexReader,
@@ -331,6 +342,16 @@ export function buildConfig(
331
342
  },
332
343
  paper: { rules: texPaperRules },
333
344
  bib: bibReachable,
345
+ format: {
346
+ rules: {
347
+ ...formatRules({
348
+ files: nodeFiles,
349
+ venuesDir: presetsDir(),
350
+ latex: latexReader,
351
+ }),
352
+ ...pageLimitRules({ files: nodeFiles, venuesDir: presetsDir() }),
353
+ },
354
+ },
334
355
  },
335
356
  language: "tex/latex",
336
357
  rules: {
@@ -341,26 +362,65 @@ export function buildConfig(
341
362
  "tex/future-promise": "warn",
342
363
  // A number with no owner: a prose rule, on beside the others above.
343
364
  "tex/claim-provenance": "warn",
344
- "tex/missing-input": "warn",
345
365
  // Sentence-initial conjunctions over the whole body: register, not vocabulary.
346
366
  "tex/register": "warn",
347
367
  "tex/acm-frontmatter-override": "error",
348
368
  // Silent for a paper whose paperlint.json names no venue (src/venue-rules.ts).
349
369
  ...VENUE_RULE_LEVELS,
370
+ ...ANONYMITY_RULE_LEVELS,
350
371
  ...TEX_VENUE_RULE_LEVELS,
372
+ ...FORMAT_RULE_LEVELS,
373
+ ...PAGE_LIMIT_RULE_LEVELS,
351
374
  },
352
375
  });
376
+ // THE FILES A paper.tex INCLUDES FROM ITS BODY, each read on its own and reported at its own path
377
+ // (#144). The block claims every `.tex` but `paper.tex` — yet it is left out of the scope below, so
378
+ // it reaches only the files `paperlint lint` un-ignores by name for each paper (src/paper-includes.ts):
379
+ // a frozen version or a macro file is never enumerated. The rules are the ones that read a file as
380
+ // a fragment (no `document` environment: all of it is body); the whole-paper rules stay on
381
+ // `paper.tex`, which reads its includes itself (src/tex-paper.ts).
382
+ const fragments: readonly ConfigBlock[] = texLanguage
383
+ ? [
384
+ {
385
+ files: ["**/*.tex"],
386
+ ignores: ["**/paper.tex"],
387
+ plugins: {
388
+ tex: {
389
+ languages: { latex: texLanguage },
390
+ rules: { "future-promise": texBuild["future-promise"] },
391
+ },
392
+ paper: {
393
+ rules: {
394
+ "section-word": typography.rules["section-word"],
395
+ "leading-zero": typography.rules["leading-zero"],
396
+ "figure-ref-style": typography.rules["figure-ref-style"],
397
+ },
398
+ },
399
+ },
400
+ language: "tex/latex",
401
+ rules: {
402
+ "paper/section-word": "warn",
403
+ "paper/leading-zero": "warn",
404
+ "paper/figure-ref-style": "warn",
405
+ "tex/future-promise": "warn",
406
+ },
407
+ },
408
+ ]
409
+ : [];
353
410
  // 🔴 ONLY THE FILES THESE BLOCKS CLAIM ARE LINTED (src/paper-files.ts). Without this block ESLint's
354
411
  // built-in defaults lint every .js/.mjs/.cjs under the papers directory — a paper's vendored
355
412
  // `repro/` code failed the run with 74 parse errors and not one finding on a paper file. It goes
356
413
  // FIRST: the `.template/` ignore below must come after its directory un-ignore to win.
357
- const owners = ruleOwners(cfg);
414
+ const owners = ruleOwners([...cfg, ...fragments]);
358
415
  cfg.unshift(scopeToOwned(ownedPatterns(cfg)));
359
416
  // The consumer's own blocks, LAST, so a later block wins — ESLint's rule. Parsed by
360
417
  // `readConfig`; each carries the settings file's directory as its `basePath`. Each is split so a
361
418
  // rule reaches only the files its plugin is registered for: `paper` is a different plugin beside
362
419
  // PIPELINE-STATUS.md than beside paper.tex, and ESLint throws on a rule its plugin lacks.
363
- cfg.push(...(opts.rules ?? []).flatMap((b) => narrowToOwners(b, owners)));
420
+ cfg.push(
421
+ ...fragments,
422
+ ...(opts.rules ?? []).flatMap((b) => narrowToOwners(b, owners)),
423
+ );
364
424
  return cfg;
365
425
  }
366
426
 
@@ -501,6 +561,17 @@ export async function silentOptionalRules(
501
561
  return [...turnedOn].filter((id) => !reached.has(id));
502
562
  }
503
563
 
564
+ /**
565
+ * The paper directories `paths` reach: each directory and the papers under it. A FILE named on the
566
+ * command line belongs to the paper it sits in.
567
+ */
568
+ function paperDirs(paths: readonly string[]): readonly string[] {
569
+ const dirs = paths.map((p) =>
570
+ existsSync(p) && statSync(p).isFile() ? dirname(p) : p,
571
+ );
572
+ return [...new Set(dirs.flatMap((p) => [p, ...papersIn(p)]))];
573
+ }
574
+
504
575
  /**
505
576
  * Every linted paper's rules, as ESLint blocks scoped to that paper, in two groups: what its venue
506
577
  * preset turns on, and what its own `paperlint.json` says. The caller puts the root's blocks between
@@ -510,11 +581,7 @@ export async function silentOptionalRules(
510
581
  export function paperRuleBlocks(
511
582
  paths: readonly string[],
512
583
  ): Parsed<{ preset: RuleBlock[]; own: RuleBlock[] }> {
513
- // A FILE named on the command line belongs to the paper it sits in: that paper's settings apply.
514
- const dirs = paths.map((p) =>
515
- existsSync(p) && statSync(p).isFile() ? dirname(p) : p,
516
- );
517
- const papers = [...new Set(dirs.flatMap((p) => [p, ...papersIn(p)]))];
584
+ const papers = paperDirs(paths);
518
585
  const out: { preset: RuleBlock[]; own: RuleBlock[] } = {
519
586
  preset: [],
520
587
  own: [],
@@ -630,6 +697,8 @@ export function parseSettings(
630
697
  };
631
698
  const defaults = stringFields(raw);
632
699
  if (!defaults.ok) return { ok: false, error: `${where}: ${defaults.error}` };
700
+ const identity = identityField(raw);
701
+ if (!identity.ok) return { ok: false, error: `${where}: ${identity.error}` };
633
702
  const structure = Structure.safeParse(raw["structure"]);
634
703
  if (!structure.success)
635
704
  return {
@@ -765,28 +834,6 @@ const VALUE_FLAGS: ReadonlySet<string> = new Set([
765
834
  "--max-warnings",
766
835
  ]);
767
836
 
768
- /**
769
- * This package's own version, from the `package.json` beside `src/` and `dist/` alike. `init` pins
770
- * the CI action to its release tag; an unreadable manifest yields `undefined`, and init then keeps
771
- * the placeholder instead of guessing.
772
- */
773
- export function ownVersion(
774
- readManifest: () => string = () =>
775
- readFileSync(new URL("../package.json", import.meta.url), "utf8"),
776
- ): string | undefined {
777
- try {
778
- const manifest: unknown = JSON.parse(readManifest());
779
- return typeof manifest === "object" &&
780
- manifest !== null &&
781
- "version" in manifest &&
782
- typeof manifest.version === "string"
783
- ? manifest.version
784
- : undefined;
785
- } catch {
786
- return undefined;
787
- }
788
- }
789
-
790
837
  /**
791
838
  * Reading the config, ONE reader for all commands. Pulled out of `run()` the moment a second
792
839
  * command needed the same config (`build`): two copies of this block would have drifted apart on
@@ -1421,7 +1468,6 @@ async function runInit(
1421
1468
  log,
1422
1469
  err,
1423
1470
  cwd,
1424
- version: ownVersion(),
1425
1471
  yes: a.yes,
1426
1472
  hooks: !a.noHooks,
1427
1473
  paper: a.paper,
@@ -1557,10 +1603,32 @@ export async function run(
1557
1603
  texLanguage = null;
1558
1604
  }
1559
1605
 
1606
+ // Each paper's body files, resolved the way the build resolves them — only when the LaTeX language
1607
+ // loaded, since only its rules read them. The project's papers too: `paperlint lint .` reaches
1608
+ // papers two levels down, which a directory's immediate papers do not.
1609
+ const bodies = texLanguage
1610
+ ? paperBodies(paperDirs([...paths, ...papersRoots(cfg)]), {
1611
+ files: nodeFiles,
1612
+ latex: latexReader,
1613
+ })
1614
+ : [];
1615
+ // Named only for the papers this run lints: a paper elsewhere in the project is not this run's.
1616
+ const inScope = (dir: string) =>
1617
+ paths.some((p) => dir === p || dir.startsWith(`${p}${sep}`));
1618
+ unreadLines(
1619
+ bodies.filter((b) => inScope(b.dir)),
1620
+ (p) => shown(cwd, p),
1621
+ ).forEach((l) => {
1622
+ err(l);
1623
+ });
1624
+
1560
1625
  const eslint = new ESLint({
1561
1626
  cwd: lintRoot(root, paths),
1562
1627
  overrideConfigFile: true,
1563
- overrideConfig: eslintConfig(buildConfig(withPapers, texLanguage)),
1628
+ overrideConfig: eslintConfig([
1629
+ ...buildConfig(withPapers, texLanguage),
1630
+ ...includeBlocks(bodies),
1631
+ ]),
1564
1632
  fix: a.fix,
1565
1633
  });
1566
1634
 
@@ -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
+ }