paperlint 2.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/CLAUDE.md +6 -1
  2. package/CONTRIBUTING.md +21 -0
  3. package/README.md +231 -180
  4. package/action.yml +4 -6
  5. package/dist/adapters/references/index.d.ts +13 -0
  6. package/dist/adapters/references/index.d.ts.map +1 -0
  7. package/dist/adapters/references/index.js +55 -0
  8. package/dist/adapters/references/index.js.map +1 -0
  9. package/dist/adapters/references/reach.io.d.ts +3 -0
  10. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  11. package/dist/adapters/references/reach.io.js +14 -0
  12. package/dist/adapters/references/reach.io.js.map +1 -0
  13. package/dist/build-engine.d.ts.map +1 -1
  14. package/dist/build-engine.js +5 -2
  15. package/dist/build-engine.js.map +1 -1
  16. package/dist/build.d.ts +16 -1
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/build.js +40 -5
  19. package/dist/build.js.map +1 -1
  20. package/dist/cli.d.ts +37 -20
  21. package/dist/cli.d.ts.map +1 -1
  22. package/dist/cli.js +377 -251
  23. package/dist/cli.js.map +1 -1
  24. package/dist/doctor.d.ts.map +1 -1
  25. package/dist/doctor.js +37 -52
  26. package/dist/doctor.js.map +1 -1
  27. package/dist/facts-file.d.ts +33 -9
  28. package/dist/facts-file.d.ts.map +1 -1
  29. package/dist/facts-file.js +117 -17
  30. package/dist/facts-file.js.map +1 -1
  31. package/dist/hooks-settings.d.ts +0 -21
  32. package/dist/hooks-settings.d.ts.map +1 -1
  33. package/dist/hooks-settings.js +17 -135
  34. package/dist/hooks-settings.js.map +1 -1
  35. package/dist/init.d.ts +40 -19
  36. package/dist/init.d.ts.map +1 -1
  37. package/dist/init.js +144 -109
  38. package/dist/init.js.map +1 -1
  39. package/dist/link-skills.d.ts +1 -1
  40. package/dist/link-skills.d.ts.map +1 -1
  41. package/dist/link-skills.js +11 -29
  42. package/dist/link-skills.js.map +1 -1
  43. package/dist/new-paper.d.ts +2 -2
  44. package/dist/new-paper.d.ts.map +1 -1
  45. package/dist/new-paper.js +6 -2
  46. package/dist/new-paper.js.map +1 -1
  47. package/dist/paper-files.d.ts +63 -0
  48. package/dist/paper-files.d.ts.map +1 -0
  49. package/dist/paper-files.js +69 -0
  50. package/dist/paper-files.js.map +1 -0
  51. package/dist/paper-settings.d.ts +40 -0
  52. package/dist/paper-settings.d.ts.map +1 -0
  53. package/dist/paper-settings.js +144 -0
  54. package/dist/paper-settings.js.map +1 -0
  55. package/dist/ports/check-references.d.ts +31 -0
  56. package/dist/ports/check-references.d.ts.map +1 -0
  57. package/dist/ports/check-references.js +7 -0
  58. package/dist/ports/check-references.js.map +1 -0
  59. package/dist/presets.d.ts +84 -0
  60. package/dist/presets.d.ts.map +1 -0
  61. package/dist/presets.js +184 -0
  62. package/dist/presets.js.map +1 -0
  63. package/dist/reference-rules.d.ts +47 -0
  64. package/dist/reference-rules.d.ts.map +1 -0
  65. package/dist/reference-rules.js +156 -0
  66. package/dist/reference-rules.js.map +1 -0
  67. package/dist/references.d.ts +36 -0
  68. package/dist/references.d.ts.map +1 -0
  69. package/dist/references.js +91 -0
  70. package/dist/references.js.map +1 -0
  71. package/dist/rules-config.d.ts +11 -2
  72. package/dist/rules-config.d.ts.map +1 -1
  73. package/dist/rules-config.js +25 -11
  74. package/dist/rules-config.js.map +1 -1
  75. package/dist/structure.d.ts.map +1 -1
  76. package/dist/structure.js +3 -2
  77. package/dist/structure.js.map +1 -1
  78. package/dist/tex-requirements.d.ts +63 -7
  79. package/dist/tex-requirements.d.ts.map +1 -1
  80. package/dist/tex-requirements.js +91 -24
  81. package/dist/tex-requirements.js.map +1 -1
  82. package/dist/types.d.ts +20 -18
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/venue-rules.d.ts +109 -0
  85. package/dist/venue-rules.d.ts.map +1 -0
  86. package/dist/venue-rules.js +389 -0
  87. package/dist/venue-rules.js.map +1 -0
  88. package/docs/configuration.md +152 -77
  89. package/docs/e2e.md +1 -1
  90. package/docs/install.md +25 -40
  91. package/docs/optional-rules.md +26 -24
  92. package/docs/rules.md +181 -15
  93. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  94. package/eslint-rules/latex-language.mjs +29 -1
  95. package/eslint-rules/paper-stages.harness.mjs +1 -117
  96. package/eslint-rules/paper-stages.mjs +0 -120
  97. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  98. package/eslint-rules/paper-typography.mjs +267 -192
  99. package/eslint-rules/paper-typography.test.mjs +297 -0
  100. package/eslint-rules/papers.harness.mjs +8 -12
  101. package/eslint-rules/papers.mjs +17 -27
  102. package/eslint-rules/pdf-last-page-balance.mjs +23 -5
  103. package/eslint-rules/review-frontmatter.mjs +118 -0
  104. package/eslint-rules/review-frontmatter.schema.json +36 -0
  105. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  106. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  107. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  108. package/eslint.config.mjs +0 -15
  109. package/fixtures/build-e2e/acmart/paperlint.json +1 -0
  110. package/fixtures/real-markdown-paper/baseline.json +6 -7
  111. package/hooks/hooks.harness.mjs +39 -98
  112. package/hooks/hooks.mutations.mjs +6 -6
  113. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  114. package/hooks/paper-edit-guard.hook.mjs +31 -42
  115. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  116. package/hooks/paper-status-gates.hook.mjs +21 -19
  117. package/hooks/paper-status-gates.sh +8 -13
  118. package/lib/paper-config.d.mts +15 -18
  119. package/lib/paper-config.harness.mjs +18 -35
  120. package/lib/paper-config.mjs +90 -81
  121. package/lib/paper-config.mutations.mjs +2 -2
  122. package/lib/skill-checks.mjs +1 -1
  123. package/lib/skill-trigger-cases.harness.mjs +9 -8
  124. package/lib/skill-trigger-cases.mjs +7 -15
  125. package/package.json +1 -1
  126. package/scripts/eslint-report-guard.mjs +1 -1
  127. package/scripts/harness-api.frozen.json +0 -3
  128. package/scripts/mutation-batteries.frozen.json +4 -13
  129. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  130. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  131. package/skills/cold-read-diff/SKILL.md +20 -1
  132. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  133. package/skills/find-venue/SKILL.md +5 -1
  134. package/skills/find-venue/SKILL.md.spec.ts +4 -0
  135. package/skills/grade-paper-writing/SKILL.md +20 -1
  136. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  137. package/skills/paper-pipeline/PIPELINE-MAP.md +14 -4
  138. package/skills/paper-pipeline/SKILL.md +4 -3
  139. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -2
  140. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  141. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  142. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  144. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  145. package/skills/plan-paper-timeline/SKILL.md +2 -2
  146. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  147. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  148. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  149. package/skills/render-paper/check-render.sh +2 -3
  150. package/skills/render-paper/extract-pdf-facts.harness.mjs +5 -5
  151. package/skills/render-paper/extract-pdf-facts.mjs +4 -4
  152. package/skills/render-paper/render-paper.harness.mjs +2 -2
  153. package/skills/study-accepted-papers/SKILL.md +2 -1
  154. package/skills/study-accepted-papers/SKILL.md.spec.ts +1 -0
  155. package/skills/submit-paper/SKILL.md +9 -1
  156. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  157. package/skills/submit-paper/references/venues/acm-sigconf.jsonc +86 -0
  158. package/skills/submit-paper/references/venues/agenticdev.jsonc +34 -102
  159. package/skills/submit-paper/references/venues/aisec.jsonc +21 -82
  160. package/skills/submit-paper/references/venues/realm.jsonc +45 -43
  161. package/skills/submit-paper/references/venues/tex-base.jsonc +1 -1
  162. package/skills/submit-paper/references/venues/venue-profile.schema.json +119 -29
  163. package/skills/verify-citations/SKILL.md +10 -1
  164. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  165. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  166. package/src/adapters/references/index.ts +102 -0
  167. package/src/adapters/references/reach.io.ts +12 -0
  168. package/src/build-engine.ts +10 -2
  169. package/src/build.harness.mjs +31 -22
  170. package/src/build.ts +65 -8
  171. package/src/cli.harness.mjs +151 -294
  172. package/src/cli.mutations.mjs +35 -44
  173. package/src/cli.ts +457 -288
  174. package/src/doctor.harness.mjs +41 -84
  175. package/src/doctor.mutations.mjs +9 -9
  176. package/src/doctor.ts +41 -61
  177. package/src/facts-file.test.ts +47 -7
  178. package/src/facts-file.ts +145 -24
  179. package/src/hooks-settings.harness.mjs +7 -92
  180. package/src/hooks-settings.mutations.mjs +2 -17
  181. package/src/hooks-settings.ts +18 -162
  182. package/src/init.test.ts +206 -0
  183. package/src/init.ts +193 -159
  184. package/src/link-skills.harness.mjs +1 -38
  185. package/src/link-skills.mutations.mjs +9 -5
  186. package/src/link-skills.ts +10 -34
  187. package/src/lint-fix.test.ts +120 -0
  188. package/src/new-paper.harness.mjs +9 -6
  189. package/src/new-paper.test.ts +133 -0
  190. package/src/new-paper.ts +7 -2
  191. package/src/paper-files.test.ts +290 -0
  192. package/src/paper-files.ts +144 -0
  193. package/src/paper-settings-commands.test.ts +274 -0
  194. package/src/paper-settings.test.ts +249 -0
  195. package/src/paper-settings.ts +208 -0
  196. package/src/ports/check-references.ts +28 -0
  197. package/src/presets.test.ts +236 -0
  198. package/src/presets.ts +297 -0
  199. package/src/reference-rules.ts +229 -0
  200. package/src/references.test.ts +209 -0
  201. package/src/references.ts +138 -0
  202. package/src/rules-config.harness.mjs +24 -8
  203. package/src/rules-config.ts +30 -13
  204. package/src/structure.harness.mjs +6 -4
  205. package/src/structure.ts +3 -2
  206. package/src/tex-requirements.harness.mjs +11 -17
  207. package/src/tex-requirements.ts +179 -29
  208. package/src/types.ts +30 -18
  209. package/src/venue-rules.test.ts +554 -0
  210. package/src/venue-rules.ts +571 -0
  211. package/templates/paper/paperlint.json +4 -0
  212. package/eslint-rules/doc-fields.harness.mjs +0 -336
  213. package/eslint-rules/doc-fields.mjs +0 -186
  214. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  215. package/eslint-rules/paper-typography.harness.mjs +0 -291
  216. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  217. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  218. package/eslint-rules/review-findings-cause.mjs +0 -135
  219. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  220. package/fixtures/build-e2e/acmart/venue.json +0 -1
  221. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  222. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  223. package/fixtures/review-findings-cause/clean.md +0 -17
  224. package/fixtures/review-findings-cause/defect.md +0 -14
  225. package/fixtures/review-findings-cause/old-debt.md +0 -14
  226. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -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 });
@@ -65,24 +66,29 @@ function parseEntry(v: unknown): RuleEntry | null {
65
66
  return v as RuleEntry;
66
67
  }
67
68
 
68
- /** One block's `rules` object. */
69
- function parseRules(
69
+ /**
70
+ * A `rules` object — rule id → severity or `[severity, ...options]` — with only the rules paperlint
71
+ * ships. Read from every `paperlint.json`, the root's and each paper's.
72
+ *
73
+ * @param where the key path to name in messages, e.g. `package.json → "paperlint".rules[0].rules`
74
+ */
75
+ export function parseRuleEntries(
70
76
  v: unknown,
71
- at: string,
77
+ where: string,
72
78
  shipped: ReadonlySet<string>,
73
79
  ): Parsed<Record<string, RuleEntry>> {
74
80
  if (typeof v !== "object" || v === null || Array.isArray(v))
75
- return bad(`${at}.rules must be an object of rule id → severity`);
81
+ return bad(`${where} must be an object of rule id → severity`);
76
82
  const out: Record<string, RuleEntry> = {};
77
83
  for (const [id, raw] of Object.entries(v)) {
78
84
  if (!shipped.has(id))
79
85
  return bad(
80
- `${at}.rules: "${id}" is not a rule paperlint ships — known: ${[...shipped].sort().join(", ")}`,
86
+ `${where}: "${id}" is not a rule paperlint ships — known: ${[...shipped].sort().join(", ")}`,
81
87
  );
82
88
  const entry = parseEntry(raw);
83
89
  if (entry === null)
84
90
  return bad(
85
- `${at}.rules["${id}"]: ${JSON.stringify(raw)} is not a severity — use "off", "warn" or "error", or ["error", { options }]`,
91
+ `${where}["${id}"]: ${JSON.stringify(raw)} is not a severity — use "off", "warn" or "error", or ["error", { options }]`,
86
92
  );
87
93
  out[id] = entry;
88
94
  }
@@ -103,17 +109,20 @@ const globsOf = (o: Record<string, unknown>) => ({
103
109
  ...(o["ignores"] ? { ignores: o["ignores"] as string[] } : {}),
104
110
  });
105
111
 
112
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
113
+ typeof v === "object" && v !== null && !Array.isArray(v);
114
+
106
115
  /** One element of the `rules` list. */
107
116
  function parseBlock(
108
117
  v: unknown,
109
118
  at: string,
110
119
  ctx: { shipped: ReadonlySet<string>; baseDir: string },
111
120
  ): Parsed<RuleBlock> {
112
- if (typeof v !== "object" || v === null || Array.isArray(v))
121
+ if (!isRecord(v))
113
122
  return bad(
114
123
  `${at} must be an object like { "files": [...], "rules": {...} }`,
115
124
  );
116
- const o = v as Record<string, unknown>;
125
+ const o = v;
117
126
  const extra = Object.keys(o).filter((k) => !BLOCK_KEYS.has(k));
118
127
  if (extra.length)
119
128
  return bad(
@@ -121,7 +130,7 @@ function parseBlock(
121
130
  );
122
131
  const globs = badGlobs(o, at);
123
132
  if (globs) return bad(globs);
124
- const rules = parseRules(o["rules"], at, ctx.shipped);
133
+ const rules = parseRuleEntries(o["rules"], `${at}.rules`, ctx.shipped);
125
134
  if (!rules.ok) return rules;
126
135
  return {
127
136
  ok: true,
@@ -130,10 +139,12 @@ function parseBlock(
130
139
  }
131
140
 
132
141
  /**
133
- * 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`.
134
145
  *
135
146
  * @param raw the value under `rules` (undefined when absent)
136
- * @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`
137
148
  * @param shipped the rule ids paperlint ships (`shippedRuleIds`)
138
149
  * @param baseDir the directory of the file holding the settings
139
150
  */
@@ -144,13 +155,19 @@ export function parseRuleBlocks(
144
155
  baseDir: string,
145
156
  ): Parsed<readonly RuleBlock[]> {
146
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
+ }
147
164
  if (!Array.isArray(raw))
148
165
  return bad(
149
- `${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": {...} }]`,
150
167
  );
151
168
  const out: RuleBlock[] = [];
152
169
  for (const [i, v] of raw.entries()) {
153
- const b = parseBlock(v, `${where}.rules[${i}]`, { shipped, baseDir });
170
+ const b = parseBlock(v, `${where} → rules[${i}]`, { shipped, baseDir });
154
171
  if (!b.ok) return b;
155
172
  out.push(b.value);
156
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",
@@ -132,9 +134,9 @@ try {
132
134
  !STRUCTURE_DEFAULTS.require.includes("paper.pdf"),
133
135
  );
134
136
  check(
135
- "and `venue.json` counts as a marker but not a requirement",
136
- STRUCTURE_DEFAULTS.markers.includes("venue.json") &&
137
- !STRUCTURE_DEFAULTS.require.includes("venue.json"),
137
+ "and `paperlint.json` counts as a marker but not a requirement",
138
+ STRUCTURE_DEFAULTS.markers.includes("paperlint.json") &&
139
+ !STRUCTURE_DEFAULTS.require.includes("paperlint.json"),
138
140
  );
139
141
 
140
142
  // ── one schema for both halves ────────────────────────────────────────────────────────
package/src/structure.ts CHANGED
@@ -25,6 +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 { CONFIG_FILE } from "../lib/paper-config.mjs";
28
29
 
29
30
  /** The requirements after the consumer's config is laid over the defaults. */
30
31
  type Rules = Required<StructureConfig>;
@@ -37,7 +38,7 @@ type Rules = Required<StructureConfig>;
37
38
  * byte-compared by the `paper/stages` rule anyway.
38
39
  */
39
40
  export const STRUCTURE_DEFAULTS = {
40
- markers: ["PIPELINE-STATUS.md", "paper.tex", "paper.md", "venue.json"],
41
+ markers: ["PIPELINE-STATUS.md", "paper.tex", "paper.md", CONFIG_FILE],
41
42
  require: ["PIPELINE-STATUS.md"],
42
43
  requireOneOf: [["paper.tex", "paper.md"]],
43
44
  ignore: [],
@@ -121,7 +122,7 @@ export function checkStructure(
121
122
  */
122
123
  function whyMissingMatters(file: string, dirName: string): string {
123
124
  if (file === "PIPELINE-STATUS.md")
124
- 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`;
125
126
  return `declared as required by your \`structure\` configuration`;
126
127
  }
127
128
 
@@ -103,7 +103,9 @@ for (const v of acm) {
103
103
  );
104
104
  }
105
105
  // Venues on ONE template must not drift apart: a second list is how the old installer's three
106
- // dictionaries diverged.
106
+ // dictionaries diverged. Since the ACM venues extend the `acm-sigconf` family, only the family
107
+ // declares the template's packages, so this holds by construction; the check stays for any two
108
+ // standalone presets that name the same template.
107
109
  const byTemplate = new Map();
108
110
  for (const v of venues) {
109
111
  const t = templateOf(v);
@@ -173,30 +175,22 @@ rmSync(tmp, { recursive: true, force: true });
173
175
  const r = R.requirementsFor(null, VENUES);
174
176
  // Guards: saying WHY a paper runs on the base set.
175
177
  check(
176
- "no venue.json → the base set, and the source says so",
177
- r.source.includes("no venue.json") &&
178
+ "no venue preset → the base set, and the source says so",
179
+ r.source.includes("no venue preset in paperlint.json") &&
178
180
  "hyperref" in r.tex.packages &&
179
181
  !("acmart" in r.tex.packages),
180
182
  r.source,
181
183
  );
182
184
  }
185
+ // Which preset a paper resolves to — a typo, the base file, a missing one — is src/presets.ts's
186
+ // question now, tested in src/presets.test.ts; this function only adds a resolved preset's packages.
183
187
  {
184
- const r = R.requirementsFor("nowhere", VENUES);
185
- check(
186
- "a venue with no profile → the base set, and the source names the venue",
187
- r.source.includes("venue nowhere has no profile") &&
188
- !("acmart" in r.tex.packages),
189
- r.source,
188
+ const r = R.requirementsFor(
189
+ { label: acm[0], tex: read(`${acm[0]}.jsonc`) },
190
+ VENUES,
190
191
  );
191
- }
192
- check(
193
- "the base file is not a venue: `tex-base` gets the base set with the reason",
194
- R.requirementsFor("tex-base", VENUES).source.includes("has no profile"),
195
- );
196
- {
197
- const r = R.requirementsFor(acm[0], VENUES);
198
192
  check(
199
- "a known venue → its packages ON TOP OF the base set",
193
+ "a resolved preset → its packages ON TOP OF the base set, and the source names its label",
200
194
  r.source === `venue ${acm[0]}` &&
201
195
  "acmart" in r.tex.packages &&
202
196
  "hyperref" in r.tex.packages &&