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
@@ -0,0 +1,229 @@
1
+ /**
2
+ * The reference rules — `paper/author-list`, `paper/cite-exists`, `paper/refs-checked`,
3
+ * `paper/refs-fresh` — judge `_build/references.json` (`references.ts`), offline. `paperlint build`
4
+ * does the online work; lint never touches the network.
5
+ *
6
+ * entry whose authors are the preprint's paper/author-list (error), on the entry
7
+ * entry whose identifier provably fails paper/cite-exists (error), on the entry
8
+ * no record, or recorded "not checked" paper/refs-checked (warn) — like pdf/measured
9
+ * bibliography edited after the build paper/refs-fresh (error) — like pdf/fresh
10
+ *
11
+ * While the record is stale, the per-entry rules are silent: they would judge a bibliography
12
+ * that no longer exists, and refs-fresh already says so once. A paper with no bibliography gets
13
+ * nothing.
14
+ *
15
+ * `paper/author-list` used to live on PIPELINE-STATUS.md and ask whether the scorecard mentioned
16
+ * a run. It lives here now because its subject is the bibliography, and the record of the run is
17
+ * the run's own output.
18
+ */
19
+ import { basename, dirname } from "node:path";
20
+ import type { Files } from "./ports/files.ts";
21
+ import {
22
+ bibHash,
23
+ bibliographyOf,
24
+ readReferences,
25
+ REFERENCES_FILE,
26
+ type Bibliography,
27
+ } from "./references.ts";
28
+
29
+ interface Loc {
30
+ readonly line: number;
31
+ readonly column: number;
32
+ }
33
+ interface RuleContext {
34
+ readonly filename: string;
35
+ readonly cwd: string;
36
+ readonly sourceCode: {
37
+ getLocFromIndex(i: number): Loc;
38
+ };
39
+ report(d: {
40
+ loc: { start: Loc; end: Loc };
41
+ messageId: string;
42
+ data?: Record<string, string>;
43
+ }): void;
44
+ }
45
+ export interface ReferenceRuleModule {
46
+ readonly meta: {
47
+ readonly type: "problem" | "suggestion";
48
+ readonly docs: { readonly description: string };
49
+ readonly schema: readonly object[];
50
+ readonly messages: Readonly<Record<string, string>>;
51
+ };
52
+ create(context: RuleContext): { "root:exit"?: () => void };
53
+ }
54
+
55
+ type Name = "author-list" | "cite-exists" | "refs-checked" | "refs-fresh";
56
+
57
+ /** What one lint run knows about a paper's references. */
58
+ type Assessment =
59
+ | { readonly kind: "no-bibliography" }
60
+ | { readonly kind: "unrecorded" }
61
+ | { readonly kind: "not-checked"; readonly why: string }
62
+ | { readonly kind: "stale" }
63
+ | {
64
+ readonly kind: "ready";
65
+ readonly bib: Bibliography;
66
+ readonly failing: readonly {
67
+ readonly key: string;
68
+ readonly rule: "author-list" | "cite-exists";
69
+ readonly why: string;
70
+ }[];
71
+ };
72
+
73
+ function assess(files: Files, paperDir: string): Assessment {
74
+ const bib = bibliographyOf(files, paperDir);
75
+ if (bib === null) return { kind: "no-bibliography" };
76
+ const doc = readReferences(files, paperDir);
77
+ if (doc === null) return { kind: "unrecorded" };
78
+ if (doc.bib.sha256 !== bibHash(bib)) return { kind: "stale" };
79
+ if (doc.status === "not-checked")
80
+ return { kind: "not-checked", why: doc.why ?? "no reason recorded" };
81
+ const failing = doc.entries.flatMap((e) => [
82
+ ...(e.authors === "mismatch"
83
+ ? [{ key: e.key, rule: "author-list" as const, why: e.why ?? "" }]
84
+ : []),
85
+ ...(e.exists === "false"
86
+ ? [{ key: e.key, rule: "cite-exists" as const, why: e.why ?? "" }]
87
+ : []),
88
+ ]);
89
+ return { kind: "ready", bib, failing };
90
+ }
91
+
92
+ /** Where entry `key` starts in the source file: its `@type{key,` line, or the file's start. */
93
+ function entryOffset(bib: Bibliography, key: string): number {
94
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
95
+ const m = new RegExp(`^@\\w+\\s*\\{\\s*${escaped}\\s*,`, "m").exec(bib.text);
96
+ return m ? bib.offset + m.index : 0;
97
+ }
98
+
99
+ const BUILD = "`npx paperlint build`";
100
+
101
+ const META: Readonly<Record<Name, ReferenceRuleModule["meta"]>> = {
102
+ "author-list": {
103
+ type: "problem",
104
+ docs: {
105
+ description:
106
+ "each entry's authors are those of the version it cites, not the preprint's (recorded by paperlint build)",
107
+ },
108
+ schema: [],
109
+ messages: {
110
+ mismatch:
111
+ "`{{key}}`: the authors are not those of the version cited — {{why}}. The citation resolves and the id resolves, and the list is the PREPRINT's under a published venue",
112
+ },
113
+ },
114
+ "cite-exists": {
115
+ type: "problem",
116
+ docs: {
117
+ description:
118
+ "each entry's identifier resolves to the work cited (recorded by paperlint build)",
119
+ },
120
+ schema: [],
121
+ messages: {
122
+ missing: "`{{key}}` does not resolve to the work cited — {{why}}",
123
+ },
124
+ },
125
+ "refs-checked": {
126
+ type: "suggestion",
127
+ docs: {
128
+ description:
129
+ "the build checked the references online (existence, title, authors); a warning when it could not",
130
+ },
131
+ schema: [],
132
+ messages: {
133
+ unrecorded: `the references have not been checked — ${BUILD} checks them online and records the result in _build/${REFERENCES_FILE}`,
134
+ notChecked: `the last build could not check the references ({{why}}) — run ${BUILD} with network`,
135
+ },
136
+ },
137
+ "refs-fresh": {
138
+ type: "problem",
139
+ docs: {
140
+ description:
141
+ "the recorded reference verdicts are about the bibliography on disk, not an earlier one",
142
+ },
143
+ schema: [],
144
+ messages: {
145
+ stale: `the references changed since the last build checked them — run ${BUILD}`,
146
+ },
147
+ },
148
+ };
149
+
150
+ type Report = { at: number; messageId: string; data?: Record<string, string> };
151
+
152
+ const JUDGES: Readonly<Record<Name, (a: Assessment) => Report[]>> = {
153
+ "author-list": (a) =>
154
+ a.kind === "ready"
155
+ ? a.failing
156
+ .filter((f) => f.rule === "author-list")
157
+ .map((f) => ({
158
+ at: entryOffset(a.bib, f.key),
159
+ messageId: "mismatch",
160
+ data: { key: f.key, why: f.why },
161
+ }))
162
+ : [],
163
+ "cite-exists": (a) =>
164
+ a.kind === "ready"
165
+ ? a.failing
166
+ .filter((f) => f.rule === "cite-exists")
167
+ .map((f) => ({
168
+ at: entryOffset(a.bib, f.key),
169
+ messageId: "missing",
170
+ data: { key: f.key, why: f.why },
171
+ }))
172
+ : [],
173
+ "refs-checked": (a) =>
174
+ a.kind === "unrecorded"
175
+ ? [{ at: 0, messageId: "unrecorded" }]
176
+ : a.kind === "not-checked"
177
+ ? [{ at: 0, messageId: "notChecked", data: { why: a.why } }]
178
+ : [],
179
+ "refs-fresh": (a) =>
180
+ a.kind === "stale" ? [{ at: 0, messageId: "stale" }] : [],
181
+ };
182
+
183
+ /** The four rules, reading through `files`. They act on `paper.tex` only. */
184
+ export function referenceRules({
185
+ files,
186
+ }: {
187
+ files: Files;
188
+ }): Record<Name, ReferenceRuleModule> {
189
+ const make = (name: Name): ReferenceRuleModule => ({
190
+ meta: META[name],
191
+ create: (context) =>
192
+ basename(context.filename) !== "paper.tex"
193
+ ? {}
194
+ : {
195
+ "root:exit": () => {
196
+ const paperDir = dirname(context.filename);
197
+ const a = assess(files, paperDir);
198
+ // An external refs.bib: the entry is not in this file, so the finding sits at its
199
+ // start and names the key.
200
+ const external =
201
+ a.kind === "ready" && a.bib.source !== "paper.tex";
202
+ for (const r of JUDGES[name](a)) {
203
+ const loc = context.sourceCode.getLocFromIndex(
204
+ external ? 0 : r.at,
205
+ );
206
+ context.report({
207
+ loc: { start: loc, end: loc },
208
+ messageId: r.messageId,
209
+ ...(r.data ? { data: r.data } : {}),
210
+ });
211
+ }
212
+ },
213
+ },
214
+ });
215
+ return {
216
+ "author-list": make("author-list"),
217
+ "cite-exists": make("cite-exists"),
218
+ "refs-checked": make("refs-checked"),
219
+ "refs-fresh": make("refs-fresh"),
220
+ };
221
+ }
222
+
223
+ /** The level each is on at for every `paper.tex` in paperlint's own config. */
224
+ export const REFERENCE_RULE_LEVELS = {
225
+ "paper/author-list": "error",
226
+ "paper/cite-exists": "error",
227
+ "paper/refs-checked": "warn",
228
+ "paper/refs-fresh": "error",
229
+ } as const;
@@ -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) {