paperlint 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/CLAUDE.md +6 -1
  2. package/README.md +233 -224
  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.d.ts +15 -0
  13. package/dist/build.d.ts.map +1 -1
  14. package/dist/build.js +38 -4
  15. package/dist/build.js.map +1 -1
  16. package/dist/cli.d.ts +28 -25
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +277 -265
  19. package/dist/cli.js.map +1 -1
  20. package/dist/doctor.d.ts.map +1 -1
  21. package/dist/doctor.js +40 -72
  22. package/dist/doctor.js.map +1 -1
  23. package/dist/facts-file.d.ts +2 -2
  24. package/dist/facts-file.js +2 -2
  25. package/dist/hooks-settings.d.ts +0 -21
  26. package/dist/hooks-settings.d.ts.map +1 -1
  27. package/dist/hooks-settings.js +17 -135
  28. package/dist/hooks-settings.js.map +1 -1
  29. package/dist/init.d.ts +39 -29
  30. package/dist/init.d.ts.map +1 -1
  31. package/dist/init.js +143 -172
  32. package/dist/init.js.map +1 -1
  33. package/dist/link-skills.d.ts +1 -1
  34. package/dist/link-skills.d.ts.map +1 -1
  35. package/dist/link-skills.js +11 -29
  36. package/dist/link-skills.js.map +1 -1
  37. package/dist/new-paper.d.ts.map +1 -1
  38. package/dist/new-paper.js +2 -13
  39. package/dist/new-paper.js.map +1 -1
  40. package/dist/paper-files.d.ts +63 -0
  41. package/dist/paper-files.d.ts.map +1 -0
  42. package/dist/paper-files.js +69 -0
  43. package/dist/paper-files.js.map +1 -0
  44. package/dist/paper-settings.d.ts +13 -41
  45. package/dist/paper-settings.d.ts.map +1 -1
  46. package/dist/paper-settings.js +71 -71
  47. package/dist/paper-settings.js.map +1 -1
  48. package/dist/ports/check-references.d.ts +31 -0
  49. package/dist/ports/check-references.d.ts.map +1 -0
  50. package/dist/ports/check-references.js +7 -0
  51. package/dist/ports/check-references.js.map +1 -0
  52. package/dist/presets.d.ts +1 -1
  53. package/dist/presets.d.ts.map +1 -1
  54. package/dist/presets.js +4 -6
  55. package/dist/presets.js.map +1 -1
  56. package/dist/reference-rules.d.ts +47 -0
  57. package/dist/reference-rules.d.ts.map +1 -0
  58. package/dist/reference-rules.js +156 -0
  59. package/dist/reference-rules.js.map +1 -0
  60. package/dist/references.d.ts +36 -0
  61. package/dist/references.d.ts.map +1 -0
  62. package/dist/references.js +91 -0
  63. package/dist/references.js.map +1 -0
  64. package/dist/rules-config.d.ts +5 -3
  65. package/dist/rules-config.d.ts.map +1 -1
  66. package/dist/rules-config.js +15 -6
  67. package/dist/rules-config.js.map +1 -1
  68. package/dist/structure.js +3 -3
  69. package/dist/structure.js.map +1 -1
  70. package/dist/tex-requirements.js +2 -2
  71. package/dist/tex-requirements.js.map +1 -1
  72. package/dist/types.d.ts +20 -18
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/venue-rules.d.ts.map +1 -1
  75. package/dist/venue-rules.js +11 -9
  76. package/dist/venue-rules.js.map +1 -1
  77. package/docs/configuration.md +119 -104
  78. package/docs/e2e.md +1 -1
  79. package/docs/install.md +25 -40
  80. package/docs/optional-rules.md +5 -4
  81. package/docs/rules.md +60 -23
  82. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  83. package/eslint-rules/latex-language.mjs +29 -1
  84. package/eslint-rules/paper-stages.harness.mjs +1 -117
  85. package/eslint-rules/paper-stages.mjs +0 -120
  86. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  87. package/eslint-rules/paper-typography.mjs +267 -192
  88. package/eslint-rules/paper-typography.test.mjs +297 -0
  89. package/eslint-rules/papers.harness.mjs +8 -12
  90. package/eslint-rules/papers.mjs +17 -31
  91. package/eslint-rules/pdf-last-page-balance.mjs +4 -7
  92. package/eslint-rules/review-frontmatter.mjs +118 -0
  93. package/eslint-rules/review-frontmatter.schema.json +36 -0
  94. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  95. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  96. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  97. package/eslint.config.mjs +0 -15
  98. package/fixtures/real-markdown-paper/baseline.json +6 -7
  99. package/hooks/hooks.harness.mjs +39 -98
  100. package/hooks/hooks.mutations.mjs +6 -6
  101. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  102. package/hooks/paper-edit-guard.hook.mjs +31 -42
  103. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  104. package/hooks/paper-status-gates.hook.mjs +21 -19
  105. package/hooks/paper-status-gates.sh +8 -13
  106. package/lib/paper-config.d.mts +15 -25
  107. package/lib/paper-config.harness.mjs +18 -35
  108. package/lib/paper-config.mjs +83 -105
  109. package/lib/paper-config.mutations.mjs +2 -2
  110. package/lib/skill-checks.mjs +1 -1
  111. package/lib/skill-trigger-cases.harness.mjs +9 -8
  112. package/lib/skill-trigger-cases.mjs +7 -15
  113. package/package.json +1 -1
  114. package/scripts/eslint-report-guard.mjs +1 -1
  115. package/scripts/harness-api.frozen.json +0 -3
  116. package/scripts/mutation-batteries.frozen.json +4 -13
  117. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  118. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  119. package/skills/cold-read-diff/SKILL.md +20 -1
  120. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  121. package/skills/grade-paper-writing/SKILL.md +20 -1
  122. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  123. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  124. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  125. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  126. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  127. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  128. package/skills/plan-paper-timeline/SKILL.md +2 -2
  129. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  130. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  131. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  132. package/skills/render-paper/check-render.sh +2 -3
  133. package/skills/render-paper/render-paper.harness.mjs +2 -2
  134. package/skills/verify-citations/SKILL.md +10 -1
  135. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  136. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  137. package/src/adapters/references/index.ts +102 -0
  138. package/src/adapters/references/reach.io.ts +12 -0
  139. package/src/build.harness.mjs +8 -4
  140. package/src/build.ts +62 -6
  141. package/src/cli.harness.mjs +147 -292
  142. package/src/cli.mutations.mjs +35 -44
  143. package/src/cli.ts +344 -304
  144. package/src/doctor.harness.mjs +41 -84
  145. package/src/doctor.mutations.mjs +9 -9
  146. package/src/doctor.ts +44 -89
  147. package/src/facts-file.test.ts +0 -7
  148. package/src/facts-file.ts +2 -2
  149. package/src/hooks-settings.harness.mjs +7 -92
  150. package/src/hooks-settings.mutations.mjs +2 -17
  151. package/src/hooks-settings.ts +18 -162
  152. package/src/init.test.ts +206 -0
  153. package/src/init.ts +186 -230
  154. package/src/link-skills.harness.mjs +1 -38
  155. package/src/link-skills.mutations.mjs +9 -5
  156. package/src/link-skills.ts +10 -34
  157. package/src/lint-fix.test.ts +120 -0
  158. package/src/new-paper.test.ts +1 -13
  159. package/src/new-paper.ts +2 -17
  160. package/src/paper-files.test.ts +290 -0
  161. package/src/paper-files.ts +144 -0
  162. package/src/paper-settings-commands.test.ts +84 -121
  163. package/src/paper-settings.test.ts +93 -64
  164. package/src/paper-settings.ts +99 -97
  165. package/src/ports/check-references.ts +28 -0
  166. package/src/presets.ts +5 -15
  167. package/src/reference-rules.ts +229 -0
  168. package/src/references.test.ts +209 -0
  169. package/src/references.ts +138 -0
  170. package/src/rules-config.harness.mjs +24 -8
  171. package/src/rules-config.ts +19 -7
  172. package/src/structure.harness.mjs +3 -1
  173. package/src/structure.ts +3 -3
  174. package/src/tex-requirements.ts +2 -2
  175. package/src/types.ts +30 -18
  176. package/src/venue-rules.test.ts +20 -11
  177. package/src/venue-rules.ts +11 -13
  178. package/eslint-rules/doc-fields.harness.mjs +0 -336
  179. package/eslint-rules/doc-fields.mjs +0 -186
  180. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  181. package/eslint-rules/paper-typography.harness.mjs +0 -291
  182. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  183. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  184. package/eslint-rules/review-findings-cause.mjs +0 -135
  185. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  186. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  187. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  188. package/fixtures/review-findings-cause/clean.md +0 -17
  189. package/fixtures/review-findings-cause/defect.md +0 -14
  190. package/fixtures/review-findings-cause/old-debt.md +0 -14
  191. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The references a build checked, end to end without a network: the `references` build step
3
+ * records what a (fake) checker answers, and the offline rules judge that record.
4
+ *
5
+ * a pass is recorded, with the hash of the bibliography checked
6
+ * one mismatched entry → paper/author-list on THAT entry's line
7
+ * the bibliography edited after the build → paper/refs-fresh, and the per-entry rules go quiet
8
+ * offline → the step still succeeds (the build is not failed) and paper/refs-checked warns
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+ import { ESLint } from "eslint";
12
+ import { join } from "node:path";
13
+ import { memoryFiles } from "./adapters/memory/index.ts";
14
+ import type { AbsolutePath } from "./domain/paths.ts";
15
+ import type {
16
+ CheckReferences,
17
+ EntryVerdict,
18
+ } from "./ports/check-references.ts";
19
+ import { referencesStep } from "./build.ts";
20
+ import {
21
+ bibHash,
22
+ bibliographyOf,
23
+ readReferences,
24
+ referencesPath,
25
+ } from "./references.ts";
26
+ import { referenceRules, REFERENCE_RULE_LEVELS } from "./reference-rules.ts";
27
+ // @ts-expect-error — an ESLint language in .mjs, it has no types
28
+ import { texLanguage } from "../eslint-rules/latex-language.mjs";
29
+
30
+ const PAPER = "/work/papers/p";
31
+ const TEX = (entries: string) =>
32
+ [
33
+ "\\documentclass{acmart}",
34
+ "\\begin{filecontents*}{refs.bib}",
35
+ entries,
36
+ "\\end{filecontents*}",
37
+ "\\begin{document}",
38
+ "x",
39
+ "\\end{document}",
40
+ "",
41
+ ].join("\n");
42
+ const ENTRIES =
43
+ "@inproceedings{schick2023,\n title = {Toolformer},\n author = {Schick, Timo},\n booktitle = {NeurIPS}\n}\n@misc{other,\n title = {Other},\n url = {https://x.org}\n}";
44
+
45
+ const verdict = (
46
+ key: string,
47
+ authors: EntryVerdict["authors"] = "match",
48
+ exists: EntryVerdict["exists"] = "true",
49
+ ): EntryVerdict => ({
50
+ key,
51
+ exists,
52
+ authors,
53
+ ...(authors === "mismatch" ? { why: "missing hambro" } : {}),
54
+ });
55
+
56
+ const checker =
57
+ (entries: readonly EntryVerdict[]): CheckReferences =>
58
+ async () => ({ kind: "checked", entries });
59
+ const offline: CheckReferences = async () => ({
60
+ kind: "not-checked",
61
+ why: "the citation services cannot be reached (fetch failed)",
62
+ });
63
+
64
+ /** Run the build step on a paper held in memory; returns the files and the step's outcome. */
65
+ async function build(tex: string, check: CheckReferences) {
66
+ const files = memoryFiles({ [`${PAPER}/paper.tex`]: tex });
67
+ const out = await referencesStep.run({
68
+ paperDir: PAPER,
69
+ env: {},
70
+ run: (() => ({})) as never,
71
+ readPdf: (() => null) as never,
72
+ measure: (() => null) as never,
73
+ files,
74
+ checkReferences: check,
75
+ });
76
+ return { files, out };
77
+ }
78
+
79
+ /**
80
+ * The rules as an ESLint plugin. Their context type is the narrow one they read (a custom
81
+ * language's SourceCode), which ESLint's JS-centred plugin type does not describe.
82
+ */
83
+ const asPlugin = (rules: object): ESLint.Plugin => ({ rules }) as ESLint.Plugin;
84
+
85
+ /** Lint `tex` as the paper's paper.tex against what `files` recorded. */
86
+ async function lint(files: ReturnType<typeof memoryFiles>, tex: string) {
87
+ files.writeAtomic(
88
+ `${PAPER}/paper.tex` as AbsolutePath,
89
+ new TextEncoder().encode(tex),
90
+ );
91
+ const eslint = new ESLint({
92
+ cwd: "/work",
93
+ overrideConfigFile: true,
94
+ overrideConfig: [
95
+ {
96
+ files: ["**/paper.tex"],
97
+ plugins: {
98
+ tex: { languages: { latex: texLanguage } },
99
+ paper: asPlugin(referenceRules({ files })),
100
+ },
101
+ language: "tex/latex",
102
+ rules: REFERENCE_RULE_LEVELS,
103
+ },
104
+ ],
105
+ });
106
+ const [res] = await eslint.lintText(tex, {
107
+ filePath: join(PAPER, "paper.tex"),
108
+ });
109
+ return res!.messages;
110
+ }
111
+
112
+ describe("the references build step", () => {
113
+ it("records a pass, with the hash of the bibliography it checked", async () => {
114
+ const tex = TEX(ENTRIES);
115
+ const { files, out } = await build(
116
+ tex,
117
+ checker([verdict("schick2023"), verdict("other")]),
118
+ );
119
+ expect(out.ok).toBe(true);
120
+ const doc = readReferences(files, PAPER);
121
+ expect(doc?.status).toBe("checked");
122
+ expect(doc?.bib.sha256).toBe(bibHash(bibliographyOf(files, PAPER)!));
123
+ expect(await lint(files, tex)).toEqual([]);
124
+ });
125
+
126
+ it("🔴 offline: the step succeeds — the build is not failed — and lint warns once", async () => {
127
+ const tex = TEX(ENTRIES);
128
+ const { files, out } = await build(tex, offline);
129
+ expect(out.ok).toBe(true);
130
+ expect(readReferences(files, PAPER)?.status).toBe("not-checked");
131
+ const msgs = await lint(files, tex);
132
+ expect(msgs.map((m) => [m.ruleId, m.severity])).toEqual([
133
+ ["paper/refs-checked", 1],
134
+ ]);
135
+ expect(msgs[0]!.message).toMatch(/cannot be reached/);
136
+ });
137
+
138
+ it("a checker that throws is recorded as not checked, not as a failed build", async () => {
139
+ const { files, out } = await build(TEX(ENTRIES), async () => {
140
+ throw new Error("DBLP exploded");
141
+ });
142
+ expect(out.ok).toBe(true);
143
+ expect(readReferences(files, PAPER)?.why).toMatch(/DBLP exploded/);
144
+ });
145
+ });
146
+
147
+ describe("the reference rules", () => {
148
+ it("one mismatched entry → paper/author-list on that entry's own line", async () => {
149
+ const tex = TEX(ENTRIES);
150
+ const { files } = await build(
151
+ tex,
152
+ checker([verdict("schick2023", "mismatch"), verdict("other")]),
153
+ );
154
+ const msgs = await lint(files, tex);
155
+ expect(msgs.map((m) => m.ruleId)).toEqual(["paper/author-list"]);
156
+ expect(msgs[0]!.line).toBe(3);
157
+ expect(msgs[0]!.message).toMatch(/`schick2023`.*missing hambro/);
158
+ });
159
+
160
+ it("an identifier that provably fails → paper/cite-exists on that entry", async () => {
161
+ const tex = TEX(ENTRIES);
162
+ const { files } = await build(
163
+ tex,
164
+ checker([verdict("schick2023"), verdict("other", "skipped", "false")]),
165
+ );
166
+ const msgs = await lint(files, tex);
167
+ expect(msgs.map((m) => [m.ruleId, m.line])).toEqual([
168
+ ["paper/cite-exists", 8],
169
+ ]);
170
+ });
171
+
172
+ it("🔴 the bibliography edited after the build → refs-fresh, and the stale verdicts are not judged", async () => {
173
+ const { files } = await build(
174
+ TEX(ENTRIES),
175
+ checker([verdict("schick2023", "mismatch"), verdict("other")]),
176
+ );
177
+ const edited = TEX(
178
+ ENTRIES.replace("Schick, Timo", "Schick, Timo and Hambro, Eric"),
179
+ );
180
+ const msgs = await lint(files, edited);
181
+ expect(msgs.map((m) => [m.ruleId, m.severity])).toEqual([
182
+ ["paper/refs-fresh", 2],
183
+ ]);
184
+ });
185
+
186
+ it("the same bibliography, unchanged → silent", async () => {
187
+ const tex = TEX(ENTRIES);
188
+ const { files } = await build(tex, checker([verdict("schick2023")]));
189
+ expect(await lint(files, tex)).toEqual([]);
190
+ });
191
+
192
+ it("never built → one warning naming the command", async () => {
193
+ const files = memoryFiles({});
194
+ const msgs = await lint(files, TEX(ENTRIES));
195
+ expect(msgs.map((m) => m.ruleId)).toEqual(["paper/refs-checked"]);
196
+ expect(msgs[0]!.message).toMatch(/npx paperlint build/);
197
+ });
198
+
199
+ it("a paper with no bibliography gets nothing", async () => {
200
+ const files = memoryFiles({});
201
+ expect(
202
+ await lint(
203
+ files,
204
+ "\\documentclass{article}\n\\begin{document}x\\end{document}\n",
205
+ ),
206
+ ).toEqual([]);
207
+ expect(referencesPath(PAPER)).toBe(`${PAPER}/_build/references.json`);
208
+ });
209
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * THE REFERENCES A BUILD CHECKED — `<paper>/_build/references.json`, written by `paperlint build`
3
+ * and read, offline, by the lint rules in `reference-rules.ts`.
4
+ *
5
+ * The package ships two online checks: `verify-cites` (the cited work exists and its title
6
+ * matches) and `bib-authors` (the authors are those of the version cited, not the preprint's).
7
+ * Until 3.0.0 lint could only ask whether someone had run them — by finding the word
8
+ * "bib-authors" in a scorecard table, or a command the project configured. Now the build runs
9
+ * them and records the result as data, beside the facts it already writes about the PDF:
10
+ *
11
+ * { "schema": 1,
12
+ * "bib": { "source": "paper.tex", "sha256": "…" }, the bibliography that was checked
13
+ * "status": "checked" | "not-checked", "why": "…", not-checked: nothing could be asked
14
+ * "entries": [ { "key", "exists", "authors", "why" } ] }
15
+ *
16
+ * A sibling file, not a new field of `paper.facts.json`: those facts are about ONE PDF and are
17
+ * stale when it changes (`pdf_sha256`); these are about the bibliography and are stale when IT
18
+ * changes (`bib.sha256`). One staleness key per file, the way `pdf/fresh` already works.
19
+ *
20
+ * 🔴 THE STEP NEVER FAILS THE BUILD. A build without network still builds the PDF; the reference
21
+ * step records `not-checked` and the lint rule says so, as a warning. Recording "not checked" as
22
+ * a pass would be the counter that counts what it never looked at.
23
+ */
24
+ import { join } from "node:path";
25
+ import type { AbsolutePath } from "./domain/paths.ts";
26
+ import { sha256Hex } from "./domain/sha256.ts";
27
+ import type { Files } from "./ports/files.ts";
28
+ import type {
29
+ CheckReferences,
30
+ EntryVerdict,
31
+ ReferencesCheck,
32
+ } from "./ports/check-references.ts";
33
+ // @ts-expect-error — an ESLint rule module in .mjs, it has no types
34
+ import { bibRange } from "../eslint-rules/paper-typography.mjs";
35
+
36
+ export const REFERENCES_SCHEMA = 1;
37
+ export const REFERENCES_FILE = "references.json";
38
+
39
+ /** Where a paper's reference verdicts live — beside `paper.facts.json`. */
40
+ export const referencesPath = (paperDir: string): string =>
41
+ join(paperDir, "_build", REFERENCES_FILE);
42
+
43
+ const at = (p: string): AbsolutePath => p as AbsolutePath;
44
+ const text = (files: Files, p: string): string | null => {
45
+ const b = files.readBytes(at(p));
46
+ return b === null ? null : new TextDecoder().decode(b);
47
+ };
48
+
49
+ /** The bibliography a paper carries: inside `paper.tex` (`filecontents`), else `refs.bib`. */
50
+ export interface Bibliography {
51
+ readonly source: "paper.tex" | "refs.bib";
52
+ readonly text: string;
53
+ /** Where `text` starts in the source file — so a finding can point at an entry. */
54
+ readonly offset: number;
55
+ }
56
+
57
+ export function bibliographyOf(
58
+ files: Files,
59
+ paperDir: string,
60
+ ): Bibliography | null {
61
+ const tex = text(files, join(paperDir, "paper.tex"));
62
+ const inline = tex === null ? null : bibRange(tex);
63
+ if (inline)
64
+ return { source: "paper.tex", text: inline.body, offset: inline.bodyStart };
65
+ const bib = text(files, join(paperDir, "refs.bib"));
66
+ return bib === null ? null : { source: "refs.bib", text: bib, offset: 0 };
67
+ }
68
+
69
+ export const bibHash = (b: Bibliography): string =>
70
+ sha256Hex(new TextEncoder().encode(b.text));
71
+
72
+ /** What `_build/references.json` holds. */
73
+ export interface ReferencesDocument {
74
+ readonly schema: number;
75
+ readonly bib: { readonly source: string; readonly sha256: string };
76
+ readonly status: "checked" | "not-checked";
77
+ readonly why?: string;
78
+ readonly entries: readonly EntryVerdict[];
79
+ }
80
+
81
+ export const documentOf = (
82
+ bib: Bibliography,
83
+ check: ReferencesCheck,
84
+ ): ReferencesDocument => ({
85
+ schema: REFERENCES_SCHEMA,
86
+ bib: { source: bib.source, sha256: bibHash(bib) },
87
+ ...(check.kind === "checked"
88
+ ? { status: "checked" as const, entries: check.entries }
89
+ : { status: "not-checked" as const, why: check.why, entries: [] }),
90
+ });
91
+
92
+ /** The recorded verdicts, or null when there are none or they do not parse. */
93
+ export function readReferences(
94
+ files: Files,
95
+ paperDir: string,
96
+ ): ReferencesDocument | null {
97
+ const raw = text(files, referencesPath(paperDir));
98
+ if (raw === null) return null;
99
+ try {
100
+ const d = JSON.parse(raw) as ReferencesDocument;
101
+ return d?.schema === REFERENCES_SCHEMA && Array.isArray(d.entries)
102
+ ? d
103
+ : null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * The build step's work: check the bibliography and record the verdicts. Returns the one-line
111
+ * note the build prints. A checker that throws is recorded as `not-checked` — the step reports,
112
+ * it never refuses.
113
+ */
114
+ export async function recordReferences(
115
+ files: Files,
116
+ paperDir: string,
117
+ check: CheckReferences,
118
+ ): Promise<string> {
119
+ const bib = bibliographyOf(files, paperDir);
120
+ if (bib === null) return "no bibliography — nothing to check";
121
+ let result: ReferencesCheck;
122
+ try {
123
+ result = await check(bib.text);
124
+ } catch (e) {
125
+ result = { kind: "not-checked", why: (e as Error).message };
126
+ }
127
+ const doc = documentOf(bib, result);
128
+ files.writeAtomic(
129
+ at(referencesPath(paperDir)),
130
+ new TextEncoder().encode(`${JSON.stringify(doc, null, 2)}\n`),
131
+ );
132
+ if (doc.status === "not-checked")
133
+ return `references NOT checked — ${doc.why ?? "no reason given"}; lint will say so`;
134
+ const bad = doc.entries.filter(
135
+ (e) => e.exists === "false" || e.authors === "mismatch",
136
+ ).length;
137
+ return `references: ${String(doc.entries.length)} checked, ${String(bad)} failing → _build/${REFERENCES_FILE}`;
138
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `rules-config.ts` — parsing the `rules` key into ESLint config blocks, and deriving which rules
3
- * paperlint ships. The end-to-end path (a consumer's package.json → `paperlint lint`) is in `cli.harness.mjs`;
3
+ * paperlint ships. The end-to-end path (a project's paperlint.json → `paperlint lint`) is in `cli.harness.mjs`;
4
4
  * this pins the parser's output shape and each refusal.
5
5
  */
6
6
  import assert from "node:assert/strict";
@@ -46,23 +46,39 @@ check(
46
46
  JSON.stringify(r),
47
47
  );
48
48
  }
49
+ {
50
+ const r = parse({ "pdf/last-page-balance": "error" });
51
+ check(
52
+ "the object form is ONE block with no `files` — narrowed to each rule's own files later",
53
+ r.ok &&
54
+ JSON.stringify(r.value) ===
55
+ JSON.stringify([
56
+ {
57
+ basePath: "/consumer",
58
+ rules: { "pdf/last-page-balance": "error" },
59
+ },
60
+ ]),
61
+ JSON.stringify(r),
62
+ );
63
+ }
49
64
  check(
50
65
  "both severity spellings are accepted",
51
66
  parse([{ rules: { "paper/typography": 0, "pdf/last-page-balance": "warn" } }])
52
67
  .ok,
53
68
  );
54
69
  const refusals = [
55
- [{ a: 1 }, "S.rules must be a list"],
56
- [[42], "S.rules[0] must be an object"],
57
- [[{ rules: [] }], "S.rules[0].rules must be an object"],
58
- [[{ files: [], rules: {} }], "S.rules[0].files must be a non-empty list"],
70
+ ["pdf/last-page-balance", 'S → "rules" must be { "<rule>": "<severity>" }'],
71
+ [{ a: 1 }, '"a" is not a rule paperlint ships'],
72
+ [[42], "S → rules[0] must be an object"],
73
+ [[{ rules: [] }], "S → rules[0].rules must be an object"],
74
+ [[{ files: [], rules: {} }], "S → rules[0].files must be a non-empty list"],
59
75
  [
60
76
  [{ files: ["x"], ignores: [1], rules: {} }],
61
- "S.rules[0].ignores must be a non-empty list",
77
+ "S → rules[0].ignores must be a non-empty list",
62
78
  ],
63
79
  [
64
80
  [{ languageOptions: {}, rules: {} }],
65
- 'S.rules[0]: unknown key "languageOptions"',
81
+ 'S → rules[0]: unknown key "languageOptions"',
66
82
  ],
67
83
  [
68
84
  [{ rules: { "markdown/no-html": "error" } }],
@@ -70,7 +86,7 @@ const refusals = [
70
86
  ],
71
87
  [
72
88
  [{ rules: { "paper/typography": ["loud"] } }],
73
- 'S.rules[0].rules["paper/typography"]: ["loud"] is not a severity',
89
+ 'S → rules[0].rules["paper/typography"]: ["loud"] is not a severity',
74
90
  ],
75
91
  ];
76
92
  for (const [raw, says] of refusals) {
@@ -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
  }
@@ -72,7 +72,9 @@ try {
72
72
  check(
73
73
  "and the message names the CONSEQUENCE, not a restatement of the condition",
74
74
  /is checked/.test(at("no-scorecard")[0].message) &&
75
- /stages, sources or authors/.test(at("no-scorecard")[0].message),
75
+ /stages, sources or research question/.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 `\`paper/stages\`, \`paper/source\` and \`paper/research-question\` read this file, so nothing \`${dirName}\` declares about its stages, sources or research question is checked`;
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
  : {
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. */
@@ -102,5 +102,17 @@ export interface BuildResult {
102
102
 
103
103
  /** The result of reading the config: either data, or the exit code the caller exits with. */
104
104
  export type ConfigRead =
105
- | { opts: PaperlintConfig; configPath: string | null; code?: undefined }
106
- | { code: number; opts?: undefined; configPath?: undefined };
105
+ | {
106
+ opts: PaperlintConfig;
107
+ /** The root `paperlint.json`, or null when the project has none. */
108
+ configPath: string | null;
109
+ /** The project root: `papersDir` and the `rules` globs are relative to it. */
110
+ root: string;
111
+ code?: undefined;
112
+ }
113
+ | {
114
+ code: number;
115
+ opts?: undefined;
116
+ configPath?: undefined;
117
+ root?: undefined;
118
+ };
@@ -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,6 +292,25 @@ 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 });