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/dist/cli.js 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,30 +24,36 @@
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 } 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 { isMain, packageVenuesDir, } from "../skills/paper-pipeline/scripts/consumer.mjs";
37
35
  export { isMain };
38
36
  import { checkStructure, formatStructure, asEslintResults, } from "./structure.js";
39
- import { buildPapers, papersIn, anyFailed, remedyFor, readFacts, MAIN, } from "./build.js";
37
+ import { buildPapers, papersIn, anyFailed, remedyFor, MAIN } from "./build.js";
40
38
  import { prepareEngine } from "./build-engine.js";
41
- import { runToolchain } from "./toolchain.js";
39
+ import { cacheRoot, cachedTree, runToolchain } from "./toolchain.js";
42
40
  import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.js";
43
41
  import { curlDownload } from "./adapters/curl/index.js";
44
- import { hostDirs, nodeAdapters } from "./adapters/node/index.js";
45
- import { mergeRequirements, requirementsFor, NO_REQUIREMENTS, } from "./tex-requirements.js";
42
+ import { hostDirs, nodeAdapters, nodeFiles } from "./adapters/node/index.js";
43
+ import { VENUE_RULE_LEVELS, venueRules } from "./venue-rules.js";
44
+ import { paperRules, stringFields, } from "./paper-settings.js";
45
+ import { referenceRules, REFERENCE_RULE_LEVELS } from "./reference-rules.js";
46
+ import { onlineReferences } from "./adapters/references/index.js";
47
+ import { narrowToOwners, ownedPatterns, ruleOwners, scopeToOwned, } from "./paper-files.js";
48
+ import { paperPreset, paperPresetProblem, } from "./presets.js";
49
+ import { mergeRequirements, declaredUnion, requirementsFor, NO_REQUIREMENTS, } from "./tex-requirements.js";
46
50
  import { doctor } from "./doctor.js";
47
51
  import { init, processInteractivity, askOnTerminal } from "./init.js";
48
52
  import { DEFAULT_FORMAT, FORMATS, isFormat, newPaper, reportNewPaper, } from "./new-paper.js";
49
53
  // The one source for the consumer's config key lives in the .mjs half of the package (the ESLint
50
54
  // rules and the skill scripts import it too); its types are in lib/paper-config.d.mts.
51
- import { CONFIG_KEY, LEGACY_CONFIG_KEY, LEGACY_KEY_MESSAGE, PAPERS_DIR_FIELD, SETTINGS_KEYS, declaredSettings, renamedFieldMessage, } from "../lib/paper-config.mjs";
52
- import { parseRuleBlocks, shippedRuleIds, unknownKeys, } from "./rules-config.js";
55
+ import { CONFIG_FILE, DEFAULT_PAPERS_ROOT, PAPERS_DIR_FIELD, SETTINGS_KEYS, findProjectRoot, } from "../lib/paper-config.mjs";
56
+ import { parseRuleBlocks, parseRuleEntries, shippedRuleIds, unknownKeys, } from "./rules-config.js";
53
57
  export { init };
54
58
  export { nextSteps } from "./init.js";
55
59
  // @ts-expect-error — an ESLint rule in .mjs, it has no types
@@ -61,9 +65,11 @@ import typography from "../eslint-rules/paper-typography.mjs";
61
65
  // @ts-expect-error — an ESLint rule in .mjs, it has no types
62
66
  import texBuild from "../eslint-rules/tex-build.mjs";
63
67
  // @ts-expect-error — an ESLint rule in .mjs, it has no types
64
- import docFields from "../eslint-rules/doc-fields.mjs";
68
+ import bibReachable from "../eslint-rules/bib-reachable-entry.mjs";
65
69
  // @ts-expect-error — an ESLint rule in .mjs, it has no types
66
- import findingsCause from "../eslint-rules/review-findings-cause.mjs";
70
+ import reviewFrontmatter from "../eslint-rules/review-frontmatter.mjs";
71
+ // @ts-expect-error — an ESLint rule in .mjs, it has no types
72
+ import siblingFrontmatter from "../eslint-rules/sibling-frontmatter.mjs";
67
73
  // @ts-expect-error — an ESLint rule in .mjs, it has no types
68
74
  import pdfRules from "../eslint-rules/pdf-last-page-balance.mjs";
69
75
  const USAGE = `paperlint — machine-checkable gates for a paper kept in git
@@ -81,8 +87,11 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
81
87
  paper directory is ignored (--dry-run: print the plan only).
82
88
  Compiles with paperlint's TeX Live, else one on PATH that has every
83
89
  package the venue declares; on a terminal it offers to install
84
- one, without a terminal it stops and names \`npx paperlint toolchain\`
85
- npx paperlint toolchain [--check] install TeX Live with every package the venue profiles declare
90
+ one, without a terminal it stops and names \`npx paperlint toolchain\`.
91
+ Then checks the references online (the cited works exist, titles
92
+ and authors match) into _build/references.json — never failing
93
+ the build: without network it records "not checked"
94
+ npx paperlint toolchain [--check] install TeX Live with every package the venue presets declare
86
95
  into ~/.cache/paperlint/texlive (PAPERLINT_TEXLIVE_DIR overrides); a second
87
96
  run does nothing. --check: report what is missing, change nothing
88
97
  npx paperlint doctor say what is actually wired — and what only LOOKS wired
@@ -97,41 +106,61 @@ init:
97
106
  --format tex|md the new paper's source format; default tex
98
107
 
99
108
  lint:
100
- npx paperlint lint [paths…] [--config <file.json>] [--json]
109
+ npx paperlint lint [paths…] [--fix] [--config <file.json>] [--json]
101
110
 
102
111
  <paths…> where your papers live, e.g. papers. Optional ONLY because the declaration
103
112
  names it — one of the two must name the scope. There is no default
104
113
  of ".": linting whatever happens to be in the checkout is how a green
105
114
  report over a scope nobody chose gets produced.
115
+ --fix write every fix the rules offer (section signs, leading zeros, figure
116
+ references), then report what is left
106
117
  --config <file> read the settings from this file instead of the discovered one
107
118
  --json machine-readable findings on stdout, nothing else on it
108
119
  --max-warnings <n> fail when warnings exceed n. Default -1: warnings never fail, because
109
120
  most findings here are advisory and a gate that fails on advice gets muted
110
121
 
111
- settings — the \`paperlint\` key of your package.json, found by walking up from the
112
- current directory, the way every other tool in the stack finds its config. \`--config\` names
113
- another file of the same shape. \`papersDir\` is required; the rest is optional:
122
+ settings — paperlint.json, at two levels, one schema. Both are optional.
123
+
124
+ paperlint.json (the project root, beside package.json) — found by walking up from the
125
+ current directory. \`--config\` names another file of the same shape.
126
+
127
+ {
128
+ "papersDir": "papers",
129
+ "rules": [ { "files": ["papers/my-paper/**"],
130
+ "rules": { "pdf/last-page-balance": "error" } } ]
131
+ }
132
+
133
+ "papersDir" defaults to "papers". "extends", "kind" and "pdf" here are defaults for every
134
+ paper. "papersDir", "structure" and the skills' keys are allowed only here.
135
+
136
+ <papersDir>/<paper>/paperlint.json — one paper, merged over the root file:
137
+
138
+ { "extends": "paperlint:aisec", "kind": "research", "rules": { "pdf/last-page-balance": "error" } }
114
139
 
115
- "paperlint": {
116
- "papersDir": "papers",
117
- "authorListCommand": "node scripts/bib-authors.mjs",
118
- "typographyDebt": { "papers/my-paper": { "sectionSign": 12 } },
119
- "docFields": { "read": { "values": ["full", "abstract", "none"] } },
120
- "reviewSince": "2026-08-23",
121
- "minFindings": 3,
122
- "causeMarker": "Cause:",
123
- "rules": [ { "files": ["papers/my-paper/**"],
124
- "rules": { "pdf/last-page-balance": "error" } } ]
125
- }
140
+ "extends" names a venue preset: paperlint:<name> (shipped: acm-sigconf, agenticdev, aisec, realm)
141
+ or ./path.jsonc, relative to the paperlint.json. npm presets are not supported yet.
126
142
 
127
- "rules" takes ESLint flat-config blocks (files, ignores, rules), appended after paperlint's own, with
128
- files relative to the file holding the settings. Optional rules (off unless turned on there):
129
- pdf/last-page-balance. An unknown key, anywhere in the settings, is an error.
143
+ "rules" is { "<rule>": "<severity>" } for every paper file in scope, or ESLint flat-config
144
+ blocks (files, ignores, rules) with globs relative to that file. Order, later wins: paperlint's
145
+ own, the venue preset's, the root file's, the paper's. Optional rules (off unless turned on):
146
+ pdf/last-page-balance. The venue rules (pdf/fresh, pdf/profile, pdf/fonts, pdf/geometry,
147
+ pdf/limits, pdf/body-size, pdf/measured) are on for every paper with a venue preset; set one to
148
+ "off" to skip it. An unknown key, in either file, is an error.
130
149
  `;
131
150
  /** The config the user would otherwise write by hand. The data comes from `opts`, the mechanism is here. */
132
151
  export function buildConfig(opts = {}, texLanguage) {
133
152
  const paperRules = { ...researchQuestion.rules, ...typography.rules };
134
- const typographyOpt = ["warn", { debt: opts.typographyDebt ?? {} }];
153
+ // The reference rules judge `_build/references.json`, and only on `paper.tex`.
154
+ const texPaperRules = {
155
+ ...paperRules,
156
+ ...referenceRules({ files: nodeFiles }),
157
+ };
158
+ // Each typography rule reports every occurrence where it is, and fixes it (`--fix`).
159
+ const prose = {
160
+ "paper/research-question": "warn",
161
+ "paper/section-word": "warn",
162
+ "paper/leading-zero": "warn",
163
+ };
135
164
  const md = {
136
165
  language: "markdown/gfm",
137
166
  languageOptions: { frontmatter: "yaml" },
@@ -143,11 +172,21 @@ export function buildConfig(opts = {}, texLanguage) {
143
172
  // default (only `node_modules/` and `.git/`), so without this block `paperlint lint` would lint the
144
173
  // template as a paper — and a richer template with placeholder stages would fail the run.
145
174
  { ignores: ["**/.template/"] },
146
- // The `pdf` plugin is registered for EVERY file, and its rule is on for none. A consumer's
147
- // block (`rules`, appended below) turns it on for a glob that also matches markdown files;
148
- // with the plugin defined only beside `paper.tex`, ESLint would refuse those files with
149
- // "could not find plugin". The rule itself acts on `paper.tex` only.
150
- { plugins: { pdf: pdfRules } },
175
+ // The `pdf` plugin is registered for EVERY file. A consumer's block (`rules`, appended below)
176
+ // may name its rules for a glob that also matches markdown files; with the plugin defined only
177
+ // beside `paper.tex`, ESLint would refuse those files with "could not find plugin". Every rule
178
+ // in it acts on `paper.tex` only. `last-page-balance` is on for no file (optional); the venue
179
+ // rules are on for every `paper.tex`, in the block below.
180
+ {
181
+ plugins: {
182
+ pdf: {
183
+ rules: {
184
+ ...pdfRules.rules,
185
+ ...venueRules({ files: nodeFiles, venuesDir: packageVenuesDir() }),
186
+ },
187
+ },
188
+ },
189
+ },
151
190
  {
152
191
  files: ["**/PIPELINE-STATUS.md"],
153
192
  plugins: { markdown, paper: paperStages },
@@ -155,51 +194,30 @@ export function buildConfig(opts = {}, texLanguage) {
155
194
  rules: {
156
195
  "paper/stages": "error",
157
196
  "paper/source": "error",
158
- "paper/author-list": [
159
- "warn",
160
- opts.authorListCommand ? { command: opts.authorListCommand } : {},
161
- ],
162
197
  },
163
198
  },
164
199
  {
165
200
  files: ["**/paper.md", "**/draft.md"],
166
201
  plugins: { markdown, paper: { rules: paperRules } },
167
202
  ...md,
168
- rules: {
169
- "paper/research-question": "warn",
170
- "paper/typography": typographyOpt,
171
- },
203
+ rules: prose,
172
204
  },
173
205
  {
174
206
  files: ["**/reviews/*.md"],
175
- plugins: {
176
- markdown,
177
- review: { rules: { ...findingsCause.rules } },
178
- doc: docFields,
179
- },
207
+ plugins: { markdown, review: reviewFrontmatter },
180
208
  ...md,
181
- rules: {
182
- /*
183
- * 🔴 THE DEFAULT IS ENGLISH SINCE 2026-09-17. It used to be the Russian word for "Cause:" —
184
- * a Russian word in a package whose interface is English. An `error`-level rule demanded
185
- * that a person put Cyrillic into their own file, and there was nothing to change the
186
- * marker with: `causeMarker` was not threaded through the CLI at all. The only way out was
187
- * to abandon the command and assemble the ESLint config by hand — that is, the defect
188
- * pushed you onto exactly the path the utility frees you from.
189
- * The Russian marker stays EXPRESSIBLE, but now as a value, not as the default.
190
- */
191
- "review/findings-cause": [
192
- "error",
193
- {
194
- minFindings: opts.minFindings ?? 3,
195
- ...(opts.causeMarker ? { causeMarker: opts.causeMarker } : {}),
196
- ...(opts.reviewSince ? { sinceCreated: opts.reviewSince } : {}),
197
- },
198
- ],
199
- ...(opts.docFields
200
- ? { "doc/fields": ["warn", { fields: opts.docFields }] }
201
- : {}),
202
- },
209
+ // A review's frontmatter is a record, validated by paperlint's JSON Schema
210
+ // (eslint-rules/review-frontmatter.mjs).
211
+ rules: { "review/frontmatter": "error" },
212
+ },
213
+ {
214
+ // A sibling card says how much of the competing paper was read (`read:`). The cards are
215
+ // written by this package's analyze-sibling-paper skill; its README is an index, not a card.
216
+ files: ["**/siblings/*.md"],
217
+ ignores: ["**/siblings/README.md"],
218
+ plugins: { markdown, sibling: siblingFrontmatter },
219
+ ...md,
220
+ rules: { "sibling/frontmatter": "warn" },
203
221
  },
204
222
  ];
205
223
  // `.tex` only if the language loaded: it pulls in the LaTeX parser, and dying because of it on a
@@ -209,19 +227,32 @@ export function buildConfig(opts = {}, texLanguage) {
209
227
  files: ["**/paper.tex"],
210
228
  plugins: {
211
229
  tex: { languages: { latex: texLanguage }, rules: texBuild },
212
- paper: { rules: paperRules },
230
+ paper: { rules: texPaperRules },
231
+ bib: bibReachable,
213
232
  },
214
233
  language: "tex/latex",
215
234
  rules: {
216
- "paper/research-question": "warn",
217
- "paper/typography": typographyOpt,
235
+ ...prose,
236
+ ...REFERENCE_RULE_LEVELS,
237
+ "paper/figure-ref-style": "warn",
238
+ "bib/reachable-entry": "warn",
218
239
  "tex/future-promise": "warn",
219
240
  "tex/acm-frontmatter-override": "error",
241
+ // Silent for a paper whose paperlint.json names no venue (src/venue-rules.ts).
242
+ ...VENUE_RULE_LEVELS,
220
243
  },
221
244
  });
245
+ // 🔴 ONLY THE FILES THESE BLOCKS CLAIM ARE LINTED (src/paper-files.ts). Without this block ESLint's
246
+ // built-in defaults lint every .js/.mjs/.cjs under the papers directory — a paper's vendored
247
+ // `repro/` code failed the run with 74 parse errors and not one finding on a paper file. It goes
248
+ // FIRST: the `.template/` ignore below must come after its directory un-ignore to win.
249
+ const owners = ruleOwners(cfg);
250
+ cfg.unshift(scopeToOwned(ownedPatterns(cfg)));
222
251
  // The consumer's own blocks, LAST, so a later block wins — ESLint's rule. Parsed by
223
- // `readConfig`; each carries the settings file's directory as its `basePath`.
224
- cfg.push(...(opts.rules ?? []));
252
+ // `readConfig`; each carries the settings file's directory as its `basePath`. Each is split so a
253
+ // rule reaches only the files its plugin is registered for: `paper` is a different plugin beside
254
+ // PIPELINE-STATUS.md than beside paper.tex, and ESLint throws on a rule its plugin lacks.
255
+ cfg.push(...(opts.rules ?? []).flatMap((b) => narrowToOwners(b, owners)));
225
256
  return cfg;
226
257
  }
227
258
  /**
@@ -260,11 +291,83 @@ export async function silentOptionalRules(eslint, lintedFiles, opts) {
260
291
  return [...turnedOn].filter((id) => !reached.has(id));
261
292
  }
262
293
  /**
263
- * The settings after the boundary: an unknown key is refused by name, and `rules` becomes parsed
264
- * config blocks. Nothing after this sees the raw object.
294
+ * Every linted paper's rules, as ESLint blocks scoped to that paper, in two groups: what its venue
295
+ * preset turns on, and what its own `paperlint.json` says. The caller puts the root's blocks between
296
+ * them. A file that does not parse, or names a rule paperlint does not ship, stops the run with one
297
+ * line naming the file — the same strictness as the root's own `rules`.
298
+ */
299
+ export function paperRuleBlocks(paths) {
300
+ // A FILE named on the command line belongs to the paper it sits in: that paper's settings apply.
301
+ const dirs = paths.map((p) => existsSync(p) && statSync(p).isFile() ? dirname(p) : p);
302
+ const papers = [...new Set(dirs.flatMap((p) => [p, ...papersIn(p)]))];
303
+ const out = { preset: [], own: [] };
304
+ for (const dir of papers) {
305
+ const p = paperPreset(dir, PRESET_DEPS);
306
+ if (p.kind === "settings-problem" && p.problem.kind === "broken")
307
+ return { ok: false, error: paperPresetProblem(dir, p) ?? dir };
308
+ const blocks = rulesOfPaper(dir, p);
309
+ if (!blocks.ok)
310
+ return blocks;
311
+ out.preset.push(...blocks.value.preset);
312
+ out.own.push(...blocks.value.own);
313
+ }
314
+ return { ok: true, value: out };
315
+ }
316
+ /**
317
+ * One paper's blocks: its preset chain's `rules`, and its own — either `{ id: severity }` over the
318
+ * paper's files, or ESLint blocks with globs relative to the paper. A preset that does not resolve
319
+ * contributes nothing here; `pdf/profile` reports it on the paper.
265
320
  */
266
- export function parseSettings(opts, where, baseDir) {
267
- const raw = opts;
321
+ function rulesOfPaper(dir, p) {
322
+ const settings = "settings" in p ? p.settings : null;
323
+ const fromPreset = p.kind === "resolved"
324
+ ? parseRuleEntries(p.preset.rules, `the venue preset ${p.preset.chain.join(" → ")} → "rules"`, SHIPPED_RULES)
325
+ : { ok: true, value: {} };
326
+ if (!fromPreset.ok)
327
+ return fromPreset;
328
+ const own = settings === null
329
+ ? { ok: true, value: [] }
330
+ : Array.isArray(settings.rules)
331
+ ? parseRuleBlocks(settings.rules, join(dir, CONFIG_FILE), SHIPPED_RULES, dir)
332
+ : ownRules(dir, settings);
333
+ if (!own.ok)
334
+ return own;
335
+ const scoped = (rules) => Object.keys(rules).length
336
+ ? [{ basePath: dir, files: PAPER_FILE_PATTERNS, rules }]
337
+ : [];
338
+ return {
339
+ ok: true,
340
+ value: { preset: scoped(fromPreset.value), own: [...own.value] },
341
+ };
342
+ }
343
+ /** A paper's `{ id: severity }` rules, as one block over the paper's files. */
344
+ function ownRules(dir, settings) {
345
+ const own = paperRules(dir, settings, SHIPPED_RULES);
346
+ if (!own.ok)
347
+ return own;
348
+ return {
349
+ ok: true,
350
+ value: own.value
351
+ ? [{ basePath: dir, files: PAPER_FILE_PATTERNS, rules: own.value }]
352
+ : [],
353
+ };
354
+ }
355
+ /**
356
+ * The files a paper's block may reach: exactly the ones paperlint's own blocks lint, read off its
357
+ * config. A wider glob (everything under the paper) would make ESLint lint files no block gives a
358
+ * language — `paperlint.json` itself would be parsed as JavaScript.
359
+ */
360
+ const PAPER_FILE_PATTERNS = ownedPatterns(buildConfig({}, { sentinel: "tex language" }));
361
+ /**
362
+ * The root `paperlint.json` after the boundary: an unknown key is refused by name, the paper
363
+ * defaults (`extends`, `kind`, `pdf`) are checked the way a paper's own are, and `rules` becomes
364
+ * parsed config blocks. Nothing after this sees the raw object.
365
+ */
366
+ export function parseSettings(json, where, baseDir) {
367
+ if (typeof json !== "object" || json === null || Array.isArray(json))
368
+ return { ok: false, error: `${where}: must be a JSON object` };
369
+ const raw = json;
370
+ const opts = raw;
268
371
  const unknown = unknownKeys(raw);
269
372
  if (unknown.length > 0)
270
373
  return {
@@ -272,15 +375,18 @@ export function parseSettings(opts, where, baseDir) {
272
375
  error: `${where}: unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")} — ` +
273
376
  `a typo would otherwise read as "not set". Known keys: ${Object.keys(SETTINGS_KEYS).join(", ")}`,
274
377
  };
378
+ const defaults = stringFields(raw);
379
+ if (!defaults.ok)
380
+ return { ok: false, error: `${where}: ${defaults.error}` };
275
381
  const rules = parseRuleBlocks(raw["rules"], where, SHIPPED_RULES, baseDir);
276
382
  if (!rules.ok)
277
383
  return rules;
278
384
  return { ok: true, value: { ...opts, rules: rules.value } };
279
385
  }
280
386
  /**
281
- * The directory ESLint runs from. ESLint ignores every file outside it (#48), and
282
- * `paper/typography` reads its debt keys relative to it — keys the config writes from its own
283
- * directory. So: the deepest directory holding the config's directory and every path. With the
387
+ * The directory ESLint runs from. ESLint ignores every file outside it (#48), and the consumer's
388
+ * `rules` globs are written relative to the config's directory. So: the deepest directory holding
389
+ * the config's directory and every path. With the
284
390
  * papers inside the config's directory, that is the config's directory itself.
285
391
  */
286
392
  const lintRoot = (home, paths) => commonDir([home, ...paths]);
@@ -298,6 +404,7 @@ export function parseArgs(argv) {
298
404
  paths: [],
299
405
  config: null,
300
406
  json: false,
407
+ fix: false,
301
408
  all: false,
302
409
  dryRun: false,
303
410
  check: false,
@@ -331,6 +438,8 @@ export function parseArgs(argv) {
331
438
  continue;
332
439
  if (a === "--json")
333
440
  out.json = true;
441
+ else if (a === "--fix")
442
+ out.fix = true;
334
443
  else if (a === "--all")
335
444
  out.all = true;
336
445
  else if (a === "--dry-run")
@@ -364,7 +473,6 @@ export function parseArgs(argv) {
364
473
  }
365
474
  return out;
366
475
  }
367
- export const PKG_NAME = "package.json";
368
476
  /**
369
477
  * This package's own version, from the `package.json` beside `src/` and `dist/` alike. `init` pins
370
478
  * the CI action to its release tag; an unreadable manifest yields `undefined`, and init then keeps
@@ -379,119 +487,61 @@ function ownVersion() {
379
487
  return undefined;
380
488
  }
381
489
  }
382
- /**
383
- * 🔴 THE CLI READS `package.json`, THE ONE DECLARATION. `paperlint init` writes the settings under
384
- * the `package.json` key that the three hooks and `eslint-rules` already read; the CLI reads the
385
- * same file, so the install and the check cannot look at different files (issue #33,
386
- * `docs/install.md`).
387
- *
388
- * The walk goes up to the filesystem root, the way eslint, prettier and tsc find theirs, so a run
389
- * from inside one paper sees the same settings as a run from the repository root.
390
- */
391
- export function findConfig(startDir) {
392
- let dir = resolve(startDir);
393
- for (;;) {
394
- const pkg = join(dir, PKG_NAME);
395
- if (existsSync(pkg) && declaresSettings(pkg))
396
- return pkg;
397
- const up = dirname(dir);
398
- if (up === dir)
399
- return null;
400
- dir = up;
401
- }
402
- }
403
- /**
404
- * A `package.json` WITHOUT the key is not a declaration and must not stop the walk — every
405
- * project on the way up has one, so stopping there would make the search find nothing, always.
406
- * An unparsable one is treated the same way here; `paperlint doctor` is the command that reports it.
407
- */
408
- const declaresSettings = (pkgPath) => {
409
- try {
410
- const d = declaredSettings(JSON.parse(readFileSync(pkgPath, "utf8")));
411
- return d.settings !== undefined || d.conflict !== null;
412
- }
413
- catch {
414
- return false;
415
- }
416
- };
417
490
  /**
418
491
  * Reading the config, ONE reader for all commands. Pulled out of `run()` the moment a second
419
492
  * command needed the same config (`build`): two copies of this block would have drifted apart on
420
493
  * the very first edit — exactly the class that already cost us the empty-set guard in two places.
421
494
  *
422
- * @returns `{ opts, configPath }` on success, or `{ code }` — and then the caller exits with it.
495
+ * 🔴 THE CONFIG FINDS ITSELF: the project root is the nearest directory up from `cwd` with a root
496
+ * `paperlint.json`, else with a `package.json`, else `cwd` (`findProjectRoot`). The file is
497
+ * optional — without one every setting has its default, and papers are in `papers/`. An explicit
498
+ * `--config` beats the discovered one: it was named out loud, and a substitution is never silent.
499
+ *
500
+ * @returns `{ opts, configPath, root }` on success, or `{ code }` — and then the caller exits with it.
423
501
  */
424
502
  export function readConfig(a, { log = console.log, err = console.error, cwd = process.cwd(), } = {}) {
425
- // 🔴 THE CONFIG FINDS ITSELF. An explicit `--config` beats the discovered one — it was named out
426
- // loud, and a substitution is never silent. Either way the file has the shape of a
427
- // `package.json`: the settings sit under the key.
428
- const configPath = a.config ?? findConfig(cwd);
429
- if (a.config && !existsSync(a.config)) {
503
+ if (a.config && !existsSync(resolve(cwd, a.config))) {
430
504
  err(`config file not found: ${a.config}`);
431
505
  return { code: 2 };
432
506
  }
433
- let opts = {};
434
- let legacyKey = false;
435
- if (configPath) {
436
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- #49: replace with a real type
437
- let parsed;
438
- try {
439
- parsed = JSON.parse(readFileSync(configPath, "utf8"));
440
- }
441
- catch (e) {
442
- err(`${configPath} is not valid JSON: ${e.message}`);
443
- return { code: 2 };
444
- }
445
- const d = declaredSettings(parsed);
446
- if (d.conflict !== null) {
447
- err(d.conflict);
448
- return { code: 2 };
449
- }
450
- opts = (d.settings ?? {});
451
- legacyKey = d.legacy;
452
- // The discovered config is NAMED out loud. Otherwise a run from someone else's directory picks
453
- // up someone else's file and does not say so — and a typography-debt mismatch looks like a finding.
454
- //
455
- // 🔴 IN `--json` MODE — TO stderr. Machine output must be ONE parsable document: a line before
456
- // the array breaks any `| jq`, and it breaks it for the consumer, not for us. Caught not by a
457
- // test but by an attempt to wire our own action to this output; in the harness I first WORKED
458
- // AROUND this line (stripped the first line before JSON.parse) — that is, the workaround hid
459
- // the defect exactly where it should have been shouting.
460
- (a.json ? err : log)(`config: ${relative(cwd, configPath) || PKG_NAME}`);
461
- if (legacyKey)
462
- (a.json ? err : log)(` ⚠ ${LEGACY_KEY_MESSAGE}`);
463
- }
464
- // The old field name is refused before anything else is read from the settings: falling back
465
- // to it would keep it working forever, and this package has no released users to migrate.
466
- const where = `${configPath ? basename(configPath) : PKG_NAME} → "${legacyKey ? LEGACY_CONFIG_KEY : CONFIG_KEY}"`;
467
- const renamed = configPath ? renamedFieldMessage(opts, where) : null;
468
- if (renamed) {
469
- err(renamed);
507
+ const root = a.config
508
+ ? dirname(resolve(cwd, a.config))
509
+ : findProjectRoot(cwd);
510
+ const found = a.config ? resolve(cwd, a.config) : join(root, CONFIG_FILE);
511
+ const configPath = existsSync(found) ? found : null;
512
+ if (configPath === null)
513
+ return { opts: {}, configPath, root };
514
+ let parsed;
515
+ try {
516
+ parsed = JSON.parse(readFileSync(configPath, "utf8"));
517
+ }
518
+ catch (e) {
519
+ err(`${configPath} is not valid JSON: ${e.message}`);
470
520
  return { code: 2 };
471
521
  }
472
- if (configPath) {
473
- const parsed = parseSettings(opts, where, dirname(resolve(cwd, configPath)));
474
- if (!parsed.ok) {
475
- err(parsed.error);
476
- return { code: 2 };
477
- }
478
- opts = parsed.value;
479
- }
480
- // 🔴 THE PAPERS DIRECTORY IS A REQUIRED FIELD. The papers directory is the one thing without which the tool
481
- // does not know what it works on, and the one thing that cannot be guessed: a default of "." runs
482
- // the rules over the whole checkout and exits green over a scope nobody chose.
483
- if (configPath && !hasPapers(opts)) {
484
- err(`${configPath} must declare \`${PAPERS_DIR_FIELD}\` — the directory your papers live in, e.g.\n` +
485
- ` { "${CONFIG_KEY}": { "${PAPERS_DIR_FIELD}": "papers" } }\n` +
486
- `It is the one thing this tool cannot guess. \`npx paperlint init\` writes it for you.`);
522
+ // The discovered config is NAMED out loud. Otherwise a run from someone else's directory picks
523
+ // up someone else's file and does not say so — and its settings then look like findings.
524
+ //
525
+ // 🔴 IN `--json` MODE — TO stderr. Machine output must be ONE parsable document: a line before
526
+ // the array breaks any `| jq`, and it breaks it for the consumer, not for us.
527
+ (a.json ? err : log)(`config: ${relative(cwd, configPath) || CONFIG_FILE}`);
528
+ const settings = parseSettings(parsed, relative(cwd, configPath) || CONFIG_FILE, root);
529
+ if (!settings.ok) {
530
+ err(settings.error);
487
531
  return { code: 2 };
488
532
  }
489
- return { opts, configPath };
533
+ return { opts: settings.value, configPath, root };
490
534
  }
491
- /** The papers directory field of the settings, read by its one declared name. */
535
+ /** The papers directory field of the settings, read by its one declared name; the default when absent. */
492
536
  export function papersDirOf(opts) {
493
- return opts[PAPERS_DIR_FIELD];
537
+ const declared = opts[PAPERS_DIR_FIELD];
538
+ return declared === undefined ? DEFAULT_PAPERS_ROOT : declared;
494
539
  }
540
+ /** The error for a papers directory that is missing or holds no paper. */
541
+ export const noPapersMessage = (dir) => `no papers in ${dir}/ — create one with \`npx paperlint new <name>\`, or set "${PAPERS_DIR_FIELD}" ` +
542
+ `in ${CONFIG_FILE} if your papers live elsewhere`;
543
+ /** The papers directories of a read config, absolute: each relative to the project root. */
544
+ export const papersRoots = (cfg) => toPaths(papersDirOf(cfg.opts)).map((rel) => resolve(cfg.root, rel));
495
545
  /** The papers directory may be one directory or several; both spellings normalise to a list. */
496
546
  export function toPaths(papers) {
497
547
  if (typeof papers === "string")
@@ -500,7 +550,6 @@ export function toPaths(papers) {
500
550
  return papers.filter((x) => typeof x === "string" && x.trim());
501
551
  return [];
502
552
  }
503
- const hasPapers = (opts) => toPaths(papersDirOf(opts)).length > 0;
504
553
  /**
505
554
  * `paperlint hook <name>` — run an editor hook. It exists for ONE thing: so that the wiring does not
506
555
  * address the runtime from the project root.
@@ -604,11 +653,10 @@ async function runNew(a, { log, err, cwd, ask = askOnTerminal, }) {
604
653
  const cfg = readConfig({ ...a, json: false }, { log: () => { }, err, cwd });
605
654
  if (cfg.code !== undefined)
606
655
  return cfg.code;
607
- const roots = toPaths(papersDirOf(cfg.opts)).map((rel) => resolve(dirname(cfg.configPath ?? cwd), rel));
656
+ const roots = papersRoots(cfg);
608
657
  const papersRoot = roots[0];
609
- if (!cfg.configPath || papersRoot === undefined) {
610
- err(`no papers directory is declared, so there is nowhere to put \`${name}\`.\n` +
611
- `Run \`npx paperlint init\` first — it declares the directory in package.json.`);
658
+ if (papersRoot === undefined) {
659
+ err(`"${PAPERS_DIR_FIELD}" names no directory, so there is nowhere to put \`${name}\`.`);
612
660
  return 2;
613
661
  }
614
662
  if (roots.length > 1)
@@ -626,12 +674,7 @@ async function runBuild(a, { log, err, cwd, }) {
626
674
  const cfg = readConfig(a, { log, err, cwd });
627
675
  if (cfg.code !== undefined)
628
676
  return cfg.code;
629
- const { opts, configPath } = cfg;
630
- // The key that used to name the scripts to run. It is read by nothing now; saying so beats a
631
- // setting that silently stopped doing anything.
632
- if (opts.buildScripts !== undefined)
633
- log(`note: "buildScripts" in ${relative(cwd, configPath ?? "") || "the settings"} is ignored — paperlint builds the paper itself`);
634
- const roots = toPaths(papersDirOf(opts)).map((rel) => resolve(configPath ? dirname(configPath) : cwd, rel));
677
+ const roots = papersRoots(cfg);
635
678
  let targets;
636
679
  if (a.all) {
637
680
  targets = roots.flatMap((r) => papersIn(r));
@@ -655,6 +698,7 @@ async function runBuild(a, { log, err, cwd, }) {
655
698
  cwd,
656
699
  dryRun: a.dryRun,
657
700
  log,
701
+ checkReferences: onlineReferences,
658
702
  engine: () => engineEnv(targets, a, { log, err }),
659
703
  });
660
704
  if (out.kind === "no-engine")
@@ -664,16 +708,33 @@ async function runBuild(a, { log, err, cwd, }) {
664
708
  err(remedy);
665
709
  return anyFailed(out.results) ? 1 : 0;
666
710
  }
667
- /** What one paper needs from TeX Live; a venue.json that does not parse is the build's to report. */
711
+ /** The presets' deps, wired to the disk and the package's own venues directory. */
712
+ const PRESET_DEPS = { files: nodeFiles, venuesDir: packageVenuesDir() };
713
+ /**
714
+ * What one paper needs from TeX Live: the base set plus its preset chain's `tex`. A paper whose
715
+ * settings or preset do not resolve gets the base set; the build reports why at its facts step.
716
+ */
668
717
  function paperRequirements(dir) {
669
- let venue = null;
670
- try {
671
- venue = readFacts(dir).venue;
672
- }
673
- catch {
674
- venue = null;
675
- }
676
- return requirementsFor(venue).tex;
718
+ const p = paperPreset(dir, PRESET_DEPS);
719
+ return requirementsFor(p.kind === "resolved" ? p.preset : null).tex;
720
+ }
721
+ /**
722
+ * What `paperlint toolchain` installs: every shipped preset's packages, plus the resolved chain of
723
+ * every paper under the project's papers directory — a project's own preset lives outside the
724
+ * package, so the shipped union alone would not see it.
725
+ */
726
+ export function toolchainTex(cwd) {
727
+ const cfg = readConfig(parseArgs(["toolchain"]), {
728
+ log: () => { },
729
+ err: () => { },
730
+ cwd,
731
+ });
732
+ const roots = cfg.code === undefined ? papersRoots(cfg) : [];
733
+ const chains = roots
734
+ .flatMap((r) => papersIn(r))
735
+ .map((dir) => paperPreset(dir, PRESET_DEPS))
736
+ .flatMap((p) => (p.kind === "resolved" ? [p.preset.tex] : []));
737
+ return declaredUnion(undefined, chains).tex;
677
738
  }
678
739
  /**
679
740
  * The environment the builds run in — PATH led by a TeX Live that has every package the targeted
@@ -702,7 +763,13 @@ const SIMPLE = {
702
763
  hook: (a, { err }) => runHook(a.paths[0], { err }),
703
764
  new: (a, io) => runNew(a, io),
704
765
  build: (a, io) => runBuild(a, io),
705
- toolchain: (a, { log, err }) => runToolchain({ check: a.check, log, err, banal: hostBanalInstaller() }),
766
+ toolchain: (a, { log, err, cwd }) => runToolchain({
767
+ check: a.check,
768
+ log,
769
+ err,
770
+ banal: hostBanalInstaller(),
771
+ tex: toolchainTex(cwd),
772
+ }),
706
773
  };
707
774
  /** banal's installer, wired from this process's environment: the composition root's work. */
708
775
  function hostBanalInstaller() {
@@ -715,6 +782,46 @@ function hostBanalInstaller() {
715
782
  });
716
783
  return banalInstaller({ ...ports, download }, s);
717
784
  }
785
+ /** `paperlint init`: its flags checked here, the install itself in `init.ts`. */
786
+ async function runInit(a, { log, err, cwd, }) {
787
+ // Deferred, not implemented: named and refused, rather than read as the directory argument.
788
+ if (a.hooksMode !== null) {
789
+ err(`--hooks=${a.hooksMode} is not implemented. init writes the hooks into .claude/settings.json ` +
790
+ `(shared, committed) or, with --no-hooks, nowhere.`);
791
+ return 2;
792
+ }
793
+ if (a.format !== null && !isFormat(a.format)) {
794
+ err(`--format must be one of ${FORMATS.join(", ")} — got \`${a.format}\``);
795
+ return 2;
796
+ }
797
+ return await init(a.paths[0] ?? ".", {
798
+ log,
799
+ err,
800
+ cwd,
801
+ version: ownVersion(),
802
+ yes: a.yes,
803
+ hooks: !a.noHooks,
804
+ paper: a.paper,
805
+ format: isFormat(a.format) ? a.format : null,
806
+ createPaper: (papersRoot, name, format) => createPaperAt(papersRoot, name, format, { log, err, cwd }),
807
+ tex: {
808
+ installed: () => cachedTree(cacheRoot(process.env)) !== null,
809
+ install: () => runToolchain({
810
+ check: false,
811
+ log,
812
+ err,
813
+ banal: hostBanalInstaller(),
814
+ tex: toolchainTex(resolve(cwd, a.paths[0] ?? ".")),
815
+ }),
816
+ },
817
+ resolveCliPapers: (root) => {
818
+ const read = readConfig({ ...a, config: null }, { log: () => { }, err: () => { }, cwd: root });
819
+ return read.code === undefined
820
+ ? (toPaths(papersDirOf(read.opts))[0] ?? null)
821
+ : null;
822
+ },
823
+ });
824
+ }
718
825
  export async function run(argv, { log = console.log, err = console.error, cwd = process.cwd(), } = {}) {
719
826
  const a = parseArgs(argv);
720
827
  // The refusal must come FIRST: behind a flag without a value there is usually a typo, or a
@@ -733,35 +840,8 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
733
840
  // `init` asks the CLI's OWN reader what it would lint, so the two sides `doctor` compares are
734
841
  // not two implementations of the same question. A second resolver here is the defect the
735
842
  // comparison exists to catch.
736
- if (a.cmd === "init") {
737
- // Deferred, not implemented: named and refused, rather than read as the directory argument.
738
- if (a.hooksMode !== null) {
739
- err(`--hooks=${a.hooksMode} is not implemented. init writes the hooks into .claude/settings.json ` +
740
- `(shared, committed) or, with --no-hooks, nowhere.`);
741
- return 2;
742
- }
743
- if (a.format !== null && !isFormat(a.format)) {
744
- err(`--format must be one of ${FORMATS.join(", ")} — got \`${a.format}\``);
745
- return 2;
746
- }
747
- return await init(a.paths[0] ?? ".", {
748
- log,
749
- err,
750
- cwd,
751
- version: ownVersion(),
752
- yes: a.yes,
753
- hooks: !a.noHooks,
754
- paper: a.paper,
755
- format: isFormat(a.format) ? a.format : null,
756
- createPaper: (papersRoot, name, format) => createPaperAt(papersRoot, name, format, { log, err, cwd }),
757
- resolveCliPapers: (root) => {
758
- const read = readConfig({ ...a, config: null }, { log: () => { }, err: () => { }, cwd: root });
759
- return read.code === undefined
760
- ? (toPaths(papersDirOf(read.opts))[0] ?? null)
761
- : null;
762
- },
763
- });
764
- }
843
+ if (a.cmd === "init")
844
+ return await runInit(a, { log, err, cwd });
765
845
  // `doctor` reads the config but must NOT die on a broken one — reporting that the config is
766
846
  // broken is precisely its job. So a failed read becomes "the CLI would lint nothing", which is
767
847
  // what it prints, rather than an early exit that tells the reader nothing about the hooks.
@@ -789,31 +869,45 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
789
869
  const cfg = readConfig(a, { log, err, cwd });
790
870
  if (cfg.code !== undefined)
791
871
  return cfg.code;
792
- const { opts, configPath } = cfg;
872
+ const { opts, root } = cfg;
793
873
  // A command-line argument OVERRIDES the config: one paper out of the corpus gets linted without
794
874
  // editing a file.
795
875
  //
796
- // 🔴 A path FROM THE CONFIG is resolved relative to the CONFIG'S DIRECTORY, not the current one.
797
- // Otherwise walking up is pointless: from `papers/aisec-2026` the file would be found, but
798
- // `"papersDir": "papers"` would point at `papers/aisec-2026/papers`, which does not exist — and the
799
- // run would fail with "nothing found" where everything is in place. A command-line argument stays
876
+ // 🔴 A path FROM THE CONFIG is resolved relative to the PROJECT ROOT, not the current directory.
877
+ // Otherwise walking up is pointless: from `papers/aisec-2026` the root would be found, but
878
+ // `"papersDir": "papers"` would point at `papers/aisec-2026/papers`. A command-line argument stays
800
879
  // relative to the current directory: it was typed here and now.
801
880
  //
802
881
  // Both kinds end up ABSOLUTE: ESLint below runs from `lintRoot`, not from here, and would resolve a
803
882
  // relative argument against the wrong directory.
804
- const paths = a.paths.length > 0
805
- ? a.paths.map((p) => resolve(cwd, p))
806
- : toPaths(papersDirOf(opts)).map((rel) => resolve(dirname(configPath ?? cwd), rel));
883
+ const paths = a.paths.length > 0 ? a.paths.map((p) => resolve(cwd, p)) : papersRoots(cfg);
807
884
  if (paths.length === 0) {
808
- err(`nothing to lint: no path was given and no "${CONFIG_KEY}" key was found in a ${PKG_NAME}.\n` +
809
- `Run \`npx paperlint init\` here, or pass the directory: \`paperlint lint papers\`.`);
885
+ err(`nothing to lint: "${PAPERS_DIR_FIELD}" names no directory. Pass one: \`paperlint lint papers\`.`);
810
886
  return 2;
811
887
  }
888
+ // 🔴 NO PAPERS WHERE THE CONFIG POINTS IS AN ERROR, NOT A CLEAN RUN. With the default in play the
889
+ // directory may simply not exist yet; a run over nothing would be green and say nothing.
890
+ if (a.paths.length === 0) {
891
+ const empty = paths.find((p) => papersIn(p).length === 0);
892
+ if (empty !== undefined) {
893
+ err(noPapersMessage(relative(cwd, empty) || "."));
894
+ return 2;
895
+ }
896
+ }
812
897
  // 🔴 STRUCTURE IS CHECKED BEFORE ESLint AND SEPARATELY FROM IT. A rule is invoked for the file
813
898
  // handed to it; a missing file is never handed over, so no rule at all can report the absence —
814
899
  // a directory without `PIPELINE-STATUS.md` simply gets not a single rule and reports clean. The
815
900
  // analysis of why a structure plugin for ESLint does not cure this is in `structure.mjs`.
816
901
  const structure = checkStructure(paths, opts.structure, { cwd });
902
+ // The order, most general first so the most specific wins (ESLint: a later block wins): each
903
+ // paper's venue preset → the root paperlint.json → the paper's own paperlint.json.
904
+ const papers = paperRuleBlocks(paths);
905
+ if (!papers.ok)
906
+ return (err(papers.error), 2);
907
+ const withPapers = {
908
+ ...opts,
909
+ rules: [...papers.value.preset, ...(opts.rules ?? []), ...papers.value.own],
910
+ };
817
911
  let texLanguage = null;
818
912
  try {
819
913
  // @ts-expect-error — the module is .mjs and has no types; a missing LaTeX parser is a normal
@@ -824,10 +918,16 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
824
918
  /* without a LaTeX parser we work over markdown */
825
919
  }
826
920
  const eslint = new ESLint({
827
- cwd: lintRoot(configPath ? dirname(resolve(cwd, configPath)) : cwd, paths),
921
+ cwd: lintRoot(root, paths),
828
922
  overrideConfigFile: true,
829
- overrideConfig: buildConfig(opts, texLanguage),
923
+ overrideConfig: buildConfig(withPapers, texLanguage),
924
+ fix: a.fix,
830
925
  });
926
+ const unowned = await firstUnownedFile(eslint, paths);
927
+ if (unowned !== null) {
928
+ err(`${relative(cwd, unowned) || unowned} is not a file paperlint lints — it lints ${PAPER_FILE_PATTERNS.join(", ")}`);
929
+ return 2;
930
+ }
831
931
  // 🔴 ESLint THROWS on an empty set (`NoFilesFoundError`) — the guard below simply never got
832
932
  // reached, which is what the very first run over an empty directory showed: instead of a clear
833
933
  // message a stack from the depths of eslint-helpers.js flew out. A failure stays a failure, but
@@ -838,13 +938,14 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
838
938
  results = await eslint.lintFiles(paths);
839
939
  }
840
940
  catch (e) {
841
- const fail = e;
842
- if (fail?.messageTemplate === "file-not-found" ||
843
- /No files matching/i.test(fail?.message ?? ""))
941
+ if (isEmptySet(e))
844
942
  results = [];
845
943
  else
846
944
  throw e;
847
945
  }
946
+ // `--fix` writes what the rules fixed; the report below is what is LEFT.
947
+ if (a.fix)
948
+ await ESLint.outputFixes(results);
848
949
  // 🔴 THE GUARD AGAINST A GREEN ZERO, the same one as in action.yml and for the same reason:
849
950
  // ESLint exits zero when there are no findings, and "no findings" is byte-for-byte
850
951
  // indistinguishable from "not a single rule got a single file". A rule whose glob did not match
@@ -857,10 +958,35 @@ export async function run(argv, { log = console.log, err = console.error, cwd =
857
958
  a,
858
959
  log,
859
960
  err,
860
- where: relative(cwd, dirname(resolve(cwd, configPath ?? "."))) || ".",
861
- opts,
961
+ where: relative(cwd, root) || ".",
962
+ opts: withPapers,
862
963
  });
863
964
  }
965
+ /**
966
+ * The first path that is a FILE paperlint does not lint, or null. ESLint would answer such a file
967
+ * with a warning result ("File ignored because of a matching ignore pattern"), which counts as a
968
+ * linted file and turns a run over nothing into a green one. The question is asked of ESLint's
969
+ * own matcher, against the same scope that decides what a directory yields.
970
+ */
971
+ async function firstUnownedFile(eslint, paths) {
972
+ for (const p of paths)
973
+ if (existsSync(p) &&
974
+ statSync(p).isFile() &&
975
+ (await eslint.isPathIgnored(p)))
976
+ return p;
977
+ return null;
978
+ }
979
+ /**
980
+ * ESLint's refusals of an empty set: nothing matched (`file-not-found`), or everything that matched
981
+ * is outside paperlint's scope (`all-matched-files-ignored` — a papers directory holding only
982
+ * vendored scripts). Both mean "nothing was linted", which the caller reports itself.
983
+ */
984
+ const isEmptySet = (e) => {
985
+ const fail = e;
986
+ return (fail?.messageTemplate === "file-not-found" ||
987
+ fail?.messageTemplate === "all-matched-files-ignored" ||
988
+ /No files matching/i.test(fail?.message ?? ""));
989
+ };
864
990
  /**
865
991
  * The end of `paperlint lint`: refuse an optional rule that reached no paper, print the findings, and
866
992
  * decide the exit code. Pulled out of `run` so each question has its own function.