paperlint 2.1.0 → 3.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 (219) hide show
  1. package/CLAUDE.md +6 -1
  2. package/README.md +243 -235
  3. package/action.yml +4 -6
  4. package/dist/adapters/references/index.d.ts +13 -0
  5. package/dist/adapters/references/index.d.ts.map +1 -0
  6. package/dist/adapters/references/index.js +55 -0
  7. package/dist/adapters/references/index.js.map +1 -0
  8. package/dist/adapters/references/reach.io.d.ts +3 -0
  9. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  10. package/dist/adapters/references/reach.io.js +14 -0
  11. package/dist/adapters/references/reach.io.js.map +1 -0
  12. package/dist/build-engine.d.ts.map +1 -1
  13. package/dist/build-engine.js +6 -2
  14. package/dist/build-engine.js.map +1 -1
  15. package/dist/build.d.ts +15 -0
  16. package/dist/build.d.ts.map +1 -1
  17. package/dist/build.js +60 -7
  18. package/dist/build.js.map +1 -1
  19. package/dist/cli.d.ts +94 -30
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +500 -288
  22. package/dist/cli.js.map +1 -1
  23. package/dist/doctor.d.ts.map +1 -1
  24. package/dist/doctor.js +40 -72
  25. package/dist/doctor.js.map +1 -1
  26. package/dist/engine.d.ts +9 -0
  27. package/dist/engine.d.ts.map +1 -1
  28. package/dist/engine.js +27 -0
  29. package/dist/engine.js.map +1 -1
  30. package/dist/facts-file.d.ts +2 -2
  31. package/dist/facts-file.js +2 -2
  32. package/dist/hooks-settings.d.ts +0 -21
  33. package/dist/hooks-settings.d.ts.map +1 -1
  34. package/dist/hooks-settings.js +17 -135
  35. package/dist/hooks-settings.js.map +1 -1
  36. package/dist/init.d.ts +39 -29
  37. package/dist/init.d.ts.map +1 -1
  38. package/dist/init.js +144 -173
  39. package/dist/init.js.map +1 -1
  40. package/dist/link-skills.d.ts +1 -1
  41. package/dist/link-skills.d.ts.map +1 -1
  42. package/dist/link-skills.js +11 -29
  43. package/dist/link-skills.js.map +1 -1
  44. package/dist/new-paper.d.ts +8 -1
  45. package/dist/new-paper.d.ts.map +1 -1
  46. package/dist/new-paper.js +49 -25
  47. package/dist/new-paper.js.map +1 -1
  48. package/dist/paper-files.d.ts +85 -0
  49. package/dist/paper-files.d.ts.map +1 -0
  50. package/dist/paper-files.js +96 -0
  51. package/dist/paper-files.js.map +1 -0
  52. package/dist/paper-settings.d.ts +13 -41
  53. package/dist/paper-settings.d.ts.map +1 -1
  54. package/dist/paper-settings.js +71 -71
  55. package/dist/paper-settings.js.map +1 -1
  56. package/dist/ports/check-references.d.ts +31 -0
  57. package/dist/ports/check-references.d.ts.map +1 -0
  58. package/dist/ports/check-references.js +7 -0
  59. package/dist/ports/check-references.js.map +1 -0
  60. package/dist/presets.d.ts +1 -1
  61. package/dist/presets.d.ts.map +1 -1
  62. package/dist/presets.js +4 -6
  63. package/dist/presets.js.map +1 -1
  64. package/dist/reference-rules.d.ts +47 -0
  65. package/dist/reference-rules.d.ts.map +1 -0
  66. package/dist/reference-rules.js +156 -0
  67. package/dist/reference-rules.js.map +1 -0
  68. package/dist/references.d.ts +36 -0
  69. package/dist/references.d.ts.map +1 -0
  70. package/dist/references.js +91 -0
  71. package/dist/references.js.map +1 -0
  72. package/dist/rules-config.d.ts +5 -3
  73. package/dist/rules-config.d.ts.map +1 -1
  74. package/dist/rules-config.js +15 -6
  75. package/dist/rules-config.js.map +1 -1
  76. package/dist/structure.js +3 -3
  77. package/dist/structure.js.map +1 -1
  78. package/dist/tex-requirements.js +2 -2
  79. package/dist/tex-requirements.js.map +1 -1
  80. package/dist/toolchain.d.ts +5 -1
  81. package/dist/toolchain.d.ts.map +1 -1
  82. package/dist/toolchain.js +22 -5
  83. package/dist/toolchain.js.map +1 -1
  84. package/dist/types.d.ts +26 -18
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/venue-rules.d.ts.map +1 -1
  87. package/dist/venue-rules.js +12 -10
  88. package/dist/venue-rules.js.map +1 -1
  89. package/docs/configuration.md +163 -104
  90. package/docs/e2e.md +1 -1
  91. package/docs/install.md +25 -40
  92. package/docs/optional-rules.md +5 -4
  93. package/docs/rules.md +65 -24
  94. package/docs/skills.md +80 -0
  95. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  96. package/eslint-rules/latex-language.mjs +29 -1
  97. package/eslint-rules/paper-stages.harness.mjs +1 -117
  98. package/eslint-rules/paper-stages.mjs +0 -120
  99. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  100. package/eslint-rules/paper-typography.mjs +306 -191
  101. package/eslint-rules/paper-typography.test.mjs +337 -0
  102. package/eslint-rules/papers.harness.mjs +8 -12
  103. package/eslint-rules/papers.mjs +17 -31
  104. package/eslint-rules/pdf-last-page-balance.mjs +4 -7
  105. package/eslint-rules/review-frontmatter.mjs +118 -0
  106. package/eslint-rules/review-frontmatter.schema.json +36 -0
  107. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  108. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  109. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  110. package/eslint.config.mjs +0 -15
  111. package/fixtures/real-markdown-paper/baseline.json +6 -7
  112. package/fixtures/toolchain-mirror/catalog.txt +1 -0
  113. package/fixtures/toolchain-mirror/install-tl +4 -1
  114. package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
  115. package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
  116. package/hooks/hooks.harness.mjs +39 -98
  117. package/hooks/hooks.mutations.mjs +6 -6
  118. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  119. package/hooks/paper-edit-guard.hook.mjs +31 -42
  120. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  121. package/hooks/paper-status-gates.hook.mjs +21 -19
  122. package/hooks/paper-status-gates.sh +8 -13
  123. package/lib/paper-config.d.mts +15 -25
  124. package/lib/paper-config.harness.mjs +18 -35
  125. package/lib/paper-config.mjs +83 -105
  126. package/lib/paper-config.mutations.mjs +2 -2
  127. package/lib/skill-checks.mjs +1 -1
  128. package/lib/skill-trigger-cases.harness.mjs +9 -8
  129. package/lib/skill-trigger-cases.mjs +7 -15
  130. package/package.json +1 -1
  131. package/scripts/eslint-report-guard.mjs +1 -1
  132. package/scripts/harness-api.frozen.json +0 -3
  133. package/scripts/mutation-batteries.frozen.json +5 -14
  134. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  135. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  136. package/skills/cold-read-diff/SKILL.md +20 -1
  137. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  138. package/skills/grade-paper-writing/SKILL.md +20 -1
  139. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  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/render-paper.harness.mjs +2 -2
  151. package/skills/verify-citations/SKILL.md +10 -1
  152. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  153. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  154. package/src/adapters/references/index.ts +102 -0
  155. package/src/adapters/references/reach.io.ts +12 -0
  156. package/src/build-engine.ts +6 -1
  157. package/src/build.harness.mjs +54 -6
  158. package/src/build.ts +89 -11
  159. package/src/cli.harness.mjs +149 -294
  160. package/src/cli.mutations.mjs +35 -44
  161. package/src/cli.ts +637 -328
  162. package/src/doctor.harness.mjs +41 -84
  163. package/src/doctor.mutations.mjs +9 -9
  164. package/src/doctor.ts +44 -89
  165. package/src/engine.harness.mjs +50 -0
  166. package/src/engine.ts +35 -0
  167. package/src/facts-file.test.ts +0 -7
  168. package/src/facts-file.ts +2 -2
  169. package/src/hooks-settings.harness.mjs +7 -92
  170. package/src/hooks-settings.mutations.mjs +2 -17
  171. package/src/hooks-settings.ts +18 -162
  172. package/src/init.test.ts +206 -0
  173. package/src/init.ts +187 -231
  174. package/src/link-skills.harness.mjs +1 -38
  175. package/src/link-skills.mutations.mjs +9 -5
  176. package/src/link-skills.ts +10 -34
  177. package/src/lint-fix.test.ts +120 -0
  178. package/src/linted-set.test.ts +118 -0
  179. package/src/new-paper.harness.mjs +2 -0
  180. package/src/new-paper.mutations.mjs +0 -14
  181. package/src/new-paper.test.ts +173 -14
  182. package/src/new-paper.ts +82 -30
  183. package/src/paper-files.test.ts +395 -0
  184. package/src/paper-files.ts +200 -0
  185. package/src/paper-settings-commands.test.ts +121 -122
  186. package/src/paper-settings.test.ts +93 -64
  187. package/src/paper-settings.ts +99 -97
  188. package/src/parse-args.test.ts +48 -0
  189. package/src/ports/check-references.ts +28 -0
  190. package/src/presets.ts +5 -15
  191. package/src/reference-rules.ts +229 -0
  192. package/src/references.test.ts +209 -0
  193. package/src/references.ts +138 -0
  194. package/src/rules-config.harness.mjs +24 -8
  195. package/src/rules-config.ts +19 -7
  196. package/src/rules-off.test.ts +111 -0
  197. package/src/structure.harness.mjs +4 -2
  198. package/src/structure.ts +3 -3
  199. package/src/tex-requirements.ts +2 -2
  200. package/src/toolchain.harness.mjs +41 -6
  201. package/src/toolchain.ts +29 -5
  202. package/src/types.ts +36 -18
  203. package/src/venue-rules.test.ts +23 -11
  204. package/src/venue-rules.ts +12 -14
  205. package/templates/paper/paperlint.json +1 -1
  206. package/eslint-rules/doc-fields.harness.mjs +0 -336
  207. package/eslint-rules/doc-fields.mjs +0 -186
  208. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  209. package/eslint-rules/paper-typography.harness.mjs +0 -291
  210. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  211. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  212. package/eslint-rules/review-findings-cause.mjs +0 -135
  213. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  214. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  215. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  216. package/fixtures/review-findings-cause/clean.md +0 -17
  217. package/fixtures/review-findings-cause/defect.md +0 -14
  218. package/fixtures/review-findings-cause/old-debt.md +0 -14
  219. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -44,6 +44,7 @@ export type Parsed<T> =
44
44
  | { readonly ok: false; readonly error: string };
45
45
 
46
46
  const SEVERITIES: readonly unknown[] = ["off", "warn", "error", 0, 1, 2];
47
+
47
48
  const BLOCK_KEYS = new Set(["files", "ignores", "rules"]);
48
49
 
49
50
  const bad = <T>(error: string): Parsed<T> => ({ ok: false, error });
@@ -67,7 +68,7 @@ function parseEntry(v: unknown): RuleEntry | null {
67
68
 
68
69
  /**
69
70
  * A `rules` object — rule id → severity or `[severity, ...options]` — with only the rules paperlint
70
- * ships. Read from the package.json blocks and from a paper's `paperlint.json`.
71
+ * ships. Read from every `paperlint.json`, the root's and each paper's.
71
72
  *
72
73
  * @param where the key path to name in messages, e.g. `package.json → "paperlint".rules[0].rules`
73
74
  */
@@ -108,17 +109,20 @@ const globsOf = (o: Record<string, unknown>) => ({
108
109
  ...(o["ignores"] ? { ignores: o["ignores"] as string[] } : {}),
109
110
  });
110
111
 
112
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
113
+ typeof v === "object" && v !== null && !Array.isArray(v);
114
+
111
115
  /** One element of the `rules` list. */
112
116
  function parseBlock(
113
117
  v: unknown,
114
118
  at: string,
115
119
  ctx: { shipped: ReadonlySet<string>; baseDir: string },
116
120
  ): Parsed<RuleBlock> {
117
- if (typeof v !== "object" || v === null || Array.isArray(v))
121
+ if (!isRecord(v))
118
122
  return bad(
119
123
  `${at} must be an object like { "files": [...], "rules": {...} }`,
120
124
  );
121
- const o = v as Record<string, unknown>;
125
+ const o = v;
122
126
  const extra = Object.keys(o).filter((k) => !BLOCK_KEYS.has(k));
123
127
  if (extra.length)
124
128
  return bad(
@@ -135,10 +139,12 @@ function parseBlock(
135
139
  }
136
140
 
137
141
  /**
138
- * The `rules` key → config blocks, or one error naming the key path.
142
+ * The `rules` key → config blocks, or one error naming the key path. Two shapes, at every level:
143
+ * `{ id: severity }` is one block over every paper file under `baseDir`; a list is ESLint blocks
144
+ * (`files`, `ignores`, `rules`), their globs relative to `baseDir`.
139
145
  *
140
146
  * @param raw the value under `rules` (undefined when absent)
141
- * @param where how the settings are named in messages, e.g. `package.json → "paperlint"`
147
+ * @param where how the settings are named in messages, e.g. `paperlint.json`
142
148
  * @param shipped the rule ids paperlint ships (`shippedRuleIds`)
143
149
  * @param baseDir the directory of the file holding the settings
144
150
  */
@@ -149,13 +155,19 @@ export function parseRuleBlocks(
149
155
  baseDir: string,
150
156
  ): Parsed<readonly RuleBlock[]> {
151
157
  if (raw === undefined) return { ok: true, value: [] };
158
+ if (isRecord(raw)) {
159
+ const rules = parseRuleEntries(raw, `${where} → "rules"`, shipped);
160
+ return rules.ok
161
+ ? { ok: true, value: [{ basePath: baseDir, rules: rules.value }] }
162
+ : rules;
163
+ }
152
164
  if (!Array.isArray(raw))
153
165
  return bad(
154
- `${where}.rules must be a list of blocks, like ESLint's flat config: [{ "files": [...], "rules": {...} }]`,
166
+ `${where} → "rules" must be { "<rule>": "<severity>" }, or a list of blocks like ESLint's flat config: [{ "files": [...], "rules": {...} }]`,
155
167
  );
156
168
  const out: RuleBlock[] = [];
157
169
  for (const [i, v] of raw.entries()) {
158
- const b = parseBlock(v, `${where}.rules[${i}]`, { shipped, baseDir });
170
+ const b = parseBlock(v, `${where} → rules[${i}]`, { shipped, baseDir });
159
171
  if (!b.ok) return b;
160
172
  out.push(b.value);
161
173
  }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * A consumer that ALSO lints paper files with its own ESLint config (#104).
3
+ *
4
+ * 3.0.0 made `% eslint-disable-next-line <rule> -- <why>` the way to keep a deliberate exception.
5
+ * A project that runs its own `eslint` over the same `paper.tex` — for its own rules, with
6
+ * paperlint's LaTeX language — did not register paperlint's rule plugins, so that run failed on the
7
+ * directive: "Definition for rule 'paper/leading-zero' was not found". `rulesOff` is the fragment
8
+ * such a config spreads in: every rule paperlint ships, registered and turned off.
9
+ */
10
+ import { ESLint, type Linter } from "eslint";
11
+ import { describe, expect, it } from "vitest";
12
+ // @ts-expect-error — the module is .mjs and has no types.
13
+ import { texLanguage } from "../eslint-rules/latex-language.mjs";
14
+ import { rulePlugins, rulesOff, SHIPPED_RULES } from "./cli.ts";
15
+
16
+ const PAPER = [
17
+ "\\documentclass{article}",
18
+ "\\begin{document}",
19
+ "% eslint-disable-next-line paper/leading-zero -- quoted from the reviewer",
20
+ "We use a threshold of .05 throughout.",
21
+ "% eslint-disable-next-line bib/reachable-entry -- kept for the artifact",
22
+ "Nothing else.",
23
+ "\\end{document}",
24
+ "",
25
+ ].join("\n");
26
+
27
+ /** The consumer's own config: its LaTeX files, with paperlint's language, and its own rules. */
28
+ async function lintAsConsumer(
29
+ fragment: Linter.Config[],
30
+ ): Promise<Linter.LintMessage[]> {
31
+ const eslint = new ESLint({
32
+ overrideConfigFile: true,
33
+ overrideConfig: [
34
+ ...fragment,
35
+ {
36
+ files: ["**/*.tex"],
37
+ plugins:
38
+ fragment.length > 0
39
+ ? {}
40
+ : { tex: { languages: { latex: texLanguage } } },
41
+ language: "tex/latex",
42
+ },
43
+ ],
44
+ });
45
+ const [res] = await eslint.lintText(PAPER, {
46
+ filePath: "papers/a/paper.tex",
47
+ });
48
+ return res!.messages;
49
+ }
50
+
51
+ describe("rulesOff — paperlint's rules, registered and off, for a consumer's own ESLint run", () => {
52
+ it("without it, the documented escape hatch breaks the consumer's run (the defect)", async () => {
53
+ const messages = await lintAsConsumer([]);
54
+ expect(messages.map((m) => m.message)).toContain(
55
+ "Definition for rule 'paper/leading-zero' was not found.",
56
+ );
57
+ });
58
+
59
+ it("🔴 spreading it: no 'Definition … not found', and no paperlint finding runs", async () => {
60
+ const messages = await lintAsConsumer(rulesOff(texLanguage));
61
+ // Guards: the defect — every directive names a rule ESLint now knows.
62
+ expect(messages.map((m) => m.message).join("\n")).not.toMatch(
63
+ /Definition for rule/,
64
+ );
65
+ // Guards: off means off — paperlint's own run judges these files, not the consumer's.
66
+ expect(messages.filter((m) => m.ruleId?.startsWith("paper/"))).toEqual([]);
67
+ // Guards: a directive naming an OFF rule suppresses nothing, and ESLint would call it unused
68
+ // ("Unused eslint-disable directive (no problems were reported from 'paper/leading-zero')").
69
+ expect(messages).toEqual([]);
70
+ });
71
+
72
+ it("registers every rule paperlint ships, each off, and carries the LaTeX language", () => {
73
+ const [cfg] = rulesOff(texLanguage);
74
+ expect(new Set(Object.keys(cfg?.rules ?? {}))).toEqual(SHIPPED_RULES);
75
+ expect(Object.values(cfg?.rules ?? {}).every((e) => e === "off")).toBe(
76
+ true,
77
+ );
78
+ const plugins = rulePlugins(texLanguage);
79
+ const defined = new Set<string>();
80
+ for (const [name, p] of Object.entries(plugins))
81
+ for (const r of Object.keys(p.rules ?? {})) defined.add(`${name}/${r}`);
82
+ expect(defined).toEqual(SHIPPED_RULES);
83
+ expect(plugins["tex"]?.languages?.["latex"]).toBe(texLanguage);
84
+ // `@eslint/markdown` is a dependency's plugin: the consumer registers its own.
85
+ expect(plugins["markdown"]).toBeUndefined();
86
+ });
87
+
88
+ it("the unused-directive report is off on paper files only, not on the consumer's code", async () => {
89
+ const eslint = new ESLint({
90
+ overrideConfigFile: true,
91
+ overrideConfig: [
92
+ ...rulesOff(texLanguage),
93
+ {
94
+ files: ["**/*.js"],
95
+ linterOptions: { reportUnusedDisableDirectives: "warn" },
96
+ },
97
+ ],
98
+ });
99
+ const [res] = await eslint.lintText(
100
+ "// eslint-disable-next-line paper/leading-zero -- nothing to silence\nconst x = 1;\n",
101
+ { filePath: "src/x.js" },
102
+ );
103
+ expect(res!.messages.map((m) => m.message)).toEqual([
104
+ "Unused eslint-disable directive (no problems were reported from 'paper/leading-zero').",
105
+ ]);
106
+ });
107
+
108
+ it("without a LaTeX language, the `tex` plugin carries rules only", () => {
109
+ expect(rulePlugins()["tex"]?.languages).toBeUndefined();
110
+ });
111
+ });
@@ -71,8 +71,10 @@ try {
71
71
  );
72
72
  check(
73
73
  "and the message names the CONSEQUENCE, not a restatement of the condition",
74
- /is checked/.test(at("no-scorecard")[0].message) &&
75
- /stages, sources or authors/.test(at("no-scorecard")[0].message),
74
+ /are skipped/.test(at("no-scorecard")[0].message) &&
75
+ /stage, source and research-question checks/.test(
76
+ at("no-scorecard")[0].message,
77
+ ),
76
78
  );
77
79
  check(
78
80
  "and the consequence names THIS directory by name",
package/src/structure.ts CHANGED
@@ -25,7 +25,7 @@
25
25
  import { readdirSync, existsSync } from "node:fs";
26
26
  import { join, relative, basename, isAbsolute, sep } from "node:path";
27
27
  import type { StructureConfig, StructureFinding } from "./types.ts";
28
- import { PAPER_SETTINGS_FILE } from "../lib/paper-config.mjs";
28
+ import { CONFIG_FILE } from "../lib/paper-config.mjs";
29
29
 
30
30
  /** The requirements after the consumer's config is laid over the defaults. */
31
31
  type Rules = Required<StructureConfig>;
@@ -38,7 +38,7 @@ type Rules = Required<StructureConfig>;
38
38
  * byte-compared by the `paper/stages` rule anyway.
39
39
  */
40
40
  export const STRUCTURE_DEFAULTS = {
41
- markers: ["PIPELINE-STATUS.md", "paper.tex", "paper.md", PAPER_SETTINGS_FILE],
41
+ markers: ["PIPELINE-STATUS.md", "paper.tex", "paper.md", CONFIG_FILE],
42
42
  require: ["PIPELINE-STATUS.md"],
43
43
  requireOneOf: [["paper.tex", "paper.md"]],
44
44
  ignore: [],
@@ -122,7 +122,7 @@ export function checkStructure(
122
122
  */
123
123
  function whyMissingMatters(file: string, dirName: string): string {
124
124
  if (file === "PIPELINE-STATUS.md")
125
- return `\`paper/stages\`, \`paper/source\` and \`paper/author-list\` read this file, so nothing \`${dirName}\` declares about its stages, sources or authors is checked`;
125
+ return `the stage, source and research-question checks are skipped for \`${dirName}\``;
126
126
  return `declared as required by your \`structure\` configuration`;
127
127
  }
128
128
 
@@ -26,7 +26,7 @@ import { join } from "node:path";
26
26
  // eslint-disable-next-line boundaries/dependencies -- legacy layer, moves behind a port in #76
27
27
  import Ajv from "ajv";
28
28
  import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
29
- import { PAPER_SETTINGS_FILE } from "../lib/paper-config.mjs";
29
+ import { CONFIG_FILE } from "../lib/paper-config.mjs";
30
30
 
31
31
  /** CTAN package name → the names that prove it is installed. */
32
32
  export type PackageProofs = Readonly<Record<string, readonly string[]>>;
@@ -298,7 +298,7 @@ export function requirementsFor(
298
298
  const base = readProfile(dir, BASE_PROFILE);
299
299
  return preset === null
300
300
  ? {
301
- source: `the base set (no venue preset in ${PAPER_SETTINGS_FILE})`,
301
+ source: `the base set (no venue preset in ${CONFIG_FILE})`,
302
302
  tex: base,
303
303
  }
304
304
  : {
@@ -163,14 +163,19 @@ for (const f of [
163
163
  "stub-pdflatex",
164
164
  "stub-kpsewhich",
165
165
  "stub-tlmgr",
166
+ "stub-fmtutil-sys",
166
167
  "release-texlive.txt",
167
168
  ])
168
169
  copyFileSync(join(FIXTURE, f), join(staging, f));
169
170
  spawnSync("chmod", [
170
171
  "+x",
171
- ...["install-tl", "stub-pdflatex", "stub-kpsewhich", "stub-tlmgr"].map((f) =>
172
- join(staging, f),
173
- ),
172
+ ...[
173
+ "install-tl",
174
+ "stub-pdflatex",
175
+ "stub-kpsewhich",
176
+ "stub-tlmgr",
177
+ "stub-fmtutil-sys",
178
+ ].map((f) => join(staging, f)),
174
179
  ]);
175
180
  spawnSync(
176
181
  "tar",
@@ -192,6 +197,10 @@ writeFileSync(
192
197
  const url = (dir) => pathToFileURL(dir).href;
193
198
  const MISSING = url(join(work, "no-such-mirror"));
194
199
 
200
+ /** A call that downloads or installs. `tlmgr check depends` only reads, so it is not one. */
201
+ const installs = (c) =>
202
+ c.cmd === "curl" || (c.cmd.endsWith("tlmgr") && c.args.includes("install"));
203
+
195
204
  /** A `spawnSync` that records every program it was asked to run. */
196
205
  const recorder = () => {
197
206
  const calls = [];
@@ -364,8 +373,33 @@ const cmd = ({ banal = BANAL, ...over } = {}) => {
364
373
  );
365
374
  check(
366
375
  "second run: downloads nothing and installs nothing",
367
- !r.calls.some((c) => c.cmd === "curl" || c.cmd.endsWith("tlmgr")),
368
- JSON.stringify(r.calls.map((c) => c.cmd)),
376
+ !r.calls.some(installs),
377
+ JSON.stringify(r.calls.map((c) => [c.cmd, ...c.args])),
378
+ );
379
+ }
380
+ // Guards: a tree with every venue package can still be broken. install-tl that lost a package
381
+ // to a mirror prints "continuing anyway"; seen 2026-09-26 with unicode-data, after which the
382
+ // pdflatex format was never built and every build died before writing a log.
383
+ {
384
+ const tree = join(root, "2026");
385
+ writeFileSync(join(tree, "missing-deps.txt"), "unicode-data\n");
386
+ const check1 = cmd({ check: true });
387
+ check(
388
+ "🔴 a dropped base dependency: --check does not call the tree complete",
389
+ check1.code === 1,
390
+ `${check1.out}\n${check1.err}`,
391
+ );
392
+ const r = cmd();
393
+ check(
394
+ "🔴 a dropped base dependency: the run installs it, then builds the missing formats",
395
+ r.code === 0 &&
396
+ readFileSync(join(tree, "tlmgr.log"), "utf8").includes("unicode-data") &&
397
+ readFileSync(join(tree, "fmtutil.log"), "utf8").trim() === "--missing",
398
+ `${r.out}\n${r.err}`,
399
+ );
400
+ check(
401
+ "a dropped base dependency: the tree is whole afterwards",
402
+ readFileSync(join(tree, "missing-deps.txt"), "utf8").trim() === "",
369
403
  );
370
404
  }
371
405
  {
@@ -500,6 +534,7 @@ const mirrorFor = (name, year, installerYear = year) => {
500
534
  "stub-pdflatex",
501
535
  "stub-kpsewhich",
502
536
  "stub-tlmgr",
537
+ "stub-fmtutil-sys",
503
538
  ])
504
539
  copyFileSync(join(staging, f), join(stage, f));
505
540
  writeFileSync(
@@ -544,7 +579,7 @@ check(
544
579
  r.out.includes("TeX Live 2026") &&
545
580
  r.out.includes("nothing to do") &&
546
581
  !existsSync(join(years, "2027")) &&
547
- !r.calls.some((c) => c.cmd === "curl" || c.cmd.endsWith("tlmgr")),
582
+ !r.calls.some(installs),
548
583
  `${r.out}\n${JSON.stringify(r.calls.map((c) => c.cmd))}`,
549
584
  );
550
585
  }
package/src/toolchain.ts CHANGED
@@ -45,6 +45,7 @@ import { join } from "node:path";
45
45
  import {
46
46
  isExecutable,
47
47
  missingTools,
48
+ missingDependencies,
48
49
  probeTree,
49
50
  supportedPlatform,
50
51
  type Runner,
@@ -240,14 +241,20 @@ function yearAfter(lines: readonly string[], label: string): string | null {
240
241
  : null;
241
242
  }
242
243
 
243
- /** What a tree lacks: packages by `kpsewhich`, tools by executable. */
244
+ /**
245
+ * What a tree lacks: the venue's packages by `kpsewhich`, tools by executable, and packages the
246
+ * tree's own database depends on but never installed (`tlmgr check depends`).
247
+ */
244
248
  export interface Gaps {
245
249
  readonly packages: readonly string[];
246
250
  readonly tools: readonly string[];
251
+ readonly dependencies: readonly string[];
247
252
  }
248
253
 
249
254
  export const noGaps = (g: Gaps): boolean =>
250
- g.packages.length === 0 && g.tools.length === 0;
255
+ g.packages.length === 0 &&
256
+ g.tools.length === 0 &&
257
+ g.dependencies.length === 0;
251
258
 
252
259
  /** `acmart (acmart.cls), texcount (texcount)` — a gap named with the file that proves it. */
253
260
  export function describeGaps(g: Gaps, tex: TexRequirements): string {
@@ -258,6 +265,9 @@ export function describeGaps(g: Gaps, tex: TexRequirements): string {
258
265
  return [
259
266
  ...named(g.packages, tex.packages),
260
267
  ...named(g.tools, tex.tools),
268
+ ...g.dependencies.map(
269
+ (d) => `${d} (a TeX Live dependency the install left out)`,
270
+ ),
261
271
  ].join(", ");
262
272
  }
263
273
 
@@ -296,6 +306,7 @@ export function gapsOf(
296
306
  return {
297
307
  packages: probeTree(tree.bin, tex.packages, run),
298
308
  tools: missingTools(tex.tools, tree.bin),
309
+ dependencies: missingDependencies(tree.bin, run),
299
310
  };
300
311
  }
301
312
 
@@ -460,7 +471,8 @@ export function installPackages(
460
471
  let last: string[] = [];
461
472
  for (const mirror of mirrors) {
462
473
  if (noGaps(gaps)) break;
463
- const want = [...gaps.packages, ...gaps.tools];
474
+ const want = [...gaps.packages, ...gaps.tools, ...gaps.dependencies];
475
+ const repairsBase = gaps.dependencies.length > 0;
464
476
  io.log(` tlmgr: installing ${want.length} package(s) from ${mirror}…`);
465
477
  const r = io.run(
466
478
  join(tree.bin, "tlmgr"),
@@ -481,6 +493,10 @@ export function installPackages(
481
493
  ],
482
494
  };
483
495
  last = tail(r);
496
+ // A dependency of the base was missing, so the formats it feeds were never built (pdflatex dies
497
+ // before writing a log without them). Build the missing ones now that the package is in.
498
+ if (repairsBase)
499
+ io.run(join(tree.bin, "fmtutil-sys"), ["--missing"], quiet(io, TLMGR_MS));
484
500
  gaps = gapsOf(tree, tex, io.run);
485
501
  }
486
502
  return { ok: true, value: { gaps, tail: last } };
@@ -688,9 +704,17 @@ function report(o: Resolved, tree: CachedTree | null): number {
688
704
  o.log(binLine(tree));
689
705
  return 0;
690
706
  }
691
- const count = gaps.packages.length + gaps.tools.length;
707
+ const declared = gaps.packages.length + gaps.tools.length;
708
+ // A dependency the install left out is not one of the venue's declared packages: counted apart,
709
+ // so the line never reads "lacks 0 of 51" followed by a name.
710
+ const what = [
711
+ ...(declared ? [`${declared} of ${n} declared packages`] : []),
712
+ ...(gaps.dependencies.length
713
+ ? [`${gaps.dependencies.length} TeX Live base package(s)`]
714
+ : []),
715
+ ].join(" and ");
692
716
  o.log(
693
- `✗ TeX Live ${tree.year} in ${tree.dir} lacks ${count} of ${n} declared packages: ${describeGaps(gaps, o.tex)}`,
717
+ `✗ TeX Live ${tree.year} in ${tree.dir} lacks ${what}: ${describeGaps(gaps, o.tex)}`,
694
718
  );
695
719
  o.log(` run \`npx paperlint toolchain\` to install them`);
696
720
  return 1;
package/src/types.ts CHANGED
@@ -5,29 +5,27 @@ import type { RuleBlock } from "./rules-config.ts";
5
5
  * in someone's head: a typo in a config key read as "field not set", not as an error.
6
6
  */
7
7
 
8
- /** The consumer's settings — the `paperlint` key of its `package.json`. */
8
+ /**
9
+ * The project's settings — the root `paperlint.json`, optional. Every key is optional; the key list
10
+ * (and which keys a paper's own `paperlint.json` may also hold) is `SETTINGS_KEYS` in
11
+ * lib/paper-config.mjs.
12
+ */
9
13
  export interface PaperlintConfig {
10
- /** 🔴 REQUIRED. The paper director(ies), relative to the config file ITSELF. The field name is
14
+ /** The paper director(ies), relative to the project root. Default `papers`. The field name is
11
15
  * `PAPERS_DIR_FIELD` in lib/paper-config.mjs; code reads it through `papersDirOf()` in cli.ts. */
12
16
  papersDir?: string | string[];
13
- /** The command that prints the author list from .bib — each corpus has its own. */
14
- authorListCommand?: string;
15
- /** Per-paper typography debt: how many findings already exist; the number may only go down. */
16
- typographyDebt?: Record<string, Record<string, number>>;
17
- /** Frontmatter fields required for review notes, and the permitted values of each. */
18
- docFields?: Record<string, { values: string[] }>;
19
- /** Ignore review findings older than this date. */
20
- reviewSince?: string;
21
- /** How many findings a cold read must produce to count as a cold read. */
22
- minFindings?: number;
23
- /** The word with which review notes introduce the cause. */
24
- causeMarker?: string;
25
17
  /** Which files a paper directory must carry; `false` turns the check off entirely. */
26
18
  structure?: StructureConfig | false;
27
- /** REMOVED — `paperlint build` compiles the paper itself. Still typed so a leftover key can be named. */
28
- buildScripts?: unknown;
29
19
  /** ESLint config blocks appended after paperlint's own — PARSED by `parseSettings` in cli.ts. */
30
20
  rules?: readonly RuleBlock[];
21
+ /** The default venue preset of every paper that names none (`src/paper-settings.ts`). */
22
+ extends?: string | null;
23
+ /** The default kind of paper. */
24
+ kind?: string;
25
+ /** The default path of the built PDF, relative to each paper. */
26
+ pdf?: string;
27
+ /** A note for humans; ignored. */
28
+ $comment?: unknown;
31
29
  }
32
30
 
33
31
  export interface StructureConfig {
@@ -47,6 +45,8 @@ export interface Args {
47
45
  paths: string[];
48
46
  config: string | null;
49
47
  json: boolean;
48
+ /** `--fix`: `lint` writes every fix a rule offers, then reports what is left. */
49
+ fix: boolean;
50
50
  all: boolean;
51
51
  dryRun: boolean;
52
52
  /** `--check`: `toolchain` reports what is missing and changes nothing. */
@@ -60,11 +60,17 @@ export interface Args {
60
60
  paper: string | null;
61
61
  /** `--format tex|md` for `new` and `init --paper`. Validated by the command, not here. */
62
62
  format: string | null;
63
+ /** `--venue <preset>` for `new`: a shipped preset name, or a path to the project's own. */
64
+ venue: string | null;
65
+ /** `--kind <kind>` for `new`: one of the venue preset's kinds. */
66
+ kind: string | null;
63
67
  /** `--hooks=<mode>` — parsed only so it can be REFUSED by name rather than read as a path. */
64
68
  hooksMode: string | null;
65
69
  help?: boolean;
66
70
  /** A flag that turned out to have no value. A non-empty field is a REFUSAL, not a default. */
67
71
  missingValue?: string;
72
+ /** The first flag the parser does not know. Refused, never read as a path. */
73
+ unknownFlag?: string;
68
74
  }
69
75
 
70
76
  /** A finding about the PRESENCE of a file — what an ESLint rule cannot express. */
@@ -102,5 +108,17 @@ export interface BuildResult {
102
108
 
103
109
  /** The result of reading the config: either data, or the exit code the caller exits with. */
104
110
  export type ConfigRead =
105
- | { opts: PaperlintConfig; configPath: string | null; code?: undefined }
106
- | { code: number; opts?: undefined; configPath?: undefined };
111
+ | {
112
+ opts: PaperlintConfig;
113
+ /** The root `paperlint.json`, or null when the project has none. */
114
+ configPath: string | null;
115
+ /** The project root: `papersDir` and the `rules` globs are relative to it. */
116
+ root: string;
117
+ code?: undefined;
118
+ }
119
+ | {
120
+ code: number;
121
+ opts?: undefined;
122
+ configPath?: undefined;
123
+ root?: undefined;
124
+ };
@@ -173,7 +173,7 @@ describe("a paper that meets its venue", () => {
173
173
  });
174
174
 
175
175
  describe("a paper that names no venue", () => {
176
- it("no paperlint.json (a paper from before 2.1.0): every rule is silent, even with no facts and no PDF", () => {
176
+ it("no paperlint.json: every rule is silent, even with no facts and no PDF", () => {
177
177
  expect(lint({ venue: undefined, facts: null, pdf: null })).toEqual([]);
178
178
  });
179
179
 
@@ -228,16 +228,6 @@ describe("pdf/profile — the declaration must resolve, or nothing is judged", (
228
228
  expect(fs[0]?.message).toMatch(text);
229
229
  });
230
230
 
231
- it("🔴 a venue.json left from before 2.1.0 is not read — pdf/profile says to run `paperlint init`", () => {
232
- const fs = lint({
233
- facts: null,
234
- pdf: null,
235
- extra: { [`${PAPER}/venue.json`]: JSON.stringify(DECL) },
236
- });
237
- expect(ids(fs)).toEqual(["pdf/profile:legacySettings"]);
238
- expect(fs[0]?.message).toMatch(/npx paperlint init/);
239
- });
240
-
241
231
  it("a profile that does not parse is named with the file", () => {
242
232
  const fs = lint({
243
233
  venue: { extends: "paperlint:mine", kind: "short" },
@@ -302,11 +292,33 @@ describe("pdf/profile — the kind", () => {
302
292
  });
303
293
  });
304
294
 
295
+ describe("pdf/profile — a preset with no kinds", () => {
296
+ it("🔴 a preset with NO kinds (acm-sigconf) and no `kind`: nothing to pick, no finding", () => {
297
+ const fs = lint({
298
+ venue: { extends: "paperlint:acm-sigconf" },
299
+ facts: withFacts((f) => (f.body_pages = 99)),
300
+ });
301
+ expect(ids(fs).filter((id) => id.startsWith("pdf/profile"))).toEqual([]);
302
+ });
303
+
304
+ it("…but a `kind` named against a kindless preset is still an error that says it has none", () => {
305
+ const fs = lint({
306
+ venue: { extends: "paperlint:acm-sigconf", kind: "short" },
307
+ });
308
+ const profile = fs.filter((f) => f.rule === "pdf/profile");
309
+ expect(ids(profile)).toEqual(["pdf/profile:kindUnknown"]);
310
+ expect(profile[0]?.message).toMatch(/its kinds: \(none\)/);
311
+ });
312
+ });
313
+
305
314
  describe("pdf/measured — a paper that names a venue and was not measured says so (warn)", () => {
306
315
  it("no facts file: one finding, from pdf/measured only", () => {
307
316
  const fs = lint({ venue: DECL, facts: null, pdf: null });
308
317
  expect(ids(fs)).toEqual(["pdf/measured:unbuilt"]);
309
318
  expect(fs[0]?.message).toMatch(/paperlint build/);
319
+ // Guards: the message stays one short line — it is the first thing a new user sees.
320
+ expect(fs[0]?.message).toMatch(/^not built yet, so /);
321
+ expect(fs[0]?.message.length).toBeLessThan(110);
310
322
  });
311
323
 
312
324
  it("facts without geometry (no banal): pdf/measured names it, the three geometry rules are silent, fonts still judge", () => {
@@ -24,7 +24,7 @@
24
24
  * One rule per reason, and the others are silent — so a paper gets one finding that says what to
25
25
  * do, not six that say the same thing:
26
26
  *
27
- * no paperlint.json every rule silent — a paper from before 2.1.0 (`paperlint new` writes one)
27
+ * no paperlint.json every rule silent (`paperlint new` writes one)
28
28
  * extends null / absent pdf/measured (warn) — no venue chosen yet; it names the file to set
29
29
  * preset does not resolve pdf/profile (error) — a typo would otherwise switch every check off
30
30
  * not built / no facts pdf/measured (warn) — lint often runs before or without a build (the
@@ -48,10 +48,7 @@ import type { FlatGeometry } from "./domain/geometry.ts";
48
48
  import type { AbsolutePath } from "./domain/paths.ts";
49
49
  import { sha256Hex } from "./domain/sha256.ts";
50
50
  import type { Files } from "./ports/files.ts";
51
- import {
52
- LEGACY_PAPER_SETTINGS_MESSAGE,
53
- PAPER_SETTINGS_FILE,
54
- } from "../lib/paper-config.mjs";
51
+ import { CONFIG_FILE } from "../lib/paper-config.mjs";
55
52
 
56
53
  // ── the verdict's vocabulary ─────────────────────────────────────────────────────────
57
54
 
@@ -109,6 +106,11 @@ function kindOf(
109
106
  kind: string | null,
110
107
  ): Pick<Resolved, "kind" | "kindProblem"> {
111
108
  const known = [...format.kinds.keys()].join(", ") || "(none)";
109
+ // A preset with no kinds (`acm-sigconf`, a family a paper for an unprofiled venue extends
110
+ // directly) has no page limit to pick, so naming no kind is the only valid declaration. A kind
111
+ // named against it is still `kindUnknown`, whose "its kinds: (none)" says why.
112
+ if (kind === null && format.kinds.size === 0)
113
+ return { kind: null, kindProblem: null };
112
114
  if (kind === null)
113
115
  return {
114
116
  kind: null,
@@ -150,14 +152,11 @@ export function assessPaper(paperDir: string, deps: VenueRuleDeps): Assessment {
150
152
  if (p.kind === "none")
151
153
  return p.settings === null
152
154
  ? { kind: "no-venue" }
153
- : { kind: "no-preset", file: join(paperDir, PAPER_SETTINGS_FILE) };
155
+ : { kind: "no-preset", file: join(paperDir, CONFIG_FILE) };
154
156
  if (p.kind === "settings-problem")
155
157
  return {
156
158
  kind: "unresolved",
157
- finding:
158
- p.problem.kind === "legacy"
159
- ? finding("legacySettings")
160
- : finding("settingsBroken", { why: p.problem.why }),
159
+ finding: finding("settingsBroken", { why: p.problem.why }),
161
160
  };
162
161
  if (p.kind === "preset-problem")
163
162
  return {
@@ -415,11 +414,10 @@ const META: Readonly<Record<VenueRuleName, Meta>> = {
415
414
  "the venue preset a paper's paperlint.json extends resolves, and the kind it names exists",
416
415
  },
417
416
  messages: {
418
- settingsBroken: `${PAPER_SETTINGS_FILE} cannot be read: {{why}}`,
419
- legacySettings: `this paper's venue checks do not run — ${LEGACY_PAPER_SETTINGS_MESSAGE}`,
417
+ settingsBroken: `${CONFIG_FILE} cannot be read: {{why}}`,
420
418
  preset:
421
419
  "{{why}} — so this paper's page limit, fonts and format are not checked. Fix `extends` in its paperlint.json, or turn pdf/profile off for this paper",
422
- kindMissing: `${PAPER_SETTINGS_FILE} names no \`kind\`, so the page limit of \`{{venue}}\` is not checked; its kinds: {{known}}`,
420
+ kindMissing: `${CONFIG_FILE} names no \`kind\`, so the page limit of \`{{venue}}\` is not checked; its kinds: {{known}}`,
423
421
  kindUnknown:
424
422
  "`{{venue}}` has no kind `{{kind}}`, so the page limit is not checked; its kinds: {{known}}",
425
423
  },
@@ -432,7 +430,7 @@ const META: Readonly<Record<VenueRuleName, Meta>> = {
432
430
  },
433
431
  messages: {
434
432
  unbuilt:
435
- "this paper names the venue `{{venue}}`, but {{file}} does not exist, so its page limit, fonts and format were NOT checked — run `paperlint build` before `paperlint lint`",
433
+ "not built yet, so `{{venue}}`'s page limit, fonts and format are not checked — run `paperlint build`",
436
434
  noPreset:
437
435
  'this paper names no venue preset yet, so its page limit, fonts and format are not checked — set "extends" in {{file}} (e.g. "paperlint:agenticdev"; see docs/rules.md)',
438
436
  noGeometry:
@@ -1,4 +1,4 @@
1
1
  {
2
- "$comment": "Name this paper's venue preset in \"extends\", e.g. \"paperlint:agenticdev\" (shipped: acm-sigconf, agenticdev, aisec, realm — see paperlint's docs/rules.md) or \"./venues/my-venue.jsonc\"; add \"kind\" for its page limit. Until then the page limit, fonts and format are not checked.",
2
+ "$comment": "Name this paper's venue preset in \"extends\": \"paperlint:<name>\" for a shipped one, e.g. \"paperlint:agenticdev\" (`npx paperlint --help` lists them), or a path to your own preset relative to THIS file, e.g. \"../../venues/my-venue.jsonc\"; add \"kind\" for its page limit. `npx paperlint new <name> --venue <preset>` writes both for a new paper. Until then the page limit, fonts and format are not checked.",
3
3
  "extends": null
4
4
  }