paperlint 2.0.1 → 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 (103) hide show
  1. package/CONTRIBUTING.md +21 -0
  2. package/README.md +56 -14
  3. package/dist/build-engine.d.ts.map +1 -1
  4. package/dist/build-engine.js +5 -2
  5. package/dist/build-engine.js.map +1 -1
  6. package/dist/build.d.ts +1 -1
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +4 -3
  9. package/dist/build.js.map +1 -1
  10. package/dist/cli.d.ts +15 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +138 -24
  13. package/dist/cli.js.map +1 -1
  14. package/dist/doctor.d.ts.map +1 -1
  15. package/dist/doctor.js +21 -4
  16. package/dist/doctor.js.map +1 -1
  17. package/dist/facts-file.d.ts +33 -9
  18. package/dist/facts-file.d.ts.map +1 -1
  19. package/dist/facts-file.js +117 -17
  20. package/dist/facts-file.js.map +1 -1
  21. package/dist/init.d.ts +11 -0
  22. package/dist/init.d.ts.map +1 -1
  23. package/dist/init.js +67 -3
  24. package/dist/init.js.map +1 -1
  25. package/dist/new-paper.d.ts +2 -2
  26. package/dist/new-paper.d.ts.map +1 -1
  27. package/dist/new-paper.js +17 -2
  28. package/dist/new-paper.js.map +1 -1
  29. package/dist/paper-settings.d.ts +68 -0
  30. package/dist/paper-settings.d.ts.map +1 -0
  31. package/dist/paper-settings.js +144 -0
  32. package/dist/paper-settings.js.map +1 -0
  33. package/dist/presets.d.ts +84 -0
  34. package/dist/presets.d.ts.map +1 -0
  35. package/dist/presets.js +186 -0
  36. package/dist/presets.js.map +1 -0
  37. package/dist/rules-config.d.ts +7 -0
  38. package/dist/rules-config.d.ts.map +1 -1
  39. package/dist/rules-config.js +11 -6
  40. package/dist/rules-config.js.map +1 -1
  41. package/dist/structure.d.ts.map +1 -1
  42. package/dist/structure.js +2 -1
  43. package/dist/structure.js.map +1 -1
  44. package/dist/tex-requirements.d.ts +63 -7
  45. package/dist/tex-requirements.d.ts.map +1 -1
  46. package/dist/tex-requirements.js +91 -24
  47. package/dist/tex-requirements.js.map +1 -1
  48. package/dist/venue-rules.d.ts +109 -0
  49. package/dist/venue-rules.d.ts.map +1 -0
  50. package/dist/venue-rules.js +387 -0
  51. package/dist/venue-rules.js.map +1 -0
  52. package/docs/configuration.md +67 -7
  53. package/docs/optional-rules.md +25 -24
  54. package/docs/rules.md +131 -2
  55. package/eslint-rules/papers.mjs +6 -2
  56. package/eslint-rules/pdf-last-page-balance.mjs +23 -2
  57. package/fixtures/build-e2e/acmart/paperlint.json +1 -0
  58. package/lib/paper-config.d.mts +7 -0
  59. package/lib/paper-config.mjs +31 -0
  60. package/package.json +1 -1
  61. package/skills/find-venue/SKILL.md +5 -1
  62. package/skills/find-venue/SKILL.md.spec.ts +4 -0
  63. package/skills/paper-pipeline/PIPELINE-MAP.md +14 -4
  64. package/skills/paper-pipeline/SKILL.md +4 -3
  65. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -2
  66. package/skills/render-paper/extract-pdf-facts.harness.mjs +5 -5
  67. package/skills/render-paper/extract-pdf-facts.mjs +4 -4
  68. package/skills/study-accepted-papers/SKILL.md +2 -1
  69. package/skills/study-accepted-papers/SKILL.md.spec.ts +1 -0
  70. package/skills/submit-paper/SKILL.md +9 -1
  71. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  72. package/skills/submit-paper/references/venues/acm-sigconf.jsonc +86 -0
  73. package/skills/submit-paper/references/venues/agenticdev.jsonc +34 -102
  74. package/skills/submit-paper/references/venues/aisec.jsonc +21 -82
  75. package/skills/submit-paper/references/venues/realm.jsonc +45 -43
  76. package/skills/submit-paper/references/venues/tex-base.jsonc +1 -1
  77. package/skills/submit-paper/references/venues/venue-profile.schema.json +119 -29
  78. package/src/build-engine.ts +10 -2
  79. package/src/build.harness.mjs +23 -18
  80. package/src/build.ts +5 -4
  81. package/src/cli.harness.mjs +4 -2
  82. package/src/cli.ts +158 -29
  83. package/src/doctor.ts +28 -3
  84. package/src/facts-file.test.ts +54 -7
  85. package/src/facts-file.ts +145 -24
  86. package/src/init.ts +81 -3
  87. package/src/new-paper.harness.mjs +9 -6
  88. package/src/new-paper.test.ts +145 -0
  89. package/src/new-paper.ts +22 -2
  90. package/src/paper-settings-commands.test.ts +311 -0
  91. package/src/paper-settings.test.ts +220 -0
  92. package/src/paper-settings.ts +206 -0
  93. package/src/presets.test.ts +236 -0
  94. package/src/presets.ts +307 -0
  95. package/src/rules-config.ts +12 -7
  96. package/src/structure.harness.mjs +3 -3
  97. package/src/structure.ts +2 -1
  98. package/src/tex-requirements.harness.mjs +11 -17
  99. package/src/tex-requirements.ts +179 -29
  100. package/src/venue-rules.test.ts +545 -0
  101. package/src/venue-rules.ts +573 -0
  102. package/templates/paper/paperlint.json +4 -0
  103. package/fixtures/build-e2e/acmart/venue.json +0 -1
@@ -137,6 +137,24 @@ const fakeRead = async (pdf) => {
137
137
  const CLEAN_TEX = "\\documentclass{article}\\begin{document}x\\end{document}";
138
138
 
139
139
  try {
140
+ // ── inputs: paperlint's own venues directory, no configuration ───────────────────────────────
141
+ // First: the facts below resolve a paper's venue preset in this same directory, so a wrong one
142
+ // must be named HERE, not as a missing venue label two checks later.
143
+ const venues = packageVenuesDir();
144
+ check(
145
+ "the venues directory is paperlint's own and holds paper-guards.tex",
146
+ existsSync(join(venues, "paper-guards.tex")),
147
+ );
148
+ check(
149
+ "🔴 with no TEXINPUTS set, the value ENDS in the separator — otherwise the system tree stops resolving",
150
+ withTexInputs({}, [venues]).TEXINPUTS === `${venues}${delimiter}`,
151
+ );
152
+ check(
153
+ "an existing TEXINPUTS is kept, after ours",
154
+ withTexInputs({ TEXINPUTS: `/mine${delimiter}` }, [venues]).TEXINPUTS ===
155
+ `${venues}${delimiter}/mine${delimiter}`,
156
+ );
157
+
140
158
  // ── facts: parsed from the paper, not configured ──────────────────────────────────────
141
159
  check(
142
160
  "documentclass and options come from the parser",
@@ -157,33 +175,20 @@ try {
157
175
  );
158
176
  const venued = paper("venued", {
159
177
  "paper.tex": CLEAN_TEX,
160
- "venue.json": JSON.stringify({ venue: "agenticdev", kind: "short" }),
178
+ "paperlint.json": JSON.stringify({
179
+ extends: "paperlint:agenticdev",
180
+ kind: "short",
181
+ }),
161
182
  "build.sh": "exit 0\n",
162
183
  });
163
184
  const facts = readFacts(venued);
164
- check("the venue comes from venue.json", facts.venue === "agenticdev");
185
+ check("the venue comes from paperlint.json", facts.venue === "agenticdev");
165
186
  check(
166
187
  "a leftover build.sh is a FACT (reported), not a step",
167
188
  JSON.stringify(facts.ignoredScripts) === JSON.stringify(["build.sh"]) &&
168
189
  IGNORED_SCRIPTS.includes("repro/build-submission.sh"),
169
190
  );
170
191
 
171
- // ── inputs: paperlint's own venues directory, no configuration ───────────────────────────────
172
- const venues = packageVenuesDir();
173
- check(
174
- "the venues directory is paperlint's own and holds paper-guards.tex",
175
- existsSync(join(venues, "paper-guards.tex")),
176
- );
177
- check(
178
- "🔴 with no TEXINPUTS set, the value ENDS in the separator — otherwise the system tree stops resolving",
179
- withTexInputs({}, [venues]).TEXINPUTS === `${venues}${delimiter}`,
180
- );
181
- check(
182
- "an existing TEXINPUTS is kept, after ours",
183
- withTexInputs({ TEXINPUTS: `/mine${delimiter}` }, [venues]).TEXINPUTS ===
184
- `${venues}${delimiter}/mine${delimiter}`,
185
- );
186
-
187
192
  // ── the plan ──────────────────────────────────────────────────────────────────────────
188
193
  const plan = planFor(facts);
189
194
  check(
package/src/build.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * ── THE SHAPE: an ordered list of STEPS, composed, not configured ────────────
14
14
  * Each step says whether it applies to THIS paper and why — decided from FACTS parsed out of the
15
- * paper (its `\documentclass` and options, the venue named in `venue.json`), never from a config
15
+ * paper (its `\documentclass` and options, the venue named in `paperlint.json`), never from a config
16
16
  * flag. `paperlint build` prints that plan before running anything, and `--dry-run` prints only the
17
17
  * plan. Adding a step is one entry in `STEPS`.
18
18
  *
@@ -45,6 +45,7 @@ import { delimiter, join, relative } from "node:path";
45
45
  // eslint-disable-next-line boundaries/dependencies -- legacy layer, moves behind a port in #76
46
46
  import { getParser } from "@unified-latex/unified-latex-util-parse";
47
47
  import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
48
+ import { PAPER_SETTINGS_FILE } from "../lib/paper-config.mjs";
48
49
  import {
49
50
  declaredVenue,
50
51
  factsPath,
@@ -86,7 +87,7 @@ export const PAPER_MARKERS = [
86
87
  "PIPELINE-STATUS.md",
87
88
  "paper.tex",
88
89
  "paper.md",
89
- "venue.json",
90
+ PAPER_SETTINGS_FILE,
90
91
  ];
91
92
 
92
93
  /** The source paperlint compiles, and the job name every output file carries. */
@@ -114,7 +115,7 @@ export interface PaperFacts {
114
115
  readonly name: string;
115
116
  readonly options: readonly string[];
116
117
  } | null;
117
- /** The `venue` field of `venue.json`, or null. */
118
+ /** The label of the venue preset `paperlint.json` extends, or null. */
118
119
  readonly venue: string | null;
119
120
  /** Paper-supplied build scripts found on disk — reported as ignored. */
120
121
  readonly ignoredScripts: readonly string[];
@@ -222,7 +223,7 @@ export function readFacts(paperDir: string): PaperFacts {
222
223
  return {
223
224
  main,
224
225
  documentclass: documentclassOf(ast),
225
- venue: venue?.venue ?? null,
226
+ venue: venue?.label ?? null,
226
227
  ignoredScripts: IGNORED_SCRIPTS.filter((s) =>
227
228
  existsSync(join(paperDir, s)),
228
229
  ),
@@ -1795,11 +1795,13 @@ console.log(
1795
1795
  );
1796
1796
  const r = await cli(["new", "demo"], root);
1797
1797
  check(
1798
- "🔴 `paperlint new demo` creates writing/demo/ and its lint is the verdict — exit 0, no findings",
1798
+ "🔴 `paperlint new demo` creates writing/demo/ and its lint is the verdict — exit 0, and only the warning that no venue is chosen yet",
1799
1799
  r.code === 0 &&
1800
1800
  existsSync(join(root, "writing", "demo", "PIPELINE-STATUS.md")) &&
1801
1801
  existsSync(join(root, "writing", "demo", "paper.tex")) &&
1802
- /no findings/.test(r.out),
1802
+ existsSync(join(root, "writing", "demo", "paperlint.json")) &&
1803
+ /names no venue preset yet/.test(r.out) &&
1804
+ /\(0 errors, 1 warning\)/.test(r.out),
1803
1805
  );
1804
1806
  const again = await cli(["new", "demo", "--format", "md"], root);
1805
1807
  check(
package/src/cli.ts CHANGED
@@ -33,7 +33,10 @@ 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
41
  import type { Args, PaperlintConfig, ConfigRead } from "./types.ts";
39
42
  import {
@@ -41,22 +44,23 @@ import {
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,7 +128,7 @@ 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
131
+ npx paperlint toolchain [--check] install TeX Live with every package the venue presets declare
126
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
@@ -166,7 +172,19 @@ another file of the same shape. \`papersDir\` is required; the rest is optional:
166
172
 
167
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. */
@@ -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
@@ -328,6 +358,66 @@ 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.
@@ -818,15 +908,40 @@ async function runBuild(
818
908
  return anyFailed(out.results) ? 1 : 0;
819
909
  }
820
910
 
821
- /** 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
+ */
822
918
  function paperRequirements(dir: string): TexRequirements {
823
- let venue: string | null = null;
824
- try {
825
- venue = readFacts(dir).venue;
826
- } catch {
827
- venue = null;
828
- }
829
- return requirementsFor(venue).tex;
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;
830
945
  }
831
946
 
832
947
  /**
@@ -868,8 +983,14 @@ const SIMPLE: Readonly<
868
983
  hook: (a, { err }) => runHook(a.paths[0], { err }),
869
984
  new: (a, io) => runNew(a, io),
870
985
  build: (a, io) => runBuild(a, io),
871
- toolchain: (a, { log, err }) =>
872
- 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
+ }),
873
994
  };
874
995
 
875
996
  /** banal's installer, wired from this process's environment: the composition root's work. */
@@ -1013,6 +1134,14 @@ export async function run(
1013
1134
  // a directory without `PIPELINE-STATUS.md` simply gets not a single rule and reports clean. The
1014
1135
  // analysis of why a structure plugin for ESLint does not cure this is in `structure.mjs`.
1015
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
+ };
1016
1145
 
1017
1146
  let texLanguage: unknown = null;
1018
1147
  try {
@@ -1026,7 +1155,7 @@ export async function run(
1026
1155
  const eslint = new ESLint({
1027
1156
  cwd: lintRoot(configPath ? dirname(resolve(cwd, configPath)) : cwd, paths),
1028
1157
  overrideConfigFile: true,
1029
- overrideConfig: buildConfig(opts, texLanguage) as Linter.Config[],
1158
+ overrideConfig: buildConfig(withPapers, texLanguage) as Linter.Config[],
1030
1159
  });
1031
1160
 
1032
1161
  // 🔴 ESLint THROWS on an empty set (`NoFilesFoundError`) — the guard below simply never got
@@ -1063,7 +1192,7 @@ export async function run(
1063
1192
  log,
1064
1193
  err,
1065
1194
  where: relative(cwd, dirname(resolve(cwd, configPath ?? "."))) || ".",
1066
- opts,
1195
+ opts: withPapers,
1067
1196
  });
1068
1197
  }
1069
1198
 
package/src/doctor.ts CHANGED
@@ -37,9 +37,11 @@ import {
37
37
  import {
38
38
  LEGACY_CONFIG_KEY,
39
39
  LEGACY_KEY_MESSAGE,
40
+ LEGACY_PAPER_SETTINGS_FILE,
41
+ PAPER_SETTINGS_FILE,
40
42
  declaredSettings,
41
43
  } from "../lib/paper-config.mjs";
42
- import { PAPER_MARKERS } from "./build.ts";
44
+ import { PAPER_MARKERS, papersIn } from "./build.ts";
43
45
  import { linkSkills, SKILLS_HOME, type LinkReport } from "./link-skills.ts";
44
46
  import { doctorHooks } from "./hooks-settings.ts";
45
47
 
@@ -233,6 +235,28 @@ function papersVerdict(
233
235
  return { lines: out, bad };
234
236
  }
235
237
 
238
+ /**
239
+ * A pre-2.1.0 `venue.json` left in a paper: it is no longer read, so that paper's venue checks
240
+ * silently would not run if nothing named it. `init` moves it.
241
+ */
242
+ function leftoverSettingsVerdict(
243
+ root: string,
244
+ papersDir: string | null,
245
+ ): { lines: string[]; bad: number } {
246
+ if (papersDir === null) return { lines: [], bad: 0 };
247
+ const lines = papersIn(resolve(root, papersDir), [
248
+ ...PAPER_MARKERS,
249
+ LEGACY_PAPER_SETTINGS_FILE,
250
+ ])
251
+ .map((dir) => join(dir, LEGACY_PAPER_SETTINGS_FILE))
252
+ .filter((f) => existsSync(f))
253
+ .map(
254
+ (f) =>
255
+ ` ✗ ${relative(root, f)} is no longer read — paperlint 2.1.0 renamed it ${PAPER_SETTINGS_FILE}, so this paper's venue checks do not run. \`npx paperlint init\` moves it`,
256
+ );
257
+ return { lines, bad: lines.length };
258
+ }
259
+
236
260
  export interface DoctorOptions {
237
261
  log?: typeof console.log;
238
262
  cwd?: string;
@@ -293,8 +317,9 @@ export function doctor({
293
317
  ` the hooks will guard ${hookSays ?? "(nothing — the guard refuses and says why on first use)"}`,
294
318
  );
295
319
  const verdict = papersVerdict(root, cliPapers, hookSays);
296
- out.push(...verdict.lines);
297
- bad += verdict.bad;
320
+ const leftover = leftoverSettingsVerdict(root, cliPapers ?? hookSays);
321
+ out.push(...verdict.lines, ...leftover.lines);
322
+ bad += verdict.bad + leftover.bad;
298
323
 
299
324
  // A skill that is not linked is ADVISORY, like a missing program: `paperlint lint`, the hooks and CI
300
325
  // work without it, and an entry of the same name that `init` refused to replace is the
@@ -8,6 +8,9 @@
8
8
  * real banal is `test/e2e/banal.mjs`.
9
9
  */
10
10
  import assert from "node:assert/strict";
11
+ import { readdirSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
11
14
  import { test } from "vitest";
12
15
  import { memoryFiles } from "./adapters/memory/index.ts";
13
16
  import {
@@ -147,12 +150,23 @@ const good: PdfReader = async () => ({
147
150
  ok: true,
148
151
  facts: { pages: 2, fonts: FONTS, last: lastPage(60, 60), layout: [] },
149
152
  });
150
- /** Options over in-memory files: the PDF and venue.json on "disk", a measurer answering `geometry`. */
153
+ /** The shipped presets at their real paths: `extends` resolves through the same Files port. */
154
+ const SHIPPED_PRESETS = Object.fromEntries(
155
+ readdirSync(packageVenuesDir())
156
+ .filter((f) => /\.jsonc?$/.test(f))
157
+ .map((f) => [
158
+ join(packageVenuesDir(), f),
159
+ readFileSync(join(packageVenuesDir(), f)),
160
+ ]),
161
+ );
162
+
163
+ /** Options over in-memory files: the PDF and paperlint.json on "disk", a measurer answering `geometry`. */
151
164
  function setup(geometry: Geometry = NONE) {
152
165
  const files = memoryFiles({
153
166
  [PDF]: PDF_BYTES,
154
- [`${PAPER}/venue.json`]: JSON.stringify({
155
- venue: "agenticdev",
167
+ ...SHIPPED_PRESETS,
168
+ [`${PAPER}/paperlint.json`]: JSON.stringify({
169
+ extends: "paperlint:agenticdev",
156
170
  kind: "short",
157
171
  }),
158
172
  });
@@ -170,7 +184,7 @@ function setup(geometry: Geometry = NONE) {
170
184
  return { files, measured, o };
171
185
  }
172
186
 
173
- test("measure: venue and kind from venue.json, pdf relative to the paper, the file's sha256", async () => {
187
+ test("measure: venue and kind from paperlint.json, pdf relative to the paper, the file's sha256", async () => {
174
188
  const { o } = setup();
175
189
  const m = await measurePaper(PAPER, PDF, o);
176
190
  assert.ok(m.ok);
@@ -233,8 +247,41 @@ test("writeFactsFile: <paper>/_build/paper.facts.json holds exactly the document
233
247
  assert.deepEqual(JSON.parse(text), m.value.facts);
234
248
  });
235
249
 
236
- test("declaredVenue: none without venue.json, none when it names no venue", () => {
250
+ test("declaredVenue: none without paperlint.json; no label when it extends no preset", () => {
237
251
  assert.equal(declaredVenue(memoryFiles(), PAPER), null);
238
- const files = memoryFiles({ [`${PAPER}/venue.json`]: '{"kind":"x"}' });
239
- assert.equal(declaredVenue(files, PAPER), null);
252
+ const files = memoryFiles({
253
+ [`${PAPER}/paperlint.json`]: '{"kind":"x","pdf":"b/p.pdf"}',
254
+ });
255
+ assert.deepEqual(declaredVenue(files, PAPER), {
256
+ label: null,
257
+ kind: "x",
258
+ pdf: "b/p.pdf",
259
+ });
260
+ });
261
+
262
+ test("declaredVenue: the label is the preset's, and an extends that resolves nowhere is refused", () => {
263
+ const ok = memoryFiles({
264
+ ...SHIPPED_PRESETS,
265
+ [`${PAPER}/paperlint.json`]: '{"extends":"paperlint:aisec"}',
266
+ });
267
+ assert.equal(declaredVenue(ok, PAPER)?.label, "aisec");
268
+ const typo = memoryFiles({
269
+ ...SHIPPED_PRESETS,
270
+ [`${PAPER}/paperlint.json`]: '{"extends":"paperlint:aisek"}',
271
+ });
272
+ assert.throws(() => declaredVenue(typo, PAPER), /shipped presets/);
273
+ });
274
+
275
+ test("declaredVenue: a venue.json alone is refused with the command that moves it, not read", () => {
276
+ const files = memoryFiles({
277
+ [`${PAPER}/venue.json`]: '{"venue":"agenticdev"}',
278
+ });
279
+ assert.throws(() => declaredVenue(files, PAPER), /npx paperlint init/);
280
+ });
281
+
282
+ test("declaredVenue: a paperlint.json with an unknown key is refused, naming the key", () => {
283
+ const files = memoryFiles({
284
+ [`${PAPER}/paperlint.json`]: '{"venu":"agenticdev"}',
285
+ });
286
+ assert.throws(() => declaredVenue(files, PAPER), /unknown key "venu"/);
240
287
  });