paperlint 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/.github/workflows/ci.yml +4 -4
  2. package/CLAUDE.md +3 -3
  3. package/CONTRIBUTING.md +21 -0
  4. package/LICENSE +1 -1
  5. package/README.md +81 -24
  6. package/action.harness.mjs +10 -10
  7. package/action.mutations.mjs +3 -3
  8. package/action.yml +11 -11
  9. package/bin/{rpp.mjs → paperlint.mjs} +2 -2
  10. package/dist/adapters/banal/invocation.js +3 -3
  11. package/dist/adapters/banal/invocation.js.map +1 -1
  12. package/dist/adapters/banal/run.d.ts +1 -1
  13. package/dist/adapters/banal/run.js +1 -1
  14. package/dist/adapters/banal/run.js.map +1 -1
  15. package/dist/adapters/banal/settings.d.ts +4 -4
  16. package/dist/adapters/banal/settings.d.ts.map +1 -1
  17. package/dist/adapters/banal/settings.js +4 -4
  18. package/dist/adapters/banal/settings.js.map +1 -1
  19. package/dist/adapters/banal/xml.js +1 -1
  20. package/dist/adapters/banal/xml.js.map +1 -1
  21. package/dist/adapters/curl/download.io.d.ts.map +1 -1
  22. package/dist/adapters/curl/download.io.js +2 -2
  23. package/dist/adapters/curl/download.io.js.map +1 -1
  24. package/dist/build-engine.d.ts +1 -1
  25. package/dist/build-engine.d.ts.map +1 -1
  26. package/dist/build-engine.js +6 -3
  27. package/dist/build-engine.js.map +1 -1
  28. package/dist/build.d.ts +1 -1
  29. package/dist/build.d.ts.map +1 -1
  30. package/dist/build.js +5 -4
  31. package/dist/build.js.map +1 -1
  32. package/dist/cli.d.ts +29 -30
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +174 -98
  35. package/dist/cli.js.map +1 -1
  36. package/dist/doctor.d.ts.map +1 -1
  37. package/dist/doctor.js +21 -6
  38. package/dist/doctor.js.map +1 -1
  39. package/dist/engine.d.ts +2 -2
  40. package/dist/engine.d.ts.map +1 -1
  41. package/dist/engine.js +1 -1
  42. package/dist/facts-file.d.ts +33 -9
  43. package/dist/facts-file.d.ts.map +1 -1
  44. package/dist/facts-file.js +117 -17
  45. package/dist/facts-file.js.map +1 -1
  46. package/dist/hooks-settings.d.ts +0 -18
  47. package/dist/hooks-settings.d.ts.map +1 -1
  48. package/dist/hooks-settings.js +70 -42
  49. package/dist/hooks-settings.js.map +1 -1
  50. package/dist/init.d.ts +13 -10
  51. package/dist/init.d.ts.map +1 -1
  52. package/dist/init.js +74 -42
  53. package/dist/init.js.map +1 -1
  54. package/dist/link-skills.js +2 -2
  55. package/dist/link-skills.js.map +1 -1
  56. package/dist/new-paper.d.ts +2 -2
  57. package/dist/new-paper.d.ts.map +1 -1
  58. package/dist/new-paper.js +17 -2
  59. package/dist/new-paper.js.map +1 -1
  60. package/dist/paper-settings.d.ts +68 -0
  61. package/dist/paper-settings.d.ts.map +1 -0
  62. package/dist/paper-settings.js +144 -0
  63. package/dist/paper-settings.js.map +1 -0
  64. package/dist/presets.d.ts +84 -0
  65. package/dist/presets.d.ts.map +1 -0
  66. package/dist/presets.js +186 -0
  67. package/dist/presets.js.map +1 -0
  68. package/dist/rules-config.d.ts +8 -1
  69. package/dist/rules-config.d.ts.map +1 -1
  70. package/dist/rules-config.js +13 -8
  71. package/dist/rules-config.js.map +1 -1
  72. package/dist/structure.d.ts.map +1 -1
  73. package/dist/structure.js +2 -1
  74. package/dist/structure.js.map +1 -1
  75. package/dist/tex-requirements.d.ts +63 -7
  76. package/dist/tex-requirements.d.ts.map +1 -1
  77. package/dist/tex-requirements.js +91 -24
  78. package/dist/tex-requirements.js.map +1 -1
  79. package/dist/toolchain.d.ts +2 -2
  80. package/dist/toolchain.d.ts.map +1 -1
  81. package/dist/toolchain.js +8 -8
  82. package/dist/toolchain.js.map +1 -1
  83. package/dist/types.d.ts +4 -4
  84. package/dist/types.d.ts.map +1 -1
  85. package/dist/venue-rules.d.ts +109 -0
  86. package/dist/venue-rules.d.ts.map +1 -0
  87. package/dist/venue-rules.js +387 -0
  88. package/dist/venue-rules.js.map +1 -0
  89. package/docs/configuration.md +73 -17
  90. package/docs/e2e.md +3 -4
  91. package/docs/install.md +11 -3
  92. package/docs/optional-rules.md +26 -25
  93. package/docs/rules.md +132 -3
  94. package/docs/toolchain.md +5 -5
  95. package/eslint-rules/paper-typography.mjs +1 -1
  96. package/eslint-rules/paper-typography.mutations.mjs +1 -1
  97. package/eslint-rules/papers.mjs +6 -2
  98. package/eslint-rules/pdf-last-page-balance.harness.mjs +5 -3
  99. package/eslint-rules/pdf-last-page-balance.mjs +23 -2
  100. package/fixtures/build-e2e/acmart/paperlint.json +1 -0
  101. package/fixtures/build-e2e/guards/paper.tex +1 -1
  102. package/fixtures/pdf-facts/README.md +1 -1
  103. package/fixtures/real-markdown-paper/baseline.json +1 -1
  104. package/fixtures/real-markdown-paper/baseline.mjs +2 -2
  105. package/fixtures/toolchain-mirror/install-tl +2 -2
  106. package/fixtures/toolchain-mirror/stub-pdflatex +1 -1
  107. package/fixtures/toolchain-mirror/stub-tlmgr +1 -1
  108. package/hooks/hooks.harness.mjs +1 -1
  109. package/lib/paper-config.d.mts +7 -0
  110. package/lib/paper-config.harness.mjs +3 -3
  111. package/lib/paper-config.mjs +34 -3
  112. package/lib/skill-trigger-cases.harness.mjs +1 -1
  113. package/package.json +4 -4
  114. package/plugin/hooks/hooks.json +3 -3
  115. package/scripts/check.mjs +1 -1
  116. package/scripts/harness-api.frozen.json +1 -1
  117. package/scripts/harness-api.test.ts +2 -2
  118. package/scripts/layer-legacy.frozen.json +1 -1
  119. package/scripts/mutation-batteries-frozen.harness.mjs +1 -1
  120. package/scripts/mutation-batteries.frozen.json +2 -2
  121. package/scripts/release-config.test.ts +1 -1
  122. package/skills/find-venue/SKILL.md +5 -1
  123. package/skills/find-venue/SKILL.md.spec.ts +4 -0
  124. package/skills/paper-pipeline/PIPELINE-MAP.md +14 -4
  125. package/skills/paper-pipeline/SKILL.md +4 -3
  126. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -2
  127. package/skills/paper-pipeline/description-language.eval.mjs +1 -1
  128. package/skills/paper-pipeline/framing-vs-vocabulary.eval.mjs +1 -1
  129. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +2 -2
  130. package/skills/paper-pipeline/pipeline-firing.eval.mjs +1 -1
  131. package/skills/paper-pipeline/pipeline-language.eval.mjs +1 -1
  132. package/skills/paper-pipeline/scripts/consumer.d.mts +1 -0
  133. package/skills/paper-pipeline/scripts/consumer.harness.mjs +10 -4
  134. package/skills/paper-pipeline/scripts/consumer.mjs +8 -1
  135. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +1 -1
  136. package/skills/render-paper/SKILL.md +3 -3
  137. package/skills/render-paper/SKILL.md.spec.ts +2 -2
  138. package/skills/render-paper/extract-pdf-facts.harness.mjs +6 -6
  139. package/skills/render-paper/extract-pdf-facts.mjs +4 -4
  140. package/skills/study-accepted-papers/SKILL.md +2 -1
  141. package/skills/study-accepted-papers/SKILL.md.spec.ts +1 -0
  142. package/skills/submit-paper/SKILL.md +9 -1
  143. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  144. package/skills/submit-paper/references/venues/acm-sigconf.jsonc +86 -0
  145. package/skills/submit-paper/references/venues/agenticdev.jsonc +34 -102
  146. package/skills/submit-paper/references/venues/aisec.jsonc +21 -82
  147. package/skills/submit-paper/references/venues/realm.jsonc +45 -43
  148. package/skills/submit-paper/references/venues/tex-base.jsonc +1 -1
  149. package/skills/submit-paper/references/venues/venue-profile.schema.json +120 -30
  150. package/src/adapters/banal/index.test.ts +1 -1
  151. package/src/adapters/banal/invocation.test.ts +3 -3
  152. package/src/adapters/banal/invocation.ts +3 -3
  153. package/src/adapters/banal/locate.test.ts +4 -4
  154. package/src/adapters/banal/run.ts +2 -2
  155. package/src/adapters/banal/settings.test.ts +12 -6
  156. package/src/adapters/banal/settings.ts +9 -5
  157. package/src/adapters/banal/xml.test.ts +1 -1
  158. package/src/adapters/banal/xml.ts +1 -1
  159. package/src/adapters/curl/download.io.ts +4 -2
  160. package/src/adapters/curl/download.test.ts +3 -1
  161. package/src/adapters/node/files.test.ts +1 -1
  162. package/src/adapters/node/process.test.ts +2 -2
  163. package/src/adapters/node/workspace.test.ts +4 -4
  164. package/src/build-engine.harness.mjs +2 -2
  165. package/src/build-engine.ts +11 -3
  166. package/src/build.harness.mjs +27 -22
  167. package/src/build.mutations.mjs +3 -2
  168. package/src/build.ts +6 -5
  169. package/src/cli.harness.mjs +72 -131
  170. package/src/cli.mutations.mjs +7 -32
  171. package/src/cli.ts +204 -121
  172. package/src/doctor.harness.mjs +9 -25
  173. package/src/doctor.ts +28 -5
  174. package/src/engine.harness.mjs +4 -2
  175. package/src/engine.ts +2 -2
  176. package/src/facts-file.test.ts +54 -7
  177. package/src/facts-file.ts +145 -24
  178. package/src/hooks-settings.harness.mjs +24 -15
  179. package/src/hooks-settings.mutations.mjs +2 -4
  180. package/src/hooks-settings.ts +92 -49
  181. package/src/init.ts +88 -52
  182. package/src/latex-log.harness.mjs +1 -1
  183. package/src/link-skills.harness.mjs +3 -1
  184. package/src/link-skills.mutations.mjs +1 -1
  185. package/src/link-skills.ts +2 -2
  186. package/src/new-paper.harness.mjs +10 -7
  187. package/src/new-paper.test.ts +145 -0
  188. package/src/new-paper.ts +22 -2
  189. package/src/paper-settings-commands.test.ts +311 -0
  190. package/src/paper-settings.test.ts +220 -0
  191. package/src/paper-settings.ts +206 -0
  192. package/src/pdf-facts.harness.mjs +1 -1
  193. package/src/presets.test.ts +236 -0
  194. package/src/presets.ts +307 -0
  195. package/src/rules-config.ts +14 -9
  196. package/src/structure.harness.mjs +4 -4
  197. package/src/structure.ts +2 -1
  198. package/src/tex-requirements.harness.mjs +12 -18
  199. package/src/tex-requirements.ts +179 -29
  200. package/src/toolchain.harness.mjs +34 -25
  201. package/src/toolchain.ts +14 -8
  202. package/src/types.ts +4 -4
  203. package/src/venue-rules.test.ts +545 -0
  204. package/src/venue-rules.ts +573 -0
  205. package/templates/paper/paperlint.json +4 -0
  206. package/fixtures/build-e2e/acmart/venue.json +0 -1
  207. package/plugin/.claude-plugin/plugin.json +0 -8
package/src/cli.ts CHANGED
@@ -10,13 +10,13 @@
10
10
  *
11
11
  * Two entry points into the tool, and both are whole now:
12
12
  * npx paperlint lint ← here
13
- * uses: zernie/research-paper-pipeline@<sha> ← action.yml
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
16
  * The typography debt, the marker of the author-list check run, the field dictionary — all of that
17
17
  * is about ONE corpus, and wiring it into the package would repeat the defect that put the path
18
18
  * `.claude/skills/verify-citations/...` into a rule's message. So they live in the consumer's
19
- * `rpp.json`.
19
+ * `package.json`, under the `paperlint` key.
20
20
  *
21
21
  * 🔴 WHY THE COMMAND IS CALLED `lint` AND NOT `check`. It does exactly what everyone else calls by
22
22
  * that word: reads files, changes nothing, prints findings, exits non-zero. `check` is taken in the
@@ -33,30 +33,34 @@ import { fileURLToPath } from "node:url";
33
33
  import { join, dirname, resolve, relative, basename, sep } from "node:path";
34
34
  import markdown from "@eslint/markdown";
35
35
  // 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";
36
+ import {
37
+ isMain,
38
+ packageVenuesDir,
39
+ } from "../skills/paper-pipeline/scripts/consumer.mjs";
37
40
  export { isMain };
38
- import type { Args, RppConfig, ConfigRead } from "./types.ts";
41
+ import type { Args, PaperlintConfig, ConfigRead } from "./types.ts";
39
42
  import {
40
43
  checkStructure,
41
44
  formatStructure,
42
45
  asEslintResults,
43
46
  } from "./structure.ts";
44
- import {
45
- buildPapers,
46
- papersIn,
47
- anyFailed,
48
- remedyFor,
49
- readFacts,
50
- MAIN,
51
- } from "./build.ts";
47
+ import { buildPapers, papersIn, anyFailed, remedyFor, MAIN } from "./build.ts";
52
48
  import { prepareEngine } from "./build-engine.ts";
53
49
  import { runToolchain } from "./toolchain.ts";
54
50
  import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
55
51
  import { curlDownload } from "./adapters/curl/index.ts";
56
- import { hostDirs, nodeAdapters } from "./adapters/node/index.ts";
52
+ import { hostDirs, nodeAdapters, nodeFiles } from "./adapters/node/index.ts";
53
+ import { VENUE_RULE_LEVELS, venueRules } from "./venue-rules.ts";
54
+ import { paperRules } from "./paper-settings.ts";
55
+ import {
56
+ paperPreset,
57
+ paperPresetProblem,
58
+ type PaperPreset,
59
+ } from "./presets.ts";
57
60
  import type { ToolInstaller } from "./ports/tool-installer.ts";
58
61
  import {
59
62
  mergeRequirements,
63
+ declaredUnion,
60
64
  requirementsFor,
61
65
  NO_REQUIREMENTS,
62
66
  type TexRequirements,
@@ -84,9 +88,11 @@ import {
84
88
  } from "../lib/paper-config.mjs";
85
89
  import {
86
90
  parseRuleBlocks,
91
+ parseRuleEntries,
87
92
  shippedRuleIds,
88
93
  unknownKeys,
89
94
  type Parsed,
95
+ type RuleBlock,
90
96
  } from "./rules-config.ts";
91
97
  export { init };
92
98
  export { nextSteps } from "./init.ts";
@@ -122,8 +128,8 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
122
128
  Compiles with paperlint's TeX Live, else one on PATH that has every
123
129
  package the venue declares; on a terminal it offers to install
124
130
  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
126
- into ~/.cache/rpp/texlive (RPP_TEXLIVE_DIR overrides); a second
131
+ npx paperlint toolchain [--check] install TeX Live with every package the venue presets declare
132
+ into ~/.cache/paperlint/texlive (PAPERLINT_TEXLIVE_DIR overrides); a second
127
133
  run does nothing. --check: report what is missing, change nothing
128
134
  npx paperlint doctor say what is actually wired — and what only LOOKS wired
129
135
  npx paperlint hook <name> run an editor hook (.claude/settings.json calls this)
@@ -149,8 +155,8 @@ lint:
149
155
  most findings here are advisory and a gate that fails on advice gets muted
150
156
 
151
157
  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. \`rpp.json\` is still
153
- read as a deprecated fallback and the run says so. \`papersDir\` is required; the rest is optional:
158
+ current directory, the way every other tool in the stack finds its config. \`--config\` names
159
+ another file of the same shape. \`papersDir\` is required; the rest is optional:
154
160
 
155
161
  "paperlint": {
156
162
  "papersDir": "papers",
@@ -164,14 +170,26 @@ read as a deprecated fallback and the run says so. \`papersDir\` is required; th
164
170
  "rules": { "pdf/last-page-balance": "error" } } ]
165
171
  }
166
172
 
167
- "rules" takes ESLint flat-config blocks (files, ignores, rules), appended after rpp's own, with
173
+ "rules" takes ESLint flat-config blocks (files, ignores, rules), appended after paperlint's own, with
168
174
  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.
175
+ pdf/last-page-balance. The venue rules (pdf/fresh, pdf/profile, pdf/fonts, pdf/geometry,
176
+ pdf/limits, pdf/body-size, pdf/measured) are on for every paper whose paperlint.json names a venue;
177
+ set one to "off" there to skip it.
178
+
179
+ per paper — <paper>/paperlint.json (it was venue.json before 2.1.0; \`npx paperlint init\` moves it):
180
+
181
+ { "extends": "paperlint:aisec", "kind": "research", "rules": { "pdf/last-page-balance": "error" } }
182
+
183
+ "extends" names a venue preset: paperlint:<name> (shipped: acm-sigconf, agenticdev, aisec, realm)
184
+ or ./path.jsonc, relative to the paperlint.json. npm presets are not supported yet.
185
+
186
+ "rules" there applies to that paper alone, after its preset's rules and before the project's. An unknown key,
187
+ anywhere in the settings or in a paperlint.json, is an error.
170
188
  `;
171
189
 
172
190
  /** The config the user would otherwise write by hand. The data comes from `opts`, the mechanism is here. */
173
191
  export function buildConfig(
174
- opts: RppConfig = {},
192
+ opts: PaperlintConfig = {},
175
193
  texLanguage: unknown,
176
194
  ): unknown[] {
177
195
  const paperRules = { ...researchQuestion.rules, ...typography.rules };
@@ -188,11 +206,21 @@ export function buildConfig(
188
206
  // default (only `node_modules/` and `.git/`), so without this block `paperlint lint` would lint the
189
207
  // template as a paper — and a richer template with placeholder stages would fail the run.
190
208
  { 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 } },
209
+ // The `pdf` plugin is registered for EVERY file. A consumer's block (`rules`, appended below)
210
+ // may name its rules for a glob that also matches markdown files; with the plugin defined only
211
+ // beside `paper.tex`, ESLint would refuse those files with "could not find plugin". Every rule
212
+ // in it acts on `paper.tex` only. `last-page-balance` is on for no file (optional); the venue
213
+ // rules are on for every `paper.tex`, in the block below.
214
+ {
215
+ plugins: {
216
+ pdf: {
217
+ rules: {
218
+ ...pdfRules.rules,
219
+ ...venueRules({ files: nodeFiles, venuesDir: packageVenuesDir() }),
220
+ },
221
+ },
222
+ },
223
+ },
196
224
  {
197
225
  files: ["**/PIPELINE-STATUS.md"],
198
226
  plugins: { markdown, paper: paperStages },
@@ -263,6 +291,8 @@ export function buildConfig(
263
291
  "paper/typography": typographyOpt,
264
292
  "tex/future-promise": "warn",
265
293
  "tex/acm-frontmatter-override": "error",
294
+ // Silent for a paper whose paperlint.json names no venue (src/venue-rules.ts).
295
+ ...VENUE_RULE_LEVELS,
266
296
  },
267
297
  });
268
298
  // The consumer's own blocks, LAST, so a later block wins — ESLint's rule. Parsed by
@@ -272,8 +302,8 @@ export function buildConfig(
272
302
  }
273
303
 
274
304
  /**
275
- * The rule ids a consumer may name in `rules`: every rule rpp's own config defines, read off that
276
- * config rather than listed again. `@eslint/markdown` is a dependency's plugin, not rpp's.
305
+ * The rule ids a consumer may name in `rules`: every rule paperlint's own config defines, read off that
306
+ * config rather than listed again. `@eslint/markdown` is a dependency's plugin, not paperlint's.
277
307
  */
278
308
  export const SHIPPED_RULES: ReadonlySet<string> = shippedRuleIds(
279
309
  buildConfig({}, { sentinel: "tex language" }),
@@ -288,7 +318,7 @@ const isOn = (entry: unknown): boolean => {
288
318
 
289
319
  /**
290
320
  * Rules paperlint ships and turns on for no file itself — the ones a consumer opts into with `rules`.
291
- * Derived: every shipped rule that no block of rpp's own config names.
321
+ * Derived: every shipped rule that no block of paperlint's own config names.
292
322
  */
293
323
  export const OPTIONAL_RULES: ReadonlySet<string> = new Set(
294
324
  [...SHIPPED_RULES].filter(
@@ -309,7 +339,7 @@ export const OPTIONAL_RULES: ReadonlySet<string> = new Set(
309
339
  export async function silentOptionalRules(
310
340
  eslint: ESLint,
311
341
  lintedFiles: readonly string[],
312
- opts: RppConfig,
342
+ opts: PaperlintConfig,
313
343
  ): Promise<string[]> {
314
344
  const turnedOn = new Set(
315
345
  (opts.rules ?? []).flatMap((b) =>
@@ -328,15 +358,75 @@ export async function silentOptionalRules(
328
358
  return [...turnedOn].filter((id) => !reached.has(id));
329
359
  }
330
360
 
361
+ /**
362
+ * Every linted paper's `rules` from its `paperlint.json`, as ESLint blocks scoped to that paper.
363
+ * A file that does not parse, or names a rule paperlint does not ship, stops the run with one line
364
+ * naming the file — the same strictness as the project's own `rules`. A leftover `venue.json` is
365
+ * not read here; `pdf/profile` and `paperlint doctor` name it.
366
+ */
367
+ export function paperRuleBlocks(paths: readonly string[]): Parsed<RuleBlock[]> {
368
+ const papers = [...new Set(paths.flatMap((p) => [p, ...papersIn(p)]))];
369
+ const out: RuleBlock[] = [];
370
+ for (const dir of papers) {
371
+ const p = paperPreset(dir, PRESET_DEPS);
372
+ if (p.kind === "settings-problem" && p.problem.kind === "broken")
373
+ return { ok: false, error: paperPresetProblem(dir, p) ?? dir };
374
+ const block = rulesOfPaper(dir, p);
375
+ if (!block.ok) return block;
376
+ if (block.value) out.push(block.value);
377
+ }
378
+ return { ok: true, value: out };
379
+ }
380
+
381
+ /**
382
+ * One paper's block: its preset chain's `rules`, then its own — later wins per rule id. A preset
383
+ * that does not resolve contributes nothing here; `pdf/profile` reports it on the paper.
384
+ */
385
+ function rulesOfPaper(dir: string, p: PaperPreset): Parsed<RuleBlock | null> {
386
+ const settings = "settings" in p ? p.settings : null;
387
+ if (settings === null) return { ok: true, value: null };
388
+ const own = paperRules(dir, settings, SHIPPED_RULES);
389
+ if (!own.ok) return own;
390
+ const fromPreset =
391
+ p.kind === "resolved"
392
+ ? parseRuleEntries(
393
+ p.preset.rules,
394
+ `the venue preset ${p.preset.chain.join(" → ")} → "rules"`,
395
+ SHIPPED_RULES,
396
+ )
397
+ : { ok: true as const, value: {} };
398
+ if (!fromPreset.ok) return fromPreset;
399
+ const rules = { ...fromPreset.value, ...(own.value ?? {}) };
400
+ return {
401
+ ok: true,
402
+ value: Object.keys(rules).length
403
+ ? { basePath: dir, files: PAPER_FILE_PATTERNS, rules }
404
+ : null,
405
+ };
406
+ }
407
+
408
+ /**
409
+ * The files a paper's block may reach: exactly the ones paperlint's own blocks lint, read off its
410
+ * config. A wider glob (everything under the paper) would make ESLint lint files no block gives a
411
+ * language — `paperlint.json` itself would be parsed as JavaScript.
412
+ */
413
+ const PAPER_FILE_PATTERNS: string[] = [
414
+ ...new Set(
415
+ buildConfig({}, { sentinel: "tex language" }).flatMap(
416
+ (b) => (b as { files?: string[] }).files ?? [],
417
+ ),
418
+ ),
419
+ ];
420
+
331
421
  /**
332
422
  * The settings after the boundary: an unknown key is refused by name, and `rules` becomes parsed
333
423
  * config blocks. Nothing after this sees the raw object.
334
424
  */
335
425
  export function parseSettings(
336
- opts: RppConfig,
426
+ opts: PaperlintConfig,
337
427
  where: string,
338
428
  baseDir: string,
339
- ): Parsed<RppConfig> {
429
+ ): Parsed<PaperlintConfig> {
340
430
  const raw = opts as Record<string, unknown>;
341
431
  const unknown = unknownKeys(raw);
342
432
  if (unknown.length > 0)
@@ -431,7 +521,6 @@ export function parseArgs(argv: readonly string[]): Args {
431
521
  return out;
432
522
  }
433
523
 
434
- export const CONFIG_NAME = "rpp.json";
435
524
  export const PKG_NAME = "package.json";
436
525
 
437
526
  /**
@@ -450,36 +539,20 @@ function ownVersion(): string | undefined {
450
539
  }
451
540
  }
452
541
 
453
- /** Where the consumer's settings were found, and in which of the two carriers. */
454
- export interface Declaration {
455
- readonly path: string;
456
- readonly kind: "package.json" | "rpp.json";
457
- }
458
-
459
542
  /**
460
- * 🔴 THE CLI HAD TO LEARN TO READ `package.json`, AND THAT IS NOT A SIDE ERRAND. `paperlint init` now
461
- * writes ONE declaration, into the `package.json` key that the three hooks and `eslint-rules`
462
- * already read. Without this walker the install it produces would not work at all: `paperlint lint`
463
- * would find no `rpp.json`, report "nothing to lint", and the consumer would be back to
464
- * declaring the same directory twice — the defect the single declaration removes (issue #33,
543
+ * 🔴 THE CLI READS `package.json`, THE ONE DECLARATION. `paperlint init` writes the settings under
544
+ * the `package.json` key that the three hooks and `eslint-rules` already read; the CLI reads the
545
+ * same file, so the install and the check cannot look at different files (issue #33,
465
546
  * `docs/install.md`).
466
547
  *
467
- * `rpp.json` stays readable as a DEPRECATED fallback, and the read says so out loud. Silently
468
- * dropping a file this command used to write would break working setups on upgrade.
469
- *
470
548
  * The walk goes up to the filesystem root, the way eslint, prettier and tsc find theirs, so a run
471
- * from inside one paper sees the same settings as a run from the repository root. At each level
472
- * `package.json` wins over `rpp.json`: it is the carrier every other reader uses, so preferring
473
- * it is what keeps "one declaration" true rather than merely intended.
549
+ * from inside one paper sees the same settings as a run from the repository root.
474
550
  */
475
- export function findDeclaration(startDir: string): Declaration | null {
551
+ export function findConfig(startDir: string): string | null {
476
552
  let dir = resolve(startDir);
477
553
  for (;;) {
478
554
  const pkg = join(dir, PKG_NAME);
479
- if (existsSync(pkg) && declaresSettings(pkg))
480
- return { path: pkg, kind: "package.json" };
481
- const rpp = join(dir, CONFIG_NAME);
482
- if (existsSync(rpp)) return { path: rpp, kind: "rpp.json" };
555
+ if (existsSync(pkg) && declaresSettings(pkg)) return pkg;
483
556
  const up = dirname(dir);
484
557
  if (up === dir) return null;
485
558
  dir = up;
@@ -500,11 +573,6 @@ const declaresSettings = (pkgPath: string): boolean => {
500
573
  }
501
574
  };
502
575
 
503
- /** Kept as the one-line question "which file holds the settings" — callers that only need a path. */
504
- export function findConfig(startDir: string): string | null {
505
- return findDeclaration(startDir)?.path ?? null;
506
- }
507
-
508
576
  /**
509
577
  * Reading the config, ONE reader for all commands. Pulled out of `run()` the moment a second
510
578
  * command needed the same config (`build`): two copies of this block would have drifted apart on
@@ -525,24 +593,17 @@ export function readConfig(
525
593
  } = {},
526
594
  ): ConfigRead {
527
595
  // 🔴 THE CONFIG FINDS ITSELF. An explicit `--config` beats the discovered one — it was named out
528
- // loud, and a substitution is never silent. For an explicit path the FILE NAME decides the
529
- // carrier: the path here is a value, not a text to make guesses about, and `package.json` holds
530
- // the settings under a key.
531
- const decl: Declaration | null = a.config
532
- ? {
533
- path: a.config,
534
- kind: basename(a.config) === PKG_NAME ? "package.json" : "rpp.json",
535
- }
536
- : findDeclaration(cwd);
537
- const configPath = decl?.path ?? null;
596
+ // loud, and a substitution is never silent. Either way the file has the shape of a
597
+ // `package.json`: the settings sit under the key.
598
+ const configPath = a.config ?? findConfig(cwd);
538
599
  if (a.config && !existsSync(a.config)) {
539
600
  err(`config file not found: ${a.config}`);
540
601
  return { code: 2 };
541
602
  }
542
603
 
543
- let opts: RppConfig = {};
604
+ let opts: PaperlintConfig = {};
544
605
  let legacyKey = false;
545
- if (decl && configPath) {
606
+ if (configPath) {
546
607
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- #49: replace with a real type
547
608
  let parsed: any;
548
609
  try {
@@ -551,15 +612,13 @@ export function readConfig(
551
612
  err(`${configPath} is not valid JSON: ${(e as Error).message}`);
552
613
  return { code: 2 };
553
614
  }
554
- if (decl.kind === "package.json") {
555
- const d = declaredSettings(parsed);
556
- if (d.conflict !== null) {
557
- err(d.conflict);
558
- return { code: 2 };
559
- }
560
- opts = (d.settings ?? {}) as RppConfig;
561
- legacyKey = d.legacy;
562
- } else opts = parsed;
615
+ const d = declaredSettings(parsed);
616
+ if (d.conflict !== null) {
617
+ err(d.conflict);
618
+ return { code: 2 };
619
+ }
620
+ opts = (d.settings ?? {}) as PaperlintConfig;
621
+ legacyKey = d.legacy;
563
622
  // The discovered config is NAMED out loud. Otherwise a run from someone else's directory picks
564
623
  // up someone else's file and does not say so — and a typography-debt mismatch looks like a finding.
565
624
  //
@@ -568,30 +627,19 @@ export function readConfig(
568
627
  // test but by an attempt to wire our own action to this output; in the harness I first WORKED
569
628
  // AROUND this line (stripped the first line before JSON.parse) — that is, the workaround hid
570
629
  // the defect exactly where it should have been shouting.
571
- (a.json ? err : log)(`config: ${relative(cwd, configPath) || CONFIG_NAME}`);
572
- // 🔴 THE DEPRECATED CARRIER IS NAMED OUT LOUD, IT DOES NOT STOP BEING READ. The hooks read ONLY
573
- // package.json, so a consumer whose settings stayed in rpp.json lints one directory and guards
574
- // another — and both states look equally green.
575
- if (decl.kind === "rpp.json")
576
- (a.json ? err : log)(
577
- ` ⚠ ${CONFIG_NAME} is deprecated — move these keys under "${CONFIG_KEY}" in ${PKG_NAME}; ` +
578
- `the hooks read only that file. \`npx paperlint init\` does it for you.`,
579
- );
630
+ (a.json ? err : log)(`config: ${relative(cwd, configPath) || PKG_NAME}`);
580
631
  if (legacyKey) (a.json ? err : log)(` ⚠ ${LEGACY_KEY_MESSAGE}`);
581
632
  }
582
633
 
583
634
  // The old field name is refused before anything else is read from the settings: falling back
584
635
  // to it would keep it working forever, and this package has no released users to migrate.
585
- const where =
586
- decl?.kind === "package.json"
587
- ? `${PKG_NAME} → "${legacyKey ? LEGACY_CONFIG_KEY : CONFIG_KEY}"`
588
- : (decl?.path ?? CONFIG_NAME);
589
- const renamed = decl ? renamedFieldMessage(opts, where) : null;
636
+ const where = `${configPath ? basename(configPath) : PKG_NAME} → "${legacyKey ? LEGACY_CONFIG_KEY : CONFIG_KEY}"`;
637
+ const renamed = configPath ? renamedFieldMessage(opts, where) : null;
590
638
  if (renamed) {
591
639
  err(renamed);
592
640
  return { code: 2 };
593
641
  }
594
- if (decl && configPath) {
642
+ if (configPath) {
595
643
  const parsed = parseSettings(
596
644
  opts,
597
645
  where,
@@ -607,15 +655,11 @@ export function readConfig(
607
655
  // 🔴 THE PAPERS DIRECTORY IS A REQUIRED FIELD. The papers directory is the one thing without which the tool
608
656
  // does not know what it works on, and the one thing that cannot be guessed: a default of "." runs
609
657
  // the rules over the whole checkout and exits green over a scope nobody chose.
610
- if (decl && !hasPapers(opts)) {
658
+ if (configPath && !hasPapers(opts)) {
611
659
  err(
612
- decl.kind === "package.json"
613
- ? `${decl.path} must declare \`${PAPERS_DIR_FIELD}\` — the directory your papers live in, e.g.\n` +
614
- ` { "${CONFIG_KEY}": { "${PAPERS_DIR_FIELD}": "papers" } }\n` +
615
- `It is the one thing this tool cannot guess. \`npx paperlint init\` writes it for you.`
616
- : `${decl.path} must declare \`${PAPERS_DIR_FIELD}\` — the directory your papers live in, e.g.\n` +
617
- ` { "${PAPERS_DIR_FIELD}": "papers" }\n` +
618
- `It is the one thing this tool cannot guess.`,
660
+ `${configPath} must declare \`${PAPERS_DIR_FIELD}\` — the directory your papers live in, e.g.\n` +
661
+ ` { "${CONFIG_KEY}": { "${PAPERS_DIR_FIELD}": "papers" } }\n` +
662
+ `It is the one thing this tool cannot guess. \`npx paperlint init\` writes it for you.`,
619
663
  );
620
664
  return { code: 2 };
621
665
  }
@@ -623,7 +667,7 @@ export function readConfig(
623
667
  }
624
668
 
625
669
  /** The papers directory field of the settings, read by its one declared name. */
626
- export function papersDirOf(opts: RppConfig): unknown {
670
+ export function papersDirOf(opts: PaperlintConfig): unknown {
627
671
  return (opts as Record<string, unknown>)[PAPERS_DIR_FIELD];
628
672
  }
629
673
 
@@ -635,7 +679,7 @@ export function toPaths(papers: unknown): string[] {
635
679
  return [];
636
680
  }
637
681
 
638
- const hasPapers = (opts: RppConfig): boolean =>
682
+ const hasPapers = (opts: PaperlintConfig): boolean =>
639
683
  toPaths(papersDirOf(opts)).length > 0;
640
684
 
641
685
  /**
@@ -703,7 +747,7 @@ export function runHook(
703
747
  throw new Error(`resolved vigiles, but no cli.js beside it: ${runtime}`);
704
748
  } catch {
705
749
  err(
706
- `rpp: the hook runtime (vigiles) is not resolvable from ${fileURLToPath(new URL(".", import.meta.url))}.\n` +
750
+ `paperlint: the hook runtime (vigiles) is not resolvable from ${fileURLToPath(new URL(".", import.meta.url))}.\n` +
707
751
  `The \`${name}\` hook is NOT running. Everything else — \`paperlint lint\`, CI — is unaffected.\n` +
708
752
  `Reinstall this package so its dependencies are present.`,
709
753
  );
@@ -864,15 +908,40 @@ async function runBuild(
864
908
  return anyFailed(out.results) ? 1 : 0;
865
909
  }
866
910
 
867
- /** What one paper needs from TeX Live; a venue.json that does not parse is the build's to report. */
911
+ /** The presets' deps, wired to the disk and the package's own venues directory. */
912
+ const PRESET_DEPS = { files: nodeFiles, venuesDir: packageVenuesDir() };
913
+
914
+ /**
915
+ * What one paper needs from TeX Live: the base set plus its preset chain's `tex`. A paper whose
916
+ * settings or preset do not resolve gets the base set; the build reports why at its facts step.
917
+ */
868
918
  function paperRequirements(dir: string): TexRequirements {
869
- let venue: string | null = null;
870
- try {
871
- venue = readFacts(dir).venue;
872
- } catch {
873
- venue = null;
874
- }
875
- return requirementsFor(venue).tex;
919
+ const p = paperPreset(dir, PRESET_DEPS);
920
+ return requirementsFor(p.kind === "resolved" ? p.preset : null).tex;
921
+ }
922
+
923
+ /**
924
+ * What `paperlint toolchain` installs: every shipped preset's packages, plus the resolved chain of
925
+ * every paper under the project's papers directory — a project's own preset lives outside the
926
+ * package, so the shipped union alone would not see it.
927
+ */
928
+ export function toolchainTex(cwd: string): TexRequirements {
929
+ const cfg = readConfig(parseArgs(["toolchain"]), {
930
+ log: () => {},
931
+ err: () => {},
932
+ cwd,
933
+ });
934
+ const roots =
935
+ cfg.code === undefined
936
+ ? toPaths(papersDirOf(cfg.opts)).map((rel) =>
937
+ resolve(dirname(cfg.configPath ?? cwd), rel),
938
+ )
939
+ : [];
940
+ const chains = roots
941
+ .flatMap((r) => papersIn(r))
942
+ .map((dir) => paperPreset(dir, PRESET_DEPS))
943
+ .flatMap((p) => (p.kind === "resolved" ? [p.preset.tex] : []));
944
+ return declaredUnion(undefined, chains).tex;
876
945
  }
877
946
 
878
947
  /**
@@ -914,8 +983,14 @@ const SIMPLE: Readonly<
914
983
  hook: (a, { err }) => runHook(a.paths[0], { err }),
915
984
  new: (a, io) => runNew(a, io),
916
985
  build: (a, io) => runBuild(a, io),
917
- toolchain: (a, { log, err }) =>
918
- runToolchain({ check: a.check, log, err, banal: hostBanalInstaller() }),
986
+ toolchain: (a, { log, err, cwd }) =>
987
+ runToolchain({
988
+ check: a.check,
989
+ log,
990
+ err,
991
+ banal: hostBanalInstaller(),
992
+ tex: toolchainTex(cwd),
993
+ }),
919
994
  };
920
995
 
921
996
  /** banal's installer, wired from this process's environment: the composition root's work. */
@@ -1048,7 +1123,7 @@ export async function run(
1048
1123
  );
1049
1124
  if (paths.length === 0) {
1050
1125
  err(
1051
- `nothing to lint: no path was given and no ${CONFIG_NAME} was found.\n` +
1126
+ `nothing to lint: no path was given and no "${CONFIG_KEY}" key was found in a ${PKG_NAME}.\n` +
1052
1127
  `Run \`npx paperlint init\` here, or pass the directory: \`paperlint lint papers\`.`,
1053
1128
  );
1054
1129
  return 2;
@@ -1059,6 +1134,14 @@ export async function run(
1059
1134
  // a directory without `PIPELINE-STATUS.md` simply gets not a single rule and reports clean. The
1060
1135
  // analysis of why a structure plugin for ESLint does not cure this is in `structure.mjs`.
1061
1136
  const structure = checkStructure(paths, opts.structure, { cwd });
1137
+ // Each paper's own `rules` (its paperlint.json) go after paperlint's blocks and BEFORE the
1138
+ // project's, so the project's package.json still has the last word.
1139
+ const papers = paperRuleBlocks(paths);
1140
+ if (!papers.ok) return (err(papers.error), 2);
1141
+ const withPapers = {
1142
+ ...opts,
1143
+ rules: [...papers.value, ...(opts.rules ?? [])],
1144
+ };
1062
1145
 
1063
1146
  let texLanguage: unknown = null;
1064
1147
  try {
@@ -1072,7 +1155,7 @@ export async function run(
1072
1155
  const eslint = new ESLint({
1073
1156
  cwd: lintRoot(configPath ? dirname(resolve(cwd, configPath)) : cwd, paths),
1074
1157
  overrideConfigFile: true,
1075
- overrideConfig: buildConfig(opts, texLanguage) as Linter.Config[],
1158
+ overrideConfig: buildConfig(withPapers, texLanguage) as Linter.Config[],
1076
1159
  });
1077
1160
 
1078
1161
  // 🔴 ESLint THROWS on an empty set (`NoFilesFoundError`) — the guard below simply never got
@@ -1109,7 +1192,7 @@ export async function run(
1109
1192
  log,
1110
1193
  err,
1111
1194
  where: relative(cwd, dirname(resolve(cwd, configPath ?? "."))) || ".",
1112
- opts,
1195
+ opts: withPapers,
1113
1196
  });
1114
1197
  }
1115
1198
 
@@ -1133,7 +1216,7 @@ async function reportLint(
1133
1216
  log: typeof console.log;
1134
1217
  err: typeof console.error;
1135
1218
  where: string;
1136
- opts: RppConfig;
1219
+ opts: PaperlintConfig;
1137
1220
  },
1138
1221
  ): Promise<number> {
1139
1222
  const silent = await silentOptionalRules(
@@ -1180,7 +1263,7 @@ async function reportLint(
1180
1263
 
1181
1264
  // 🔴 `isMain`, NOT A STRING COMPARISON. The first version wrote
1182
1265
  // if (import.meta.url === `file://${process.argv[1]}`)
1183
- // and the utility, launched via `node_modules/.bin/rpp`, SILENTLY EXITED WITH ZERO: npm puts a
1266
+ // and the utility, launched via `node_modules/.bin/paperlint`, SILENTLY EXITED WITH ZERO: npm puts a
1184
1267
  // SYMLINK there, `process.argv[1]` stays the symlink's path while `import.meta.url` is the real
1185
1268
  // path, and the condition is false. That is, the only way a real consumer launches the utility did
1186
1269
  // not work at all — and it looked like a clean run.
@@ -41,9 +41,9 @@ const check = (label, cond) => {
41
41
  assert.ok(cond, label);
42
42
  };
43
43
 
44
- /** A consumer on disk: a papers directory, declarations in one or both places. */
45
- function consumer({ papersDir, pkgKey, rppJson, makeDir = true }) {
46
- const dir = realpathSync(mkdtempSync(join(tmpdir(), "rpp-doctor-")));
44
+ /** A consumer on disk: a papers directory, and maybe a declaration in package.json. */
45
+ function consumer({ papersDir, pkgKey, makeDir = true }) {
46
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-doctor-")));
47
47
  if (makeDir && papersDir) {
48
48
  mkdirSync(join(dir, papersDir, "some-paper"), { recursive: true });
49
49
  writeFileSync(
@@ -54,11 +54,6 @@ function consumer({ papersDir, pkgKey, rppJson, makeDir = true }) {
54
54
  const pkg = { name: "consumer", version: "1.0.0" };
55
55
  if (pkgKey !== undefined) pkg["paperlint"] = { [PAPERS_DIR_FIELD]: pkgKey };
56
56
  writeFileSync(join(dir, "package.json"), JSON.stringify(pkg, null, 2));
57
- if (rppJson !== undefined)
58
- writeFileSync(
59
- join(dir, "rpp.json"),
60
- JSON.stringify({ [PAPERS_DIR_FIELD]: rppJson }, null, 2),
61
- );
62
57
  return dir;
63
58
  }
64
59
 
@@ -113,13 +108,10 @@ const runDoctor = (
113
108
  }
114
109
 
115
110
  // ── II. THE VERY DEFECT: AN INSTALL FOLLOWING THE DOCS ─────────────────────────────────────
116
- // `paperlint init` writes rpp.json and does not touch package.json; the hook reads package.json.
111
+ // The old `init` wrote its own config file and did not touch package.json; the hook reads package.json.
117
112
  // Measured 09-18.
118
113
  {
119
- const dir = consumer({
120
- papersDir: "writing/drafts",
121
- rppJson: "writing/drafts",
122
- });
114
+ const dir = consumer({ papersDir: "writing/drafts" });
123
115
  const r = runDoctor(dir, { cliPapers: "writing/drafts" });
124
116
  check(
125
117
  "an install that follows the docs — a FAILURE, not a cheerful report",
@@ -150,7 +142,7 @@ const runDoctor = (
150
142
  // It must not fail here (an error-level false positive costs more than a miss), but it must
151
143
  // not stay silent either.
152
144
  {
153
- const dir = consumer({ papersDir: "papers", rppJson: "papers" });
145
+ const dir = consumer({ papersDir: "papers" });
154
146
  const r = runDoctor(dir, { cliPapers: "papers" });
155
147
  check("an install that works by coincidence does NOT crash", r.code === 0);
156
148
  check(
@@ -220,18 +212,10 @@ const runDoctor = (
220
212
 
221
213
  // ── III. TWO DECLARATIONS HAVE DRIFTED APART ────────────────────────────────────────────────
222
214
  {
223
- const dir = consumer({
224
- papersDir: "writing/drafts",
225
- pkgKey: "papers",
226
- rppJson: "writing/drafts",
227
- });
215
+ const dir = consumer({ papersDir: "writing/drafts", pkgKey: "papers" });
228
216
  mkdirSync(join(dir, "papers"), { recursive: true });
229
217
  const r = runDoctor(dir, { cliPapers: "writing/drafts" });
230
218
  check("both declarations exist, but differ — a FAILURE", r.code === 2);
231
- check(
232
- "the stale rpp.json is called out ⚠",
233
- /rpp\.json is present/.test(r.out),
234
- );
235
219
  rmSync(dir, { recursive: true, force: true });
236
220
  }
237
221
 
@@ -307,7 +291,7 @@ const runDoctor = (
307
291
 
308
292
  // ── VII. DETECTING THE PAPERS DIRECTORY ─────────────────────────────────────────────────────
309
293
  {
310
- const dir = realpathSync(mkdtempSync(join(tmpdir(), "rpp-detect-")));
294
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-detect-")));
311
295
  mkdirSync(join(dir, "writing", "drafts", "p1"), { recursive: true });
312
296
  writeFileSync(join(dir, "writing", "drafts", "p1", "paper.tex"), "x");
313
297
  mkdirSync(join(dir, "node_modules", "pkg", "papers", "p"), {
@@ -387,7 +371,7 @@ const runDoctor = (
387
371
  check("a program that really exists is found", found("node") === true);
388
372
  check(
389
373
  "a made-up one is not (otherwise the check answers the form, not the subject)",
390
- found("rpp-definitely-not-a-real-binary-xyz") === false,
374
+ found("paperlint-definitely-not-a-real-binary-xyz") === false,
391
375
  );
392
376
  }
393
377