paperlint 2.0.1 → 3.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 (226) hide show
  1. package/CLAUDE.md +6 -1
  2. package/CONTRIBUTING.md +21 -0
  3. package/README.md +231 -180
  4. package/action.yml +4 -6
  5. package/dist/adapters/references/index.d.ts +13 -0
  6. package/dist/adapters/references/index.d.ts.map +1 -0
  7. package/dist/adapters/references/index.js +55 -0
  8. package/dist/adapters/references/index.js.map +1 -0
  9. package/dist/adapters/references/reach.io.d.ts +3 -0
  10. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  11. package/dist/adapters/references/reach.io.js +14 -0
  12. package/dist/adapters/references/reach.io.js.map +1 -0
  13. package/dist/build-engine.d.ts.map +1 -1
  14. package/dist/build-engine.js +5 -2
  15. package/dist/build-engine.js.map +1 -1
  16. package/dist/build.d.ts +16 -1
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/build.js +40 -5
  19. package/dist/build.js.map +1 -1
  20. package/dist/cli.d.ts +37 -20
  21. package/dist/cli.d.ts.map +1 -1
  22. package/dist/cli.js +377 -251
  23. package/dist/cli.js.map +1 -1
  24. package/dist/doctor.d.ts.map +1 -1
  25. package/dist/doctor.js +37 -52
  26. package/dist/doctor.js.map +1 -1
  27. package/dist/facts-file.d.ts +33 -9
  28. package/dist/facts-file.d.ts.map +1 -1
  29. package/dist/facts-file.js +117 -17
  30. package/dist/facts-file.js.map +1 -1
  31. package/dist/hooks-settings.d.ts +0 -21
  32. package/dist/hooks-settings.d.ts.map +1 -1
  33. package/dist/hooks-settings.js +17 -135
  34. package/dist/hooks-settings.js.map +1 -1
  35. package/dist/init.d.ts +40 -19
  36. package/dist/init.d.ts.map +1 -1
  37. package/dist/init.js +144 -109
  38. package/dist/init.js.map +1 -1
  39. package/dist/link-skills.d.ts +1 -1
  40. package/dist/link-skills.d.ts.map +1 -1
  41. package/dist/link-skills.js +11 -29
  42. package/dist/link-skills.js.map +1 -1
  43. package/dist/new-paper.d.ts +2 -2
  44. package/dist/new-paper.d.ts.map +1 -1
  45. package/dist/new-paper.js +6 -2
  46. package/dist/new-paper.js.map +1 -1
  47. package/dist/paper-files.d.ts +63 -0
  48. package/dist/paper-files.d.ts.map +1 -0
  49. package/dist/paper-files.js +69 -0
  50. package/dist/paper-files.js.map +1 -0
  51. package/dist/paper-settings.d.ts +40 -0
  52. package/dist/paper-settings.d.ts.map +1 -0
  53. package/dist/paper-settings.js +144 -0
  54. package/dist/paper-settings.js.map +1 -0
  55. package/dist/ports/check-references.d.ts +31 -0
  56. package/dist/ports/check-references.d.ts.map +1 -0
  57. package/dist/ports/check-references.js +7 -0
  58. package/dist/ports/check-references.js.map +1 -0
  59. package/dist/presets.d.ts +84 -0
  60. package/dist/presets.d.ts.map +1 -0
  61. package/dist/presets.js +184 -0
  62. package/dist/presets.js.map +1 -0
  63. package/dist/reference-rules.d.ts +47 -0
  64. package/dist/reference-rules.d.ts.map +1 -0
  65. package/dist/reference-rules.js +156 -0
  66. package/dist/reference-rules.js.map +1 -0
  67. package/dist/references.d.ts +36 -0
  68. package/dist/references.d.ts.map +1 -0
  69. package/dist/references.js +91 -0
  70. package/dist/references.js.map +1 -0
  71. package/dist/rules-config.d.ts +11 -2
  72. package/dist/rules-config.d.ts.map +1 -1
  73. package/dist/rules-config.js +25 -11
  74. package/dist/rules-config.js.map +1 -1
  75. package/dist/structure.d.ts.map +1 -1
  76. package/dist/structure.js +3 -2
  77. package/dist/structure.js.map +1 -1
  78. package/dist/tex-requirements.d.ts +63 -7
  79. package/dist/tex-requirements.d.ts.map +1 -1
  80. package/dist/tex-requirements.js +91 -24
  81. package/dist/tex-requirements.js.map +1 -1
  82. package/dist/types.d.ts +20 -18
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/venue-rules.d.ts +109 -0
  85. package/dist/venue-rules.d.ts.map +1 -0
  86. package/dist/venue-rules.js +389 -0
  87. package/dist/venue-rules.js.map +1 -0
  88. package/docs/configuration.md +152 -77
  89. package/docs/e2e.md +1 -1
  90. package/docs/install.md +25 -40
  91. package/docs/optional-rules.md +26 -24
  92. package/docs/rules.md +181 -15
  93. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  94. package/eslint-rules/latex-language.mjs +29 -1
  95. package/eslint-rules/paper-stages.harness.mjs +1 -117
  96. package/eslint-rules/paper-stages.mjs +0 -120
  97. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  98. package/eslint-rules/paper-typography.mjs +267 -192
  99. package/eslint-rules/paper-typography.test.mjs +297 -0
  100. package/eslint-rules/papers.harness.mjs +8 -12
  101. package/eslint-rules/papers.mjs +17 -27
  102. package/eslint-rules/pdf-last-page-balance.mjs +23 -5
  103. package/eslint-rules/review-frontmatter.mjs +118 -0
  104. package/eslint-rules/review-frontmatter.schema.json +36 -0
  105. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  106. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  107. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  108. package/eslint.config.mjs +0 -15
  109. package/fixtures/build-e2e/acmart/paperlint.json +1 -0
  110. package/fixtures/real-markdown-paper/baseline.json +6 -7
  111. package/hooks/hooks.harness.mjs +39 -98
  112. package/hooks/hooks.mutations.mjs +6 -6
  113. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  114. package/hooks/paper-edit-guard.hook.mjs +31 -42
  115. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  116. package/hooks/paper-status-gates.hook.mjs +21 -19
  117. package/hooks/paper-status-gates.sh +8 -13
  118. package/lib/paper-config.d.mts +15 -18
  119. package/lib/paper-config.harness.mjs +18 -35
  120. package/lib/paper-config.mjs +90 -81
  121. package/lib/paper-config.mutations.mjs +2 -2
  122. package/lib/skill-checks.mjs +1 -1
  123. package/lib/skill-trigger-cases.harness.mjs +9 -8
  124. package/lib/skill-trigger-cases.mjs +7 -15
  125. package/package.json +1 -1
  126. package/scripts/eslint-report-guard.mjs +1 -1
  127. package/scripts/harness-api.frozen.json +0 -3
  128. package/scripts/mutation-batteries.frozen.json +4 -13
  129. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  130. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  131. package/skills/cold-read-diff/SKILL.md +20 -1
  132. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  133. package/skills/find-venue/SKILL.md +5 -1
  134. package/skills/find-venue/SKILL.md.spec.ts +4 -0
  135. package/skills/grade-paper-writing/SKILL.md +20 -1
  136. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  137. package/skills/paper-pipeline/PIPELINE-MAP.md +14 -4
  138. package/skills/paper-pipeline/SKILL.md +4 -3
  139. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -2
  140. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  141. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  142. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  144. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  145. package/skills/plan-paper-timeline/SKILL.md +2 -2
  146. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  147. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  148. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  149. package/skills/render-paper/check-render.sh +2 -3
  150. package/skills/render-paper/extract-pdf-facts.harness.mjs +5 -5
  151. package/skills/render-paper/extract-pdf-facts.mjs +4 -4
  152. package/skills/render-paper/render-paper.harness.mjs +2 -2
  153. package/skills/study-accepted-papers/SKILL.md +2 -1
  154. package/skills/study-accepted-papers/SKILL.md.spec.ts +1 -0
  155. package/skills/submit-paper/SKILL.md +9 -1
  156. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  157. package/skills/submit-paper/references/venues/acm-sigconf.jsonc +86 -0
  158. package/skills/submit-paper/references/venues/agenticdev.jsonc +34 -102
  159. package/skills/submit-paper/references/venues/aisec.jsonc +21 -82
  160. package/skills/submit-paper/references/venues/realm.jsonc +45 -43
  161. package/skills/submit-paper/references/venues/tex-base.jsonc +1 -1
  162. package/skills/submit-paper/references/venues/venue-profile.schema.json +119 -29
  163. package/skills/verify-citations/SKILL.md +10 -1
  164. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  165. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  166. package/src/adapters/references/index.ts +102 -0
  167. package/src/adapters/references/reach.io.ts +12 -0
  168. package/src/build-engine.ts +10 -2
  169. package/src/build.harness.mjs +31 -22
  170. package/src/build.ts +65 -8
  171. package/src/cli.harness.mjs +151 -294
  172. package/src/cli.mutations.mjs +35 -44
  173. package/src/cli.ts +457 -288
  174. package/src/doctor.harness.mjs +41 -84
  175. package/src/doctor.mutations.mjs +9 -9
  176. package/src/doctor.ts +41 -61
  177. package/src/facts-file.test.ts +47 -7
  178. package/src/facts-file.ts +145 -24
  179. package/src/hooks-settings.harness.mjs +7 -92
  180. package/src/hooks-settings.mutations.mjs +2 -17
  181. package/src/hooks-settings.ts +18 -162
  182. package/src/init.test.ts +206 -0
  183. package/src/init.ts +193 -159
  184. package/src/link-skills.harness.mjs +1 -38
  185. package/src/link-skills.mutations.mjs +9 -5
  186. package/src/link-skills.ts +10 -34
  187. package/src/lint-fix.test.ts +120 -0
  188. package/src/new-paper.harness.mjs +9 -6
  189. package/src/new-paper.test.ts +133 -0
  190. package/src/new-paper.ts +7 -2
  191. package/src/paper-files.test.ts +290 -0
  192. package/src/paper-files.ts +144 -0
  193. package/src/paper-settings-commands.test.ts +274 -0
  194. package/src/paper-settings.test.ts +249 -0
  195. package/src/paper-settings.ts +208 -0
  196. package/src/ports/check-references.ts +28 -0
  197. package/src/presets.test.ts +236 -0
  198. package/src/presets.ts +297 -0
  199. package/src/reference-rules.ts +229 -0
  200. package/src/references.test.ts +209 -0
  201. package/src/references.ts +138 -0
  202. package/src/rules-config.harness.mjs +24 -8
  203. package/src/rules-config.ts +30 -13
  204. package/src/structure.harness.mjs +6 -4
  205. package/src/structure.ts +3 -2
  206. package/src/tex-requirements.harness.mjs +11 -17
  207. package/src/tex-requirements.ts +179 -29
  208. package/src/types.ts +30 -18
  209. package/src/venue-rules.test.ts +554 -0
  210. package/src/venue-rules.ts +571 -0
  211. package/templates/paper/paperlint.json +4 -0
  212. package/eslint-rules/doc-fields.harness.mjs +0 -336
  213. package/eslint-rules/doc-fields.mjs +0 -186
  214. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  215. package/eslint-rules/paper-typography.harness.mjs +0 -291
  216. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  217. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  218. package/eslint-rules/review-findings-cause.mjs +0 -135
  219. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  220. package/fixtures/build-e2e/acmart/venue.json +0 -1
  221. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  222. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  223. package/fixtures/review-findings-cause/clean.md +0 -17
  224. package/fixtures/review-findings-cause/defect.md +0 -14
  225. package/fixtures/review-findings-cause/old-debt.md +0 -14
  226. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
package/src/cli.ts CHANGED
@@ -13,10 +13,8 @@
13
13
  * uses: zernie/paperlint@<sha> ← action.yml
14
14
  *
15
15
  * ⚠️ THE BOUNDARY THIS UTILITY HAS NO RIGHT TO ERASE: the consumer's data stays with the consumer.
16
- * The typography debt, the marker of the author-list check run, the field dictionary — all of that
17
- * is about ONE corpus, and wiring it into the package would repeat the defect that put the path
18
- * `.claude/skills/verify-citations/...` into a rule's message. So they live in the consumer's
19
- * `package.json`, under the `paperlint` key.
16
+ * Where the papers are and the project's own rule blocks — that is about ONE corpus, so it lives in
17
+ * the consumer's own `paperlint.json`, at the project root and in each paper.
20
18
  *
21
19
  * 🔴 WHY THE COMMAND IS CALLED `lint` AND NOT `check`. It does exactly what everyone else calls by
22
20
  * that word: reads files, changes nothing, prints findings, exits non-zero. `check` is taken in the
@@ -26,14 +24,17 @@
26
24
  * replaced it: silently breaking someone else's workflow is worse than asking them to fix a line.
27
25
  */
28
26
  import { ESLint, type Linter } from "eslint";
29
- import { readFileSync, existsSync } from "node:fs";
27
+ import { readFileSync, existsSync, statSync } from "node:fs";
30
28
  import { createRequire } from "node:module";
31
29
  import { spawnSync } from "node:child_process";
32
30
  import { fileURLToPath } from "node:url";
33
31
  import { join, dirname, resolve, relative, basename, sep } from "node:path";
34
32
  import markdown from "@eslint/markdown";
35
33
  // Types come from consumer.d.mts beside it, the same arrangement as lib/paper-config.d.mts.
36
- import { isMain } from "../skills/paper-pipeline/scripts/consumer.mjs";
34
+ import {
35
+ isMain,
36
+ packageVenuesDir,
37
+ } from "../skills/paper-pipeline/scripts/consumer.mjs";
37
38
  export { isMain };
38
39
  import type { Args, PaperlintConfig, ConfigRead } from "./types.ts";
39
40
  import {
@@ -41,22 +42,35 @@ import {
41
42
  formatStructure,
42
43
  asEslintResults,
43
44
  } from "./structure.ts";
44
- import {
45
- buildPapers,
46
- papersIn,
47
- anyFailed,
48
- remedyFor,
49
- readFacts,
50
- MAIN,
51
- } from "./build.ts";
45
+ import { buildPapers, papersIn, anyFailed, remedyFor, MAIN } from "./build.ts";
52
46
  import { prepareEngine } from "./build-engine.ts";
53
- import { runToolchain } from "./toolchain.ts";
47
+ import { cacheRoot, cachedTree, runToolchain } from "./toolchain.ts";
54
48
  import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
55
49
  import { curlDownload } from "./adapters/curl/index.ts";
56
- import { hostDirs, nodeAdapters } from "./adapters/node/index.ts";
50
+ import { hostDirs, nodeAdapters, nodeFiles } from "./adapters/node/index.ts";
51
+ import { VENUE_RULE_LEVELS, venueRules } from "./venue-rules.ts";
52
+ import {
53
+ paperRules,
54
+ stringFields,
55
+ type PaperSettings,
56
+ } from "./paper-settings.ts";
57
+ import { referenceRules, REFERENCE_RULE_LEVELS } from "./reference-rules.ts";
58
+ import { onlineReferences } from "./adapters/references/index.ts";
59
+ import {
60
+ narrowToOwners,
61
+ ownedPatterns,
62
+ ruleOwners,
63
+ scopeToOwned,
64
+ } from "./paper-files.ts";
65
+ import {
66
+ paperPreset,
67
+ paperPresetProblem,
68
+ type PaperPreset,
69
+ } from "./presets.ts";
57
70
  import type { ToolInstaller } from "./ports/tool-installer.ts";
58
71
  import {
59
72
  mergeRequirements,
73
+ declaredUnion,
60
74
  requirementsFor,
61
75
  NO_REQUIREMENTS,
62
76
  type TexRequirements,
@@ -74,19 +88,20 @@ import {
74
88
  // The one source for the consumer's config key lives in the .mjs half of the package (the ESLint
75
89
  // rules and the skill scripts import it too); its types are in lib/paper-config.d.mts.
76
90
  import {
77
- CONFIG_KEY,
78
- LEGACY_CONFIG_KEY,
79
- LEGACY_KEY_MESSAGE,
91
+ CONFIG_FILE,
92
+ DEFAULT_PAPERS_ROOT,
80
93
  PAPERS_DIR_FIELD,
81
94
  SETTINGS_KEYS,
82
- declaredSettings,
83
- renamedFieldMessage,
95
+ findProjectRoot,
84
96
  } from "../lib/paper-config.mjs";
85
97
  import {
86
98
  parseRuleBlocks,
99
+ parseRuleEntries,
87
100
  shippedRuleIds,
88
101
  unknownKeys,
89
102
  type Parsed,
103
+ type RuleBlock,
104
+ type RuleEntry,
90
105
  } from "./rules-config.ts";
91
106
  export { init };
92
107
  export { nextSteps } from "./init.ts";
@@ -100,9 +115,11 @@ import typography from "../eslint-rules/paper-typography.mjs";
100
115
  // @ts-expect-error — an ESLint rule in .mjs, it has no types
101
116
  import texBuild from "../eslint-rules/tex-build.mjs";
102
117
  // @ts-expect-error — an ESLint rule in .mjs, it has no types
103
- import docFields from "../eslint-rules/doc-fields.mjs";
118
+ import bibReachable from "../eslint-rules/bib-reachable-entry.mjs";
119
+ // @ts-expect-error — an ESLint rule in .mjs, it has no types
120
+ import reviewFrontmatter from "../eslint-rules/review-frontmatter.mjs";
104
121
  // @ts-expect-error — an ESLint rule in .mjs, it has no types
105
- import findingsCause from "../eslint-rules/review-findings-cause.mjs";
122
+ import siblingFrontmatter from "../eslint-rules/sibling-frontmatter.mjs";
106
123
  // @ts-expect-error — an ESLint rule in .mjs, it has no types
107
124
  import pdfRules from "../eslint-rules/pdf-last-page-balance.mjs";
108
125
 
@@ -121,8 +138,11 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
121
138
  paper directory is ignored (--dry-run: print the plan only).
122
139
  Compiles with paperlint's TeX Live, else one on PATH that has every
123
140
  package the venue declares; on a terminal it offers to install
124
- one, without a terminal it stops and names \`npx paperlint toolchain\`
125
- npx paperlint toolchain [--check] install TeX Live with every package the venue profiles declare
141
+ one, without a terminal it stops and names \`npx paperlint toolchain\`.
142
+ Then checks the references online (the cited works exist, titles
143
+ and authors match) into _build/references.json — never failing
144
+ the build: without network it records "not checked"
145
+ npx paperlint toolchain [--check] install TeX Live with every package the venue presets declare
126
146
  into ~/.cache/paperlint/texlive (PAPERLINT_TEXLIVE_DIR overrides); a second
127
147
  run does nothing. --check: report what is missing, change nothing
128
148
  npx paperlint doctor say what is actually wired — and what only LOOKS wired
@@ -137,36 +157,46 @@ init:
137
157
  --format tex|md the new paper's source format; default tex
138
158
 
139
159
  lint:
140
- npx paperlint lint [paths…] [--config <file.json>] [--json]
160
+ npx paperlint lint [paths…] [--fix] [--config <file.json>] [--json]
141
161
 
142
162
  <paths…> where your papers live, e.g. papers. Optional ONLY because the declaration
143
163
  names it — one of the two must name the scope. There is no default
144
164
  of ".": linting whatever happens to be in the checkout is how a green
145
165
  report over a scope nobody chose gets produced.
166
+ --fix write every fix the rules offer (section signs, leading zeros, figure
167
+ references), then report what is left
146
168
  --config <file> read the settings from this file instead of the discovered one
147
169
  --json machine-readable findings on stdout, nothing else on it
148
170
  --max-warnings <n> fail when warnings exceed n. Default -1: warnings never fail, because
149
171
  most findings here are advisory and a gate that fails on advice gets muted
150
172
 
151
- settings — the \`paperlint\` key of your package.json, found by walking up from the
152
- current directory, the way every other tool in the stack finds its config. \`--config\` names
153
- another file of the same shape. \`papersDir\` is required; the rest is optional:
154
-
155
- "paperlint": {
156
- "papersDir": "papers",
157
- "authorListCommand": "node scripts/bib-authors.mjs",
158
- "typographyDebt": { "papers/my-paper": { "sectionSign": 12 } },
159
- "docFields": { "read": { "values": ["full", "abstract", "none"] } },
160
- "reviewSince": "2026-08-23",
161
- "minFindings": 3,
162
- "causeMarker": "Cause:",
163
- "rules": [ { "files": ["papers/my-paper/**"],
164
- "rules": { "pdf/last-page-balance": "error" } } ]
165
- }
173
+ settings — paperlint.json, at two levels, one schema. Both are optional.
174
+
175
+ paperlint.json (the project root, beside package.json) — found by walking up from the
176
+ current directory. \`--config\` names another file of the same shape.
177
+
178
+ {
179
+ "papersDir": "papers",
180
+ "rules": [ { "files": ["papers/my-paper/**"],
181
+ "rules": { "pdf/last-page-balance": "error" } } ]
182
+ }
166
183
 
167
- "rules" takes ESLint flat-config blocks (files, ignores, rules), appended after paperlint's own, with
168
- files relative to the file holding the settings. Optional rules (off unless turned on there):
169
- pdf/last-page-balance. An unknown key, anywhere in the settings, is an error.
184
+ "papersDir" defaults to "papers". "extends", "kind" and "pdf" here are defaults for every
185
+ paper. "papersDir", "structure" and the skills' keys are allowed only here.
186
+
187
+ <papersDir>/<paper>/paperlint.json — one paper, merged over the root file:
188
+
189
+ { "extends": "paperlint:aisec", "kind": "research", "rules": { "pdf/last-page-balance": "error" } }
190
+
191
+ "extends" names a venue preset: paperlint:<name> (shipped: acm-sigconf, agenticdev, aisec, realm)
192
+ or ./path.jsonc, relative to the paperlint.json. npm presets are not supported yet.
193
+
194
+ "rules" is { "<rule>": "<severity>" } for every paper file in scope, or ESLint flat-config
195
+ blocks (files, ignores, rules) with globs relative to that file. Order, later wins: paperlint's
196
+ own, the venue preset's, the root file's, the paper's. Optional rules (off unless turned on):
197
+ pdf/last-page-balance. The venue rules (pdf/fresh, pdf/profile, pdf/fonts, pdf/geometry,
198
+ pdf/limits, pdf/body-size, pdf/measured) are on for every paper with a venue preset; set one to
199
+ "off" to skip it. An unknown key, in either file, is an error.
170
200
  `;
171
201
 
172
202
  /** The config the user would otherwise write by hand. The data comes from `opts`, the mechanism is here. */
@@ -175,7 +205,17 @@ export function buildConfig(
175
205
  texLanguage: unknown,
176
206
  ): unknown[] {
177
207
  const paperRules = { ...researchQuestion.rules, ...typography.rules };
178
- const typographyOpt = ["warn", { debt: opts.typographyDebt ?? {} }];
208
+ // The reference rules judge `_build/references.json`, and only on `paper.tex`.
209
+ const texPaperRules = {
210
+ ...paperRules,
211
+ ...referenceRules({ files: nodeFiles }),
212
+ };
213
+ // Each typography rule reports every occurrence where it is, and fixes it (`--fix`).
214
+ const prose = {
215
+ "paper/research-question": "warn",
216
+ "paper/section-word": "warn",
217
+ "paper/leading-zero": "warn",
218
+ };
179
219
  const md = {
180
220
  language: "markdown/gfm",
181
221
  languageOptions: { frontmatter: "yaml" },
@@ -188,11 +228,21 @@ export function buildConfig(
188
228
  // default (only `node_modules/` and `.git/`), so without this block `paperlint lint` would lint the
189
229
  // template as a paper — and a richer template with placeholder stages would fail the run.
190
230
  { ignores: ["**/.template/"] },
191
- // The `pdf` plugin is registered for EVERY file, and its rule is on for none. A consumer's
192
- // block (`rules`, appended below) turns it on for a glob that also matches markdown files;
193
- // with the plugin defined only beside `paper.tex`, ESLint would refuse those files with
194
- // "could not find plugin". The rule itself acts on `paper.tex` only.
195
- { plugins: { pdf: pdfRules } },
231
+ // The `pdf` plugin is registered for EVERY file. A consumer's block (`rules`, appended below)
232
+ // may name its rules for a glob that also matches markdown files; with the plugin defined only
233
+ // beside `paper.tex`, ESLint would refuse those files with "could not find plugin". Every rule
234
+ // in it acts on `paper.tex` only. `last-page-balance` is on for no file (optional); the venue
235
+ // rules are on for every `paper.tex`, in the block below.
236
+ {
237
+ plugins: {
238
+ pdf: {
239
+ rules: {
240
+ ...pdfRules.rules,
241
+ ...venueRules({ files: nodeFiles, venuesDir: packageVenuesDir() }),
242
+ },
243
+ },
244
+ },
245
+ },
196
246
  {
197
247
  files: ["**/PIPELINE-STATUS.md"],
198
248
  plugins: { markdown, paper: paperStages },
@@ -200,51 +250,30 @@ export function buildConfig(
200
250
  rules: {
201
251
  "paper/stages": "error",
202
252
  "paper/source": "error",
203
- "paper/author-list": [
204
- "warn",
205
- opts.authorListCommand ? { command: opts.authorListCommand } : {},
206
- ],
207
253
  },
208
254
  },
209
255
  {
210
256
  files: ["**/paper.md", "**/draft.md"],
211
257
  plugins: { markdown, paper: { rules: paperRules } },
212
258
  ...md,
213
- rules: {
214
- "paper/research-question": "warn",
215
- "paper/typography": typographyOpt,
216
- },
259
+ rules: prose,
217
260
  },
218
261
  {
219
262
  files: ["**/reviews/*.md"],
220
- plugins: {
221
- markdown,
222
- review: { rules: { ...findingsCause.rules } },
223
- doc: docFields,
224
- },
263
+ plugins: { markdown, review: reviewFrontmatter },
225
264
  ...md,
226
- rules: {
227
- /*
228
- * 🔴 THE DEFAULT IS ENGLISH SINCE 2026-09-17. It used to be the Russian word for "Cause:" —
229
- * a Russian word in a package whose interface is English. An `error`-level rule demanded
230
- * that a person put Cyrillic into their own file, and there was nothing to change the
231
- * marker with: `causeMarker` was not threaded through the CLI at all. The only way out was
232
- * to abandon the command and assemble the ESLint config by hand — that is, the defect
233
- * pushed you onto exactly the path the utility frees you from.
234
- * The Russian marker stays EXPRESSIBLE, but now as a value, not as the default.
235
- */
236
- "review/findings-cause": [
237
- "error",
238
- {
239
- minFindings: opts.minFindings ?? 3,
240
- ...(opts.causeMarker ? { causeMarker: opts.causeMarker } : {}),
241
- ...(opts.reviewSince ? { sinceCreated: opts.reviewSince } : {}),
242
- },
243
- ],
244
- ...(opts.docFields
245
- ? { "doc/fields": ["warn", { fields: opts.docFields }] }
246
- : {}),
247
- },
265
+ // A review's frontmatter is a record, validated by paperlint's JSON Schema
266
+ // (eslint-rules/review-frontmatter.mjs).
267
+ rules: { "review/frontmatter": "error" },
268
+ },
269
+ {
270
+ // A sibling card says how much of the competing paper was read (`read:`). The cards are
271
+ // written by this package's analyze-sibling-paper skill; its README is an index, not a card.
272
+ files: ["**/siblings/*.md"],
273
+ ignores: ["**/siblings/README.md"],
274
+ plugins: { markdown, sibling: siblingFrontmatter },
275
+ ...md,
276
+ rules: { "sibling/frontmatter": "warn" },
248
277
  },
249
278
  ];
250
279
 
@@ -255,19 +284,32 @@ export function buildConfig(
255
284
  files: ["**/paper.tex"],
256
285
  plugins: {
257
286
  tex: { languages: { latex: texLanguage }, rules: texBuild },
258
- paper: { rules: paperRules },
287
+ paper: { rules: texPaperRules },
288
+ bib: bibReachable,
259
289
  },
260
290
  language: "tex/latex",
261
291
  rules: {
262
- "paper/research-question": "warn",
263
- "paper/typography": typographyOpt,
292
+ ...prose,
293
+ ...REFERENCE_RULE_LEVELS,
294
+ "paper/figure-ref-style": "warn",
295
+ "bib/reachable-entry": "warn",
264
296
  "tex/future-promise": "warn",
265
297
  "tex/acm-frontmatter-override": "error",
298
+ // Silent for a paper whose paperlint.json names no venue (src/venue-rules.ts).
299
+ ...VENUE_RULE_LEVELS,
266
300
  },
267
301
  });
302
+ // 🔴 ONLY THE FILES THESE BLOCKS CLAIM ARE LINTED (src/paper-files.ts). Without this block ESLint's
303
+ // built-in defaults lint every .js/.mjs/.cjs under the papers directory — a paper's vendored
304
+ // `repro/` code failed the run with 74 parse errors and not one finding on a paper file. It goes
305
+ // FIRST: the `.template/` ignore below must come after its directory un-ignore to win.
306
+ const owners = ruleOwners(cfg);
307
+ cfg.unshift(scopeToOwned(ownedPatterns(cfg)));
268
308
  // The consumer's own blocks, LAST, so a later block wins — ESLint's rule. Parsed by
269
- // `readConfig`; each carries the settings file's directory as its `basePath`.
270
- cfg.push(...(opts.rules ?? []));
309
+ // `readConfig`; each carries the settings file's directory as its `basePath`. Each is split so a
310
+ // rule reaches only the files its plugin is registered for: `paper` is a different plugin beside
311
+ // PIPELINE-STATUS.md than beside paper.tex, and ESLint throws on a rule its plugin lacks.
312
+ cfg.push(...(opts.rules ?? []).flatMap((b) => narrowToOwners(b, owners)));
271
313
  return cfg;
272
314
  }
273
315
 
@@ -329,15 +371,111 @@ export async function silentOptionalRules(
329
371
  }
330
372
 
331
373
  /**
332
- * The settings after the boundary: an unknown key is refused by name, and `rules` becomes parsed
333
- * config blocks. Nothing after this sees the raw object.
374
+ * Every linted paper's rules, as ESLint blocks scoped to that paper, in two groups: what its venue
375
+ * preset turns on, and what its own `paperlint.json` says. The caller puts the root's blocks between
376
+ * them. A file that does not parse, or names a rule paperlint does not ship, stops the run with one
377
+ * line naming the file — the same strictness as the root's own `rules`.
378
+ */
379
+ export function paperRuleBlocks(
380
+ paths: readonly string[],
381
+ ): Parsed<{ preset: RuleBlock[]; own: RuleBlock[] }> {
382
+ // A FILE named on the command line belongs to the paper it sits in: that paper's settings apply.
383
+ const dirs = paths.map((p) =>
384
+ existsSync(p) && statSync(p).isFile() ? dirname(p) : p,
385
+ );
386
+ const papers = [...new Set(dirs.flatMap((p) => [p, ...papersIn(p)]))];
387
+ const out = { preset: [] as RuleBlock[], own: [] as RuleBlock[] };
388
+ for (const dir of papers) {
389
+ const p = paperPreset(dir, PRESET_DEPS);
390
+ if (p.kind === "settings-problem" && p.problem.kind === "broken")
391
+ return { ok: false, error: paperPresetProblem(dir, p) ?? dir };
392
+ const blocks = rulesOfPaper(dir, p);
393
+ if (!blocks.ok) return blocks;
394
+ out.preset.push(...blocks.value.preset);
395
+ out.own.push(...blocks.value.own);
396
+ }
397
+ return { ok: true, value: out };
398
+ }
399
+
400
+ /**
401
+ * One paper's blocks: its preset chain's `rules`, and its own — either `{ id: severity }` over the
402
+ * paper's files, or ESLint blocks with globs relative to the paper. A preset that does not resolve
403
+ * contributes nothing here; `pdf/profile` reports it on the paper.
404
+ */
405
+ function rulesOfPaper(
406
+ dir: string,
407
+ p: PaperPreset,
408
+ ): Parsed<{ preset: RuleBlock[]; own: RuleBlock[] }> {
409
+ const settings = "settings" in p ? p.settings : null;
410
+ const fromPreset =
411
+ p.kind === "resolved"
412
+ ? parseRuleEntries(
413
+ p.preset.rules,
414
+ `the venue preset ${p.preset.chain.join(" → ")} → "rules"`,
415
+ SHIPPED_RULES,
416
+ )
417
+ : { ok: true as const, value: {} };
418
+ if (!fromPreset.ok) return fromPreset;
419
+ const own =
420
+ settings === null
421
+ ? { ok: true as const, value: [] }
422
+ : Array.isArray(settings.rules)
423
+ ? parseRuleBlocks(
424
+ settings.rules,
425
+ join(dir, CONFIG_FILE),
426
+ SHIPPED_RULES,
427
+ dir,
428
+ )
429
+ : ownRules(dir, settings);
430
+ if (!own.ok) return own;
431
+ const scoped = (rules: Record<string, RuleEntry>): RuleBlock[] =>
432
+ Object.keys(rules).length
433
+ ? [{ basePath: dir, files: PAPER_FILE_PATTERNS, rules }]
434
+ : [];
435
+ return {
436
+ ok: true,
437
+ value: { preset: scoped(fromPreset.value), own: [...own.value] },
438
+ };
439
+ }
440
+
441
+ /** A paper's `{ id: severity }` rules, as one block over the paper's files. */
442
+ function ownRules(
443
+ dir: string,
444
+ settings: PaperSettings,
445
+ ): Parsed<readonly RuleBlock[]> {
446
+ const own = paperRules(dir, settings, SHIPPED_RULES);
447
+ if (!own.ok) return own;
448
+ return {
449
+ ok: true,
450
+ value: own.value
451
+ ? [{ basePath: dir, files: PAPER_FILE_PATTERNS, rules: own.value }]
452
+ : [],
453
+ };
454
+ }
455
+
456
+ /**
457
+ * The files a paper's block may reach: exactly the ones paperlint's own blocks lint, read off its
458
+ * config. A wider glob (everything under the paper) would make ESLint lint files no block gives a
459
+ * language — `paperlint.json` itself would be parsed as JavaScript.
460
+ */
461
+ const PAPER_FILE_PATTERNS: string[] = ownedPatterns(
462
+ buildConfig({}, { sentinel: "tex language" }),
463
+ );
464
+
465
+ /**
466
+ * The root `paperlint.json` after the boundary: an unknown key is refused by name, the paper
467
+ * defaults (`extends`, `kind`, `pdf`) are checked the way a paper's own are, and `rules` becomes
468
+ * parsed config blocks. Nothing after this sees the raw object.
334
469
  */
335
470
  export function parseSettings(
336
- opts: PaperlintConfig,
471
+ json: unknown,
337
472
  where: string,
338
473
  baseDir: string,
339
474
  ): Parsed<PaperlintConfig> {
340
- const raw = opts as Record<string, unknown>;
475
+ if (typeof json !== "object" || json === null || Array.isArray(json))
476
+ return { ok: false, error: `${where}: must be a JSON object` };
477
+ const raw = json as Record<string, unknown>;
478
+ const opts = raw as PaperlintConfig;
341
479
  const unknown = unknownKeys(raw);
342
480
  if (unknown.length > 0)
343
481
  return {
@@ -346,15 +484,17 @@ export function parseSettings(
346
484
  `${where}: unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")} — ` +
347
485
  `a typo would otherwise read as "not set". Known keys: ${Object.keys(SETTINGS_KEYS).join(", ")}`,
348
486
  };
487
+ const defaults = stringFields(raw);
488
+ if (!defaults.ok) return { ok: false, error: `${where}: ${defaults.error}` };
349
489
  const rules = parseRuleBlocks(raw["rules"], where, SHIPPED_RULES, baseDir);
350
490
  if (!rules.ok) return rules;
351
491
  return { ok: true, value: { ...opts, rules: rules.value } };
352
492
  }
353
493
 
354
494
  /**
355
- * The directory ESLint runs from. ESLint ignores every file outside it (#48), and
356
- * `paper/typography` reads its debt keys relative to it — keys the config writes from its own
357
- * directory. So: the deepest directory holding the config's directory and every path. With the
495
+ * The directory ESLint runs from. ESLint ignores every file outside it (#48), and the consumer's
496
+ * `rules` globs are written relative to the config's directory. So: the deepest directory holding
497
+ * the config's directory and every path. With the
358
498
  * papers inside the config's directory, that is the config's directory itself.
359
499
  */
360
500
  const lintRoot = (home: string, paths: readonly string[]): string =>
@@ -377,6 +517,7 @@ export function parseArgs(argv: readonly string[]): Args {
377
517
  paths: [],
378
518
  config: null,
379
519
  json: false,
520
+ fix: false,
380
521
  all: false,
381
522
  dryRun: false,
382
523
  check: false,
@@ -408,6 +549,7 @@ export function parseArgs(argv: readonly string[]): Args {
408
549
  const a = rest[i];
409
550
  if (a === undefined) continue;
410
551
  if (a === "--json") out.json = true;
552
+ else if (a === "--fix") out.fix = true;
411
553
  else if (a === "--all") out.all = true;
412
554
  else if (a === "--dry-run") out.dryRun = true;
413
555
  else if (a === "--check") out.check = true;
@@ -431,8 +573,6 @@ export function parseArgs(argv: readonly string[]): Args {
431
573
  return out;
432
574
  }
433
575
 
434
- export const PKG_NAME = "package.json";
435
-
436
576
  /**
437
577
  * This package's own version, from the `package.json` beside `src/` and `dist/` alike. `init` pins
438
578
  * the CI action to its release tag; an unreadable manifest yields `undefined`, and init then keeps
@@ -449,46 +589,17 @@ function ownVersion(): string | undefined {
449
589
  }
450
590
  }
451
591
 
452
- /**
453
- * 🔴 THE CLI READS `package.json`, THE ONE DECLARATION. `paperlint init` writes the settings under
454
- * the `package.json` key that the three hooks and `eslint-rules` already read; the CLI reads the
455
- * same file, so the install and the check cannot look at different files (issue #33,
456
- * `docs/install.md`).
457
- *
458
- * The walk goes up to the filesystem root, the way eslint, prettier and tsc find theirs, so a run
459
- * from inside one paper sees the same settings as a run from the repository root.
460
- */
461
- export function findConfig(startDir: string): string | null {
462
- let dir = resolve(startDir);
463
- for (;;) {
464
- const pkg = join(dir, PKG_NAME);
465
- if (existsSync(pkg) && declaresSettings(pkg)) return pkg;
466
- const up = dirname(dir);
467
- if (up === dir) return null;
468
- dir = up;
469
- }
470
- }
471
-
472
- /**
473
- * A `package.json` WITHOUT the key is not a declaration and must not stop the walk — every
474
- * project on the way up has one, so stopping there would make the search find nothing, always.
475
- * An unparsable one is treated the same way here; `paperlint doctor` is the command that reports it.
476
- */
477
- const declaresSettings = (pkgPath: string): boolean => {
478
- try {
479
- const d = declaredSettings(JSON.parse(readFileSync(pkgPath, "utf8")));
480
- return d.settings !== undefined || d.conflict !== null;
481
- } catch {
482
- return false;
483
- }
484
- };
485
-
486
592
  /**
487
593
  * Reading the config, ONE reader for all commands. Pulled out of `run()` the moment a second
488
594
  * command needed the same config (`build`): two copies of this block would have drifted apart on
489
595
  * the very first edit — exactly the class that already cost us the empty-set guard in two places.
490
596
  *
491
- * @returns `{ opts, configPath }` on success, or `{ code }` — and then the caller exits with it.
597
+ * 🔴 THE CONFIG FINDS ITSELF: the project root is the nearest directory up from `cwd` with a root
598
+ * `paperlint.json`, else with a `package.json`, else `cwd` (`findProjectRoot`). The file is
599
+ * optional — without one every setting has its default, and papers are in `papers/`. An explicit
600
+ * `--config` beats the discovered one: it was named out loud, and a substitution is never silent.
601
+ *
602
+ * @returns `{ opts, configPath, root }` on success, or `{ code }` — and then the caller exits with it.
492
603
  */
493
604
  export function readConfig(
494
605
  a: Args,
@@ -502,85 +613,60 @@ export function readConfig(
502
613
  cwd?: string;
503
614
  } = {},
504
615
  ): ConfigRead {
505
- // 🔴 THE CONFIG FINDS ITSELF. An explicit `--config` beats the discovered one — it was named out
506
- // loud, and a substitution is never silent. Either way the file has the shape of a
507
- // `package.json`: the settings sit under the key.
508
- const configPath = a.config ?? findConfig(cwd);
509
- if (a.config && !existsSync(a.config)) {
616
+ if (a.config && !existsSync(resolve(cwd, a.config))) {
510
617
  err(`config file not found: ${a.config}`);
511
618
  return { code: 2 };
512
619
  }
620
+ const root = a.config
621
+ ? dirname(resolve(cwd, a.config))
622
+ : findProjectRoot(cwd);
623
+ const found = a.config ? resolve(cwd, a.config) : join(root, CONFIG_FILE);
624
+ const configPath = existsSync(found) ? found : null;
625
+ if (configPath === null) return { opts: {}, configPath, root };
513
626
 
514
- let opts: PaperlintConfig = {};
515
- let legacyKey = false;
516
- if (configPath) {
517
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- #49: replace with a real type
518
- let parsed: any;
519
- try {
520
- parsed = JSON.parse(readFileSync(configPath, "utf8"));
521
- } catch (e) {
522
- err(`${configPath} is not valid JSON: ${(e as Error).message}`);
523
- return { code: 2 };
524
- }
525
- const d = declaredSettings(parsed);
526
- if (d.conflict !== null) {
527
- err(d.conflict);
528
- return { code: 2 };
529
- }
530
- opts = (d.settings ?? {}) as PaperlintConfig;
531
- legacyKey = d.legacy;
532
- // The discovered config is NAMED out loud. Otherwise a run from someone else's directory picks
533
- // up someone else's file and does not say so — and a typography-debt mismatch looks like a finding.
534
- //
535
- // 🔴 IN `--json` MODE — TO stderr. Machine output must be ONE parsable document: a line before
536
- // the array breaks any `| jq`, and it breaks it for the consumer, not for us. Caught not by a
537
- // test but by an attempt to wire our own action to this output; in the harness I first WORKED
538
- // AROUND this line (stripped the first line before JSON.parse) — that is, the workaround hid
539
- // the defect exactly where it should have been shouting.
540
- (a.json ? err : log)(`config: ${relative(cwd, configPath) || PKG_NAME}`);
541
- if (legacyKey) (a.json ? err : log)(` ⚠ ${LEGACY_KEY_MESSAGE}`);
542
- }
543
-
544
- // The old field name is refused before anything else is read from the settings: falling back
545
- // to it would keep it working forever, and this package has no released users to migrate.
546
- const where = `${configPath ? basename(configPath) : PKG_NAME} → "${legacyKey ? LEGACY_CONFIG_KEY : CONFIG_KEY}"`;
547
- const renamed = configPath ? renamedFieldMessage(opts, where) : null;
548
- if (renamed) {
549
- err(renamed);
627
+ let parsed: unknown;
628
+ try {
629
+ parsed = JSON.parse(readFileSync(configPath, "utf8"));
630
+ } catch (e) {
631
+ err(`${configPath} is not valid JSON: ${(e as Error).message}`);
550
632
  return { code: 2 };
551
633
  }
552
- if (configPath) {
553
- const parsed = parseSettings(
554
- opts,
555
- where,
556
- dirname(resolve(cwd, configPath)),
557
- );
558
- if (!parsed.ok) {
559
- err(parsed.error);
560
- return { code: 2 };
561
- }
562
- opts = parsed.value;
563
- }
564
-
565
- // 🔴 THE PAPERS DIRECTORY IS A REQUIRED FIELD. The papers directory is the one thing without which the tool
566
- // does not know what it works on, and the one thing that cannot be guessed: a default of "." runs
567
- // the rules over the whole checkout and exits green over a scope nobody chose.
568
- if (configPath && !hasPapers(opts)) {
569
- err(
570
- `${configPath} must declare \`${PAPERS_DIR_FIELD}\` — the directory your papers live in, e.g.\n` +
571
- ` { "${CONFIG_KEY}": { "${PAPERS_DIR_FIELD}": "papers" } }\n` +
572
- `It is the one thing this tool cannot guess. \`npx paperlint init\` writes it for you.`,
573
- );
634
+ // The discovered config is NAMED out loud. Otherwise a run from someone else's directory picks
635
+ // up someone else's file and does not say so — and its settings then look like findings.
636
+ //
637
+ // 🔴 IN `--json` MODE — TO stderr. Machine output must be ONE parsable document: a line before
638
+ // the array breaks any `| jq`, and it breaks it for the consumer, not for us.
639
+ (a.json ? err : log)(`config: ${relative(cwd, configPath) || CONFIG_FILE}`);
640
+ const settings = parseSettings(
641
+ parsed,
642
+ relative(cwd, configPath) || CONFIG_FILE,
643
+ root,
644
+ );
645
+ if (!settings.ok) {
646
+ err(settings.error);
574
647
  return { code: 2 };
575
648
  }
576
- return { opts, configPath };
649
+ return { opts: settings.value, configPath, root };
577
650
  }
578
651
 
579
- /** The papers directory field of the settings, read by its one declared name. */
652
+ /** The papers directory field of the settings, read by its one declared name; the default when absent. */
580
653
  export function papersDirOf(opts: PaperlintConfig): unknown {
581
- return (opts as Record<string, unknown>)[PAPERS_DIR_FIELD];
654
+ const declared = (opts as Record<string, unknown>)[PAPERS_DIR_FIELD];
655
+ return declared === undefined ? DEFAULT_PAPERS_ROOT : declared;
582
656
  }
583
657
 
658
+ /** The error for a papers directory that is missing or holds no paper. */
659
+ export const noPapersMessage = (dir: string): string =>
660
+ `no papers in ${dir}/ — create one with \`npx paperlint new <name>\`, or set "${PAPERS_DIR_FIELD}" ` +
661
+ `in ${CONFIG_FILE} if your papers live elsewhere`;
662
+
663
+ /** The papers directories of a read config, absolute: each relative to the project root. */
664
+ export const papersRoots = (cfg: {
665
+ opts: PaperlintConfig;
666
+ root: string;
667
+ }): string[] =>
668
+ toPaths(papersDirOf(cfg.opts)).map((rel) => resolve(cfg.root, rel));
669
+
584
670
  /** The papers directory may be one directory or several; both spellings normalise to a list. */
585
671
  export function toPaths(papers: unknown): string[] {
586
672
  if (typeof papers === "string") return papers.trim() ? [papers.trim()] : [];
@@ -589,9 +675,6 @@ export function toPaths(papers: unknown): string[] {
589
675
  return [];
590
676
  }
591
677
 
592
- const hasPapers = (opts: PaperlintConfig): boolean =>
593
- toPaths(papersDirOf(opts)).length > 0;
594
-
595
678
  /**
596
679
  * `paperlint hook <name>` — run an editor hook. It exists for ONE thing: so that the wiring does not
597
680
  * address the runtime from the project root.
@@ -738,14 +821,11 @@ async function runNew(
738
821
  }
739
822
  const cfg = readConfig({ ...a, json: false }, { log: () => {}, err, cwd });
740
823
  if (cfg.code !== undefined) return cfg.code;
741
- const roots = toPaths(papersDirOf(cfg.opts)).map((rel) =>
742
- resolve(dirname(cfg.configPath ?? cwd), rel),
743
- );
824
+ const roots = papersRoots(cfg);
744
825
  const papersRoot = roots[0];
745
- if (!cfg.configPath || papersRoot === undefined) {
826
+ if (papersRoot === undefined) {
746
827
  err(
747
- `no papers directory is declared, so there is nowhere to put \`${name}\`.\n` +
748
- `Run \`npx paperlint init\` first — it declares the directory in package.json.`,
828
+ `"${PAPERS_DIR_FIELD}" names no directory, so there is nowhere to put \`${name}\`.`,
749
829
  );
750
830
  return 2;
751
831
  }
@@ -773,16 +853,7 @@ async function runBuild(
773
853
  ): Promise<number> {
774
854
  const cfg = readConfig(a, { log, err, cwd });
775
855
  if (cfg.code !== undefined) return cfg.code;
776
- const { opts, configPath } = cfg;
777
- // The key that used to name the scripts to run. It is read by nothing now; saying so beats a
778
- // setting that silently stopped doing anything.
779
- if (opts.buildScripts !== undefined)
780
- log(
781
- `note: "buildScripts" in ${relative(cwd, configPath ?? "") || "the settings"} is ignored — paperlint builds the paper itself`,
782
- );
783
- const roots = toPaths(papersDirOf(opts)).map((rel) =>
784
- resolve(configPath ? dirname(configPath) : cwd, rel),
785
- );
856
+ const roots = papersRoots(cfg);
786
857
 
787
858
  let targets;
788
859
  if (a.all) {
@@ -810,6 +881,7 @@ async function runBuild(
810
881
  cwd,
811
882
  dryRun: a.dryRun,
812
883
  log,
884
+ checkReferences: onlineReferences,
813
885
  engine: () => engineEnv(targets, a, { log, err }),
814
886
  });
815
887
  if (out.kind === "no-engine") return 1;
@@ -818,15 +890,35 @@ async function runBuild(
818
890
  return anyFailed(out.results) ? 1 : 0;
819
891
  }
820
892
 
821
- /** What one paper needs from TeX Live; a venue.json that does not parse is the build's to report. */
893
+ /** The presets' deps, wired to the disk and the package's own venues directory. */
894
+ const PRESET_DEPS = { files: nodeFiles, venuesDir: packageVenuesDir() };
895
+
896
+ /**
897
+ * What one paper needs from TeX Live: the base set plus its preset chain's `tex`. A paper whose
898
+ * settings or preset do not resolve gets the base set; the build reports why at its facts step.
899
+ */
822
900
  function paperRequirements(dir: string): TexRequirements {
823
- let venue: string | null = null;
824
- try {
825
- venue = readFacts(dir).venue;
826
- } catch {
827
- venue = null;
828
- }
829
- return requirementsFor(venue).tex;
901
+ const p = paperPreset(dir, PRESET_DEPS);
902
+ return requirementsFor(p.kind === "resolved" ? p.preset : null).tex;
903
+ }
904
+
905
+ /**
906
+ * What `paperlint toolchain` installs: every shipped preset's packages, plus the resolved chain of
907
+ * every paper under the project's papers directory — a project's own preset lives outside the
908
+ * package, so the shipped union alone would not see it.
909
+ */
910
+ export function toolchainTex(cwd: string): TexRequirements {
911
+ const cfg = readConfig(parseArgs(["toolchain"]), {
912
+ log: () => {},
913
+ err: () => {},
914
+ cwd,
915
+ });
916
+ const roots = cfg.code === undefined ? papersRoots(cfg) : [];
917
+ const chains = roots
918
+ .flatMap((r) => papersIn(r))
919
+ .map((dir) => paperPreset(dir, PRESET_DEPS))
920
+ .flatMap((p) => (p.kind === "resolved" ? [p.preset.tex] : []));
921
+ return declaredUnion(undefined, chains).tex;
830
922
  }
831
923
 
832
924
  /**
@@ -868,8 +960,14 @@ const SIMPLE: Readonly<
868
960
  hook: (a, { err }) => runHook(a.paths[0], { err }),
869
961
  new: (a, io) => runNew(a, io),
870
962
  build: (a, io) => runBuild(a, io),
871
- toolchain: (a, { log, err }) =>
872
- runToolchain({ check: a.check, log, err, banal: hostBanalInstaller() }),
963
+ toolchain: (a, { log, err, cwd }) =>
964
+ runToolchain({
965
+ check: a.check,
966
+ log,
967
+ err,
968
+ banal: hostBanalInstaller(),
969
+ tex: toolchainTex(cwd),
970
+ }),
873
971
  };
874
972
 
875
973
  /** banal's installer, wired from this process's environment: the composition root's work. */
@@ -884,6 +982,61 @@ function hostBanalInstaller(): ToolInstaller {
884
982
  return banalInstaller({ ...ports, download }, s);
885
983
  }
886
984
 
985
+ /** `paperlint init`: its flags checked here, the install itself in `init.ts`. */
986
+ async function runInit(
987
+ a: Args,
988
+ {
989
+ log,
990
+ err,
991
+ cwd,
992
+ }: { log: typeof console.log; err: typeof console.error; cwd: string },
993
+ ): Promise<number> {
994
+ // Deferred, not implemented: named and refused, rather than read as the directory argument.
995
+ if (a.hooksMode !== null) {
996
+ err(
997
+ `--hooks=${a.hooksMode} is not implemented. init writes the hooks into .claude/settings.json ` +
998
+ `(shared, committed) or, with --no-hooks, nowhere.`,
999
+ );
1000
+ return 2;
1001
+ }
1002
+ if (a.format !== null && !isFormat(a.format)) {
1003
+ err(`--format must be one of ${FORMATS.join(", ")} — got \`${a.format}\``);
1004
+ return 2;
1005
+ }
1006
+ return await init(a.paths[0] ?? ".", {
1007
+ log,
1008
+ err,
1009
+ cwd,
1010
+ version: ownVersion(),
1011
+ yes: a.yes,
1012
+ hooks: !a.noHooks,
1013
+ paper: a.paper,
1014
+ format: isFormat(a.format) ? a.format : null,
1015
+ createPaper: (papersRoot, name, format) =>
1016
+ createPaperAt(papersRoot, name, format, { log, err, cwd }),
1017
+ tex: {
1018
+ installed: () => cachedTree(cacheRoot(process.env)) !== null,
1019
+ install: () =>
1020
+ runToolchain({
1021
+ check: false,
1022
+ log,
1023
+ err,
1024
+ banal: hostBanalInstaller(),
1025
+ tex: toolchainTex(resolve(cwd, a.paths[0] ?? ".")),
1026
+ }),
1027
+ },
1028
+ resolveCliPapers: (root: string): string | null => {
1029
+ const read = readConfig(
1030
+ { ...a, config: null },
1031
+ { log: () => {}, err: () => {}, cwd: root },
1032
+ );
1033
+ return read.code === undefined
1034
+ ? (toPaths(papersDirOf(read.opts))[0] ?? null)
1035
+ : null;
1036
+ },
1037
+ });
1038
+ }
1039
+
887
1040
  export async function run(
888
1041
  argv: readonly string[],
889
1042
  {
@@ -915,43 +1068,7 @@ export async function run(
915
1068
  // `init` asks the CLI's OWN reader what it would lint, so the two sides `doctor` compares are
916
1069
  // not two implementations of the same question. A second resolver here is the defect the
917
1070
  // comparison exists to catch.
918
- if (a.cmd === "init") {
919
- // Deferred, not implemented: named and refused, rather than read as the directory argument.
920
- if (a.hooksMode !== null) {
921
- err(
922
- `--hooks=${a.hooksMode} is not implemented. init writes the hooks into .claude/settings.json ` +
923
- `(shared, committed) or, with --no-hooks, nowhere.`,
924
- );
925
- return 2;
926
- }
927
- if (a.format !== null && !isFormat(a.format)) {
928
- err(
929
- `--format must be one of ${FORMATS.join(", ")} — got \`${a.format}\``,
930
- );
931
- return 2;
932
- }
933
- return await init(a.paths[0] ?? ".", {
934
- log,
935
- err,
936
- cwd,
937
- version: ownVersion(),
938
- yes: a.yes,
939
- hooks: !a.noHooks,
940
- paper: a.paper,
941
- format: isFormat(a.format) ? a.format : null,
942
- createPaper: (papersRoot, name, format) =>
943
- createPaperAt(papersRoot, name, format, { log, err, cwd }),
944
- resolveCliPapers: (root: string): string | null => {
945
- const read = readConfig(
946
- { ...a, config: null },
947
- { log: () => {}, err: () => {}, cwd: root },
948
- );
949
- return read.code === undefined
950
- ? (toPaths(papersDirOf(read.opts))[0] ?? null)
951
- : null;
952
- },
953
- });
954
- }
1071
+ if (a.cmd === "init") return await runInit(a, { log, err, cwd });
955
1072
  // `doctor` reads the config but must NOT die on a broken one — reporting that the config is
956
1073
  // broken is precisely its job. So a failed read becomes "the CLI would lint nothing", which is
957
1074
  // what it prints, rather than an early exit that tells the reader nothing about the hooks.
@@ -981,38 +1098,49 @@ export async function run(
981
1098
 
982
1099
  const cfg = readConfig(a, { log, err, cwd });
983
1100
  if (cfg.code !== undefined) return cfg.code;
984
- const { opts, configPath } = cfg;
1101
+ const { opts, root } = cfg;
985
1102
 
986
1103
  // A command-line argument OVERRIDES the config: one paper out of the corpus gets linted without
987
1104
  // editing a file.
988
1105
  //
989
- // 🔴 A path FROM THE CONFIG is resolved relative to the CONFIG'S DIRECTORY, not the current one.
990
- // Otherwise walking up is pointless: from `papers/aisec-2026` the file would be found, but
991
- // `"papersDir": "papers"` would point at `papers/aisec-2026/papers`, which does not exist — and the
992
- // run would fail with "nothing found" where everything is in place. A command-line argument stays
1106
+ // 🔴 A path FROM THE CONFIG is resolved relative to the PROJECT ROOT, not the current directory.
1107
+ // Otherwise walking up is pointless: from `papers/aisec-2026` the root would be found, but
1108
+ // `"papersDir": "papers"` would point at `papers/aisec-2026/papers`. A command-line argument stays
993
1109
  // relative to the current directory: it was typed here and now.
994
1110
  //
995
1111
  // Both kinds end up ABSOLUTE: ESLint below runs from `lintRoot`, not from here, and would resolve a
996
1112
  // relative argument against the wrong directory.
997
1113
  const paths =
998
- a.paths.length > 0
999
- ? a.paths.map((p) => resolve(cwd, p))
1000
- : toPaths(papersDirOf(opts)).map((rel) =>
1001
- resolve(dirname(configPath ?? cwd), rel),
1002
- );
1114
+ a.paths.length > 0 ? a.paths.map((p) => resolve(cwd, p)) : papersRoots(cfg);
1003
1115
  if (paths.length === 0) {
1004
1116
  err(
1005
- `nothing to lint: no path was given and no "${CONFIG_KEY}" key was found in a ${PKG_NAME}.\n` +
1006
- `Run \`npx paperlint init\` here, or pass the directory: \`paperlint lint papers\`.`,
1117
+ `nothing to lint: "${PAPERS_DIR_FIELD}" names no directory. Pass one: \`paperlint lint papers\`.`,
1007
1118
  );
1008
1119
  return 2;
1009
1120
  }
1121
+ // 🔴 NO PAPERS WHERE THE CONFIG POINTS IS AN ERROR, NOT A CLEAN RUN. With the default in play the
1122
+ // directory may simply not exist yet; a run over nothing would be green and say nothing.
1123
+ if (a.paths.length === 0) {
1124
+ const empty = paths.find((p) => papersIn(p).length === 0);
1125
+ if (empty !== undefined) {
1126
+ err(noPapersMessage(relative(cwd, empty) || "."));
1127
+ return 2;
1128
+ }
1129
+ }
1010
1130
 
1011
1131
  // 🔴 STRUCTURE IS CHECKED BEFORE ESLint AND SEPARATELY FROM IT. A rule is invoked for the file
1012
1132
  // handed to it; a missing file is never handed over, so no rule at all can report the absence —
1013
1133
  // a directory without `PIPELINE-STATUS.md` simply gets not a single rule and reports clean. The
1014
1134
  // analysis of why a structure plugin for ESLint does not cure this is in `structure.mjs`.
1015
1135
  const structure = checkStructure(paths, opts.structure, { cwd });
1136
+ // The order, most general first so the most specific wins (ESLint: a later block wins): each
1137
+ // paper's venue preset → the root paperlint.json → the paper's own paperlint.json.
1138
+ const papers = paperRuleBlocks(paths);
1139
+ if (!papers.ok) return (err(papers.error), 2);
1140
+ const withPapers = {
1141
+ ...opts,
1142
+ rules: [...papers.value.preset, ...(opts.rules ?? []), ...papers.value.own],
1143
+ };
1016
1144
 
1017
1145
  let texLanguage: unknown = null;
1018
1146
  try {
@@ -1024,11 +1152,20 @@ export async function run(
1024
1152
  }
1025
1153
 
1026
1154
  const eslint = new ESLint({
1027
- cwd: lintRoot(configPath ? dirname(resolve(cwd, configPath)) : cwd, paths),
1155
+ cwd: lintRoot(root, paths),
1028
1156
  overrideConfigFile: true,
1029
- overrideConfig: buildConfig(opts, texLanguage) as Linter.Config[],
1157
+ overrideConfig: buildConfig(withPapers, texLanguage) as Linter.Config[],
1158
+ fix: a.fix,
1030
1159
  });
1031
1160
 
1161
+ const unowned = await firstUnownedFile(eslint, paths);
1162
+ if (unowned !== null) {
1163
+ err(
1164
+ `${relative(cwd, unowned) || unowned} is not a file paperlint lints — it lints ${PAPER_FILE_PATTERNS.join(", ")}`,
1165
+ );
1166
+ return 2;
1167
+ }
1168
+
1032
1169
  // 🔴 ESLint THROWS on an empty set (`NoFilesFoundError`) — the guard below simply never got
1033
1170
  // reached, which is what the very first run over an empty directory showed: instead of a clear
1034
1171
  // message a stack from the depths of eslint-helpers.js flew out. A failure stays a failure, but
@@ -1038,15 +1175,13 @@ export async function run(
1038
1175
  try {
1039
1176
  results = await eslint.lintFiles(paths);
1040
1177
  } catch (e) {
1041
- const fail = e as { messageTemplate?: string; message?: string } | null;
1042
- if (
1043
- fail?.messageTemplate === "file-not-found" ||
1044
- /No files matching/i.test(fail?.message ?? "")
1045
- )
1046
- results = [];
1178
+ if (isEmptySet(e)) results = [];
1047
1179
  else throw e;
1048
1180
  }
1049
1181
 
1182
+ // `--fix` writes what the rules fixed; the report below is what is LEFT.
1183
+ if (a.fix) await ESLint.outputFixes(results);
1184
+
1050
1185
  // 🔴 THE GUARD AGAINST A GREEN ZERO, the same one as in action.yml and for the same reason:
1051
1186
  // ESLint exits zero when there are no findings, and "no findings" is byte-for-byte
1052
1187
  // indistinguishable from "not a single rule got a single file". A rule whose glob did not match
@@ -1062,11 +1197,45 @@ export async function run(
1062
1197
  a,
1063
1198
  log,
1064
1199
  err,
1065
- where: relative(cwd, dirname(resolve(cwd, configPath ?? "."))) || ".",
1066
- opts,
1200
+ where: relative(cwd, root) || ".",
1201
+ opts: withPapers,
1067
1202
  });
1068
1203
  }
1069
1204
 
1205
+ /**
1206
+ * The first path that is a FILE paperlint does not lint, or null. ESLint would answer such a file
1207
+ * with a warning result ("File ignored because of a matching ignore pattern"), which counts as a
1208
+ * linted file and turns a run over nothing into a green one. The question is asked of ESLint's
1209
+ * own matcher, against the same scope that decides what a directory yields.
1210
+ */
1211
+ async function firstUnownedFile(
1212
+ eslint: ESLint,
1213
+ paths: readonly string[],
1214
+ ): Promise<string | null> {
1215
+ for (const p of paths)
1216
+ if (
1217
+ existsSync(p) &&
1218
+ statSync(p).isFile() &&
1219
+ (await eslint.isPathIgnored(p))
1220
+ )
1221
+ return p;
1222
+ return null;
1223
+ }
1224
+
1225
+ /**
1226
+ * ESLint's refusals of an empty set: nothing matched (`file-not-found`), or everything that matched
1227
+ * is outside paperlint's scope (`all-matched-files-ignored` — a papers directory holding only
1228
+ * vendored scripts). Both mean "nothing was linted", which the caller reports itself.
1229
+ */
1230
+ const isEmptySet = (e: unknown): boolean => {
1231
+ const fail = e as { messageTemplate?: string; message?: string } | null;
1232
+ return (
1233
+ fail?.messageTemplate === "file-not-found" ||
1234
+ fail?.messageTemplate === "all-matched-files-ignored" ||
1235
+ /No files matching/i.test(fail?.message ?? "")
1236
+ );
1237
+ };
1238
+
1070
1239
  /**
1071
1240
  * The end of `paperlint lint`: refuse an optional rule that reached no paper, print the findings, and
1072
1241
  * decide the exit code. Pulled out of `run` so each question has its own function.