paperlint 3.1.0 → 3.1.2

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 (311) hide show
  1. package/.github/workflows/ci.yml +28 -41
  2. package/.github/workflows/dependabot-automerge.yml +1 -1
  3. package/CLAUDE.md +21 -105
  4. package/CONTRIBUTING.md +23 -25
  5. package/README.md +11 -7
  6. package/bin/paperlint.test.mjs +45 -0
  7. package/dist/adapters/node/files.io.d.ts +2 -0
  8. package/dist/adapters/node/files.io.d.ts.map +1 -1
  9. package/dist/adapters/node/files.io.js +3 -2
  10. package/dist/adapters/node/files.io.js.map +1 -1
  11. package/dist/adapters/pdfjs/fill.d.ts +15 -4
  12. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  13. package/dist/adapters/pdfjs/fill.js +31 -21
  14. package/dist/adapters/pdfjs/fill.js.map +1 -1
  15. package/dist/adapters/references/clock.io.d.ts +5 -0
  16. package/dist/adapters/references/clock.io.d.ts.map +1 -0
  17. package/dist/adapters/references/clock.io.js +7 -0
  18. package/dist/adapters/references/clock.io.js.map +1 -0
  19. package/dist/adapters/references/index.d.ts +24 -2
  20. package/dist/adapters/references/index.d.ts.map +1 -1
  21. package/dist/adapters/references/index.js +131 -12
  22. package/dist/adapters/references/index.js.map +1 -1
  23. package/dist/adapters/references/pool.d.ts +8 -0
  24. package/dist/adapters/references/pool.d.ts.map +1 -0
  25. package/dist/adapters/references/pool.js +19 -0
  26. package/dist/adapters/references/pool.js.map +1 -0
  27. package/dist/build.d.ts +14 -2
  28. package/dist/build.d.ts.map +1 -1
  29. package/dist/build.js +17 -11
  30. package/dist/build.js.map +1 -1
  31. package/dist/cli.d.ts +27 -1
  32. package/dist/cli.d.ts.map +1 -1
  33. package/dist/cli.js +71 -55
  34. package/dist/cli.js.map +1 -1
  35. package/dist/doctor.d.ts +27 -1
  36. package/dist/doctor.d.ts.map +1 -1
  37. package/dist/doctor.js +4 -3
  38. package/dist/doctor.js.map +1 -1
  39. package/dist/domain/lookup-cache.d.ts +75 -0
  40. package/dist/domain/lookup-cache.d.ts.map +1 -0
  41. package/dist/domain/lookup-cache.js +137 -0
  42. package/dist/domain/lookup-cache.js.map +1 -0
  43. package/dist/domain/result.d.ts +0 -2
  44. package/dist/domain/result.d.ts.map +1 -1
  45. package/dist/domain/result.js +0 -4
  46. package/dist/domain/result.js.map +1 -1
  47. package/dist/hooks-settings.d.ts +8 -5
  48. package/dist/hooks-settings.d.ts.map +1 -1
  49. package/dist/hooks-settings.js +32 -32
  50. package/dist/hooks-settings.js.map +1 -1
  51. package/dist/init.d.ts +8 -0
  52. package/dist/init.d.ts.map +1 -1
  53. package/dist/init.js +3 -3
  54. package/dist/init.js.map +1 -1
  55. package/dist/latex-log.d.ts +0 -2
  56. package/dist/latex-log.d.ts.map +1 -1
  57. package/dist/latex-log.js +7 -13
  58. package/dist/latex-log.js.map +1 -1
  59. package/dist/link-skills.d.ts +15 -2
  60. package/dist/link-skills.d.ts.map +1 -1
  61. package/dist/link-skills.js +36 -20
  62. package/dist/link-skills.js.map +1 -1
  63. package/dist/paper-files.d.ts.map +1 -1
  64. package/dist/paper-files.js +6 -4
  65. package/dist/paper-files.js.map +1 -1
  66. package/dist/pdf-facts.d.ts +11 -0
  67. package/dist/pdf-facts.d.ts.map +1 -1
  68. package/dist/pdf-facts.js +16 -18
  69. package/dist/pdf-facts.js.map +1 -1
  70. package/dist/pdf-geometry.js +1 -1
  71. package/dist/pdf-geometry.js.map +1 -1
  72. package/dist/ports/check-references.d.ts +12 -2
  73. package/dist/ports/check-references.d.ts.map +1 -1
  74. package/dist/ports/check-references.js +0 -5
  75. package/dist/ports/check-references.js.map +1 -1
  76. package/dist/presets.d.ts +2 -0
  77. package/dist/presets.d.ts.map +1 -1
  78. package/dist/presets.js +3 -1
  79. package/dist/presets.js.map +1 -1
  80. package/dist/references.d.ts +11 -3
  81. package/dist/references.d.ts.map +1 -1
  82. package/dist/references.js +63 -15
  83. package/dist/references.js.map +1 -1
  84. package/dist/tex-requirements.d.ts +8 -0
  85. package/dist/tex-requirements.d.ts.map +1 -1
  86. package/dist/tex-requirements.js +6 -5
  87. package/dist/tex-requirements.js.map +1 -1
  88. package/dist/toolchain.d.ts +3 -2
  89. package/dist/toolchain.d.ts.map +1 -1
  90. package/dist/toolchain.js +4 -5
  91. package/dist/toolchain.js.map +1 -1
  92. package/dist/venue-rules.d.ts.map +1 -1
  93. package/dist/venue-rules.js +11 -9
  94. package/dist/venue-rules.js.map +1 -1
  95. package/docs/prior-art/README.md +11 -10
  96. package/docs/prior-art/citation-checkers.md +174 -0
  97. package/docs/prior-art/test-tooling.md +7 -30
  98. package/docs/references.md +126 -0
  99. package/docs/testing.md +118 -0
  100. package/docs/toolchain.md +6 -4
  101. package/eslint-rules/install-path-literals.harness.mjs +17 -1
  102. package/eslint-rules/latex-language.harness.mjs +3 -3
  103. package/eslint-rules/latex-language.mjs +12 -9
  104. package/eslint-rules/latex-language.test.mjs +200 -0
  105. package/eslint-rules/paper-research-question.harness.mjs +73 -7
  106. package/eslint-rules/paper-stages.harness.mjs +9 -14
  107. package/eslint-rules/paper-stages.test.mjs +135 -0
  108. package/eslint-rules/paper-typography.mjs +9 -2
  109. package/eslint-rules/paper-typography.test.mjs +146 -1
  110. package/eslint-rules/pdf-last-page-balance.harness.mjs +63 -7
  111. package/eslint-rules/review-frontmatter.mjs +2 -1
  112. package/eslint-rules/review-frontmatter.test.mjs +37 -1
  113. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  114. package/eslint-rules/tex-build.mjs +13 -13
  115. package/eslint.config.mjs +6 -2
  116. package/hooks/hooks.harness.mjs +6 -11
  117. package/hooks/paper-edit-guard.hook.mjs +2 -2
  118. package/hooks/paper-edit-guard.test.mjs +61 -0
  119. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  120. package/hooks/paper-skills-nudge.test.mjs +38 -0
  121. package/hooks/paper-status-gates.test.mjs +42 -0
  122. package/lib/agent-cli-version.harness.mjs +7 -0
  123. package/lib/check.d.mts +17 -0
  124. package/lib/check.mjs +50 -0
  125. package/lib/check.test.ts +67 -0
  126. package/lib/markdown.mjs +16 -69
  127. package/lib/markdown.test.mjs +246 -0
  128. package/lib/paper-config.harness.mjs +28 -9
  129. package/lib/paper-config.mjs +7 -2
  130. package/lib/skill-checks.mjs +18 -46
  131. package/lib/skill-checks.test.mjs +297 -0
  132. package/lib/skill-corpus.mjs +11 -13
  133. package/lib/skill-corpus.test.mjs +54 -0
  134. package/lib/skill-trigger-cases.harness.mjs +57 -4
  135. package/package.json +6 -5
  136. package/scripts/check.harness.mjs +4 -10
  137. package/scripts/check.mjs +45 -42
  138. package/scripts/check.test.mjs +104 -0
  139. package/scripts/coverage-config.test.ts +406 -0
  140. package/scripts/eslint-report-guard.test.mjs +48 -0
  141. package/scripts/harness-api.frozen.json +0 -2
  142. package/scripts/layer-legacy-frozen.d.mts +9 -1
  143. package/scripts/layer-legacy-frozen.mjs +28 -11
  144. package/scripts/layer-legacy-frozen.test.ts +143 -1
  145. package/scripts/rules-are-content-only.harness.mjs +3 -11
  146. package/scripts/rules-are-content-only.mjs +29 -19
  147. package/scripts/rules-are-content-only.test.mjs +61 -0
  148. package/scripts/rules-see-files.mjs +11 -9
  149. package/scripts/rules-see-files.test.mjs +71 -0
  150. package/scripts/test.mjs +44 -0
  151. package/scripts/test.test.mjs +52 -0
  152. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  153. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  154. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  155. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  156. package/skills/paper-pipeline/references/artifact-checklist.md +7 -7
  157. package/skills/paper-pipeline/scripts/README.md +13 -132
  158. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  159. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  160. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  161. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  162. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  163. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  164. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  165. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  166. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  167. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  168. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  169. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  170. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  171. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  172. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
  173. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  174. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  175. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  176. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  177. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  178. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  179. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  180. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  181. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  182. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  183. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  184. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  185. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  186. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  187. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  188. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  189. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  190. package/skills/render-paper/checkers-requirements.txt +30 -19
  191. package/skills/render-paper/ensure-checkers.sh +20 -4
  192. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  193. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  194. package/skills/tighten-paper/structure.test.mjs +116 -0
  195. package/skills/verify-citations/SKILL.md +30 -14
  196. package/skills/verify-citations/SKILL.md.spec.ts +29 -13
  197. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +253 -0
  198. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  199. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  200. package/skills/verify-citations/scripts/bib-authors.mjs +6 -6
  201. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  202. package/skills/verify-citations/scripts/verify-cites.mjs +360 -215
  203. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +588 -0
  204. package/skills/verify-citations/scripts/verify-cites.test.mjs +151 -5
  205. package/src/adapters/banal/failure.test.ts +70 -1
  206. package/src/adapters/banal/index.test.ts +16 -0
  207. package/src/adapters/banal/output.test.ts +5 -0
  208. package/src/adapters/curl/download.test.ts +43 -1
  209. package/src/adapters/memory/index.test.ts +25 -0
  210. package/src/adapters/node/files.io.ts +3 -2
  211. package/src/adapters/node/files.test.ts +13 -1
  212. package/src/adapters/node/process.test.ts +38 -0
  213. package/src/adapters/pdfjs/fill.test.ts +34 -0
  214. package/src/adapters/pdfjs/fill.ts +44 -25
  215. package/src/adapters/references/clock.io.ts +8 -0
  216. package/src/adapters/references/index.test.ts +560 -0
  217. package/src/adapters/references/index.ts +203 -19
  218. package/src/adapters/references/pool.test.ts +31 -0
  219. package/src/adapters/references/pool.ts +24 -0
  220. package/src/build-engine.harness.mjs +3 -7
  221. package/src/build-engine.test.ts +137 -0
  222. package/src/build.harness.mjs +5 -9
  223. package/src/build.test.ts +362 -0
  224. package/src/build.ts +23 -12
  225. package/src/cli.harness.mjs +10 -17
  226. package/src/cli.test.ts +584 -0
  227. package/src/cli.ts +112 -66
  228. package/src/doctor.harness.mjs +3 -8
  229. package/src/doctor.test.ts +64 -0
  230. package/src/doctor.ts +12 -4
  231. package/src/domain/lookup-cache.test.ts +178 -0
  232. package/src/domain/lookup-cache.ts +202 -0
  233. package/src/domain/result.test.ts +4 -0
  234. package/src/domain/result.ts +0 -5
  235. package/src/engine.harness.mjs +27 -7
  236. package/src/facts-file.test.ts +110 -0
  237. package/src/hooks-settings.harness.mjs +43 -13
  238. package/src/hooks-settings.ts +41 -35
  239. package/src/init.test.ts +308 -5
  240. package/src/init.ts +3 -3
  241. package/src/latex-log.harness.mjs +32 -7
  242. package/src/latex-log.ts +6 -13
  243. package/src/latex-loop.harness.mjs +8 -7
  244. package/src/link-skills.harness.mjs +3 -8
  245. package/src/link-skills.test.ts +105 -0
  246. package/src/link-skills.ts +75 -21
  247. package/src/new-paper.harness.mjs +3 -7
  248. package/src/new-paper.test.ts +31 -1
  249. package/src/paper-files.test.ts +12 -0
  250. package/src/paper-files.ts +9 -5
  251. package/src/paper-settings.test.ts +20 -0
  252. package/src/pdf-facts.harness.mjs +3 -6
  253. package/src/pdf-facts.test.ts +144 -0
  254. package/src/pdf-facts.ts +17 -19
  255. package/src/pdf-geometry.harness.mjs +3 -7
  256. package/src/pdf-geometry.ts +1 -1
  257. package/src/ports/check-references.ts +16 -2
  258. package/src/presets.test.ts +2 -0
  259. package/src/presets.ts +7 -1
  260. package/src/references.test.ts +257 -10
  261. package/src/references.ts +93 -17
  262. package/src/rules-config.harness.mjs +10 -8
  263. package/src/structure.harness.mjs +3 -7
  264. package/src/tex-requirements.harness.mjs +34 -7
  265. package/src/tex-requirements.ts +12 -6
  266. package/src/toolchain.harness.mjs +3 -7
  267. package/src/toolchain.test.ts +280 -0
  268. package/src/toolchain.ts +8 -7
  269. package/src/venue-rules.test.ts +60 -0
  270. package/src/venue-rules.ts +13 -11
  271. package/action.mutations.mjs +0 -162
  272. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  273. package/eslint-rules/latex-language.mutations.mjs +0 -196
  274. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  275. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  276. package/eslint-rules/papers.mutations.mjs +0 -186
  277. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  278. package/eslint-rules/tex-build.mutations.mjs +0 -258
  279. package/hooks/hooks.mutations.mjs +0 -337
  280. package/lib/agent-cli-version.mutations.mjs +0 -109
  281. package/lib/mutation-driver.harness.mjs +0 -227
  282. package/lib/mutation-driver.mjs +0 -397
  283. package/lib/mutation-driver.mutations.mjs +0 -68
  284. package/lib/paper-config.mutations.mjs +0 -143
  285. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  286. package/scripts/check.mutations.mjs +0 -110
  287. package/scripts/exclusive.mjs +0 -138
  288. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  289. package/scripts/mutation-batteries-frozen.mjs +0 -238
  290. package/scripts/mutation-batteries.frozen.json +0 -108
  291. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  292. package/scripts/rules-see-files.mutations.mjs +0 -131
  293. package/scripts/run-mutations.mjs +0 -100
  294. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  295. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  296. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  297. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  298. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  299. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  300. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  301. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  302. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  303. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  304. package/skills/skill-contract.mutations.mjs +0 -179
  305. package/src/build.mutations.mjs +0 -196
  306. package/src/cli.mutations.mjs +0 -414
  307. package/src/doctor.mutations.mjs +0 -175
  308. package/src/hooks-settings.mutations.mjs +0 -99
  309. package/src/link-skills.mutations.mjs +0 -140
  310. package/src/new-paper.mutations.mjs +0 -65
  311. package/src/structure.mutations.mjs +0 -83
@@ -13,10 +13,19 @@
13
13
  */
14
14
  import assert from "node:assert/strict";
15
15
  import { join, dirname } from "node:path";
16
- import { readFileSync } from "node:fs";
16
+ import {
17
+ mkdirSync,
18
+ mkdtempSync,
19
+ readFileSync,
20
+ realpathSync,
21
+ rmSync,
22
+ writeFileSync,
23
+ } from "node:fs";
24
+ import { tmpdir } from "node:os";
17
25
  import { fileURLToPath } from "node:url";
18
26
  import { Linter } from "eslint";
19
27
  import markdown from "@eslint/markdown";
28
+ import { createChecker } from "../lib/check.mjs";
20
29
 
21
30
  const HERE = dirname(fileURLToPath(import.meta.url));
22
31
  const FIX = join(HERE, "..", "fixtures", "paper-research-question");
@@ -24,11 +33,7 @@ const FIX = join(HERE, "..", "fixtures", "paper-research-question");
24
33
  const { texLanguage } = await import(join(HERE, "latex-language.mjs"));
25
34
  const rq = (await import(join(HERE, "paper-research-question.mjs"))).default;
26
35
 
27
- let n = 0;
28
- const check = (label, cond) => {
29
- assert.ok(cond, label);
30
- n++;
31
- };
36
+ const check = createChecker();
32
37
 
33
38
  const linter = new Linter();
34
39
 
@@ -141,6 +146,67 @@ check(
141
146
  .length === 1,
142
147
  );
143
148
 
149
+ // ── a scorecard that cannot be read is no scorecard: silence, never a crash ────────────
150
+ // A directory where the file should be (EISDIR), and front matter that is not YAML — which
151
+ // `paper/stages` reports on the scorecard itself, so this rule does not report it twice.
152
+ {
153
+ const work = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-rq-")));
154
+ try {
155
+ const paperIn = (name, status) => {
156
+ const dir = join(work, name);
157
+ mkdirSync(dir);
158
+ status(join(dir, "PIPELINE-STATUS.md"));
159
+ const path = join(dir, "paper.md");
160
+ writeFileSync(path, "# A paper\n");
161
+ // A linter rooted at the scratch directory: files outside `cwd` match no config.
162
+ return new Linter({ cwd: work }).verify(
163
+ "# A paper\n",
164
+ [
165
+ {
166
+ files: ["**/*.md"],
167
+ plugins: { markdown, paper: rq },
168
+ language: "markdown/gfm",
169
+ rules: { "paper/research-question": "error" },
170
+ },
171
+ ],
172
+ path,
173
+ );
174
+ };
175
+ check(
176
+ "a scorecard path that is a directory: no finding, no crash",
177
+ JSON.stringify(paperIn("dir", (p) => mkdirSync(p))) === "[]",
178
+ () => paperIn("dir2", (p) => mkdirSync(p)),
179
+ );
180
+ check(
181
+ "a scorecard whose front matter is not YAML: no finding, no crash",
182
+ JSON.stringify(
183
+ paperIn("yaml", (p) =>
184
+ writeFileSync(p, "---\nstages: [submitted\n---\n"),
185
+ ),
186
+ ) === "[]",
187
+ );
188
+ } finally {
189
+ rmSync(work, { recursive: true, force: true });
190
+ }
191
+ }
192
+
193
+ // ── a language whose source code carries no text at all: nothing to compare, silence ───
194
+ {
195
+ const reports = [];
196
+ rq.rules["research-question"]
197
+ .create({
198
+ options: [],
199
+ filename: join(FIX, "shipped-no-rq", "paper.tex"),
200
+ sourceCode: {},
201
+ report: (r) => reports.push(r),
202
+ })
203
+ ["root:exit"]({});
204
+ check(
205
+ "a source code with neither `raw` nor `text` is skipped, even for a shipped paper",
206
+ reports.length === 0,
207
+ );
208
+ }
209
+
144
210
  console.log(
145
- `✓ ${String(n)} assertions passed — paper/research-question, the debt of a shipped paper`,
211
+ `✓ ${String(check.count)} assertions passed — paper/research-question, the debt of a shipped paper`,
146
212
  );
@@ -16,6 +16,7 @@ import { fileURLToPath } from "node:url";
16
16
  import { Linter } from "eslint";
17
17
  import markdown from "@eslint/markdown";
18
18
  import stages from "./paper-stages.mjs";
19
+ import { createChecker } from "../lib/check.mjs";
19
20
 
20
21
  const FIX = join(
21
22
  dirname(fileURLToPath(import.meta.url)),
@@ -25,11 +26,7 @@ const FIX = join(
25
26
  );
26
27
  const linter = new Linter();
27
28
 
28
- let n = 0;
29
- const check = (label, cond) => {
30
- assert.ok(cond, label);
31
- n++;
32
- };
29
+ const check = createChecker();
33
30
 
34
31
  // 🔴 The `n` counter is RUNNING TOTAL across the whole file, so printing it under one rule's
35
32
  // heading would credit that rule with someone else's asserts. That's exactly what happened: the
@@ -38,8 +35,8 @@ const check = (label, cond) => {
38
35
  // stands next to it again.
39
36
  let mark = 0;
40
37
  const since = () => {
41
- const d = n - mark;
42
- mark = n;
38
+ const d = check.count - mark;
39
+ mark = check.count;
43
40
  return d;
44
41
  };
45
42
 
@@ -141,12 +138,10 @@ console.log(
141
138
  const { mkdtempSync, writeFileSync, mkdirSync, rmSync } =
142
139
  await import("node:fs");
143
140
  const root = mkdtempSync(join(FIX, "..", ".tmp-stages-src-"));
144
- // 🔴 try/finally, NOT cleanup at the end of the block. Observed 09-16: under every mutation
145
- // the assert throws — that is, precisely when the harness is doing its job — and cleanup on
146
- // the happy path never runs. One run of the battery left EIGHT `.tmp-stages-src-*` directories
147
- // in the repository, one for each mutation deliberately killed. The litter here is not
148
- // cosmetic: the mutation driver refuses to run on a dirty tree, so the harness would break
149
- // the next run.
141
+ // 🔴 try/finally, NOT cleanup at the end of the block. Any `check()` above can throw on
142
+ // failure — that is precisely when the harness is doing its job — so cleanup written after
143
+ // the checks would never run on that path and would leave a `.tmp-stages-src-*` directory
144
+ // behind in the repository.
150
145
  try {
151
146
  const paper = join(root, "one");
152
147
  mkdirSync(join(paper, "versions"), { recursive: true });
@@ -237,4 +232,4 @@ console.log(
237
232
  `✓ ${String(since())} assertions passed — paper/source, frozen bytes instead of a sha`,
238
233
  );
239
234
 
240
- console.log(`✓ ${String(n)} assertions passed in total`);
235
+ console.log(`✓ ${String(check.count)} assertions passed in total`);
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `paper/stages` and `paper/source` on the malformed declarations the fixture papers do not carry:
3
+ * YAML that does not parse, `stages` that is not a list, an unknown or missing stage, a missing
4
+ * field, a date that is not a date — and, in `versions/`, the files the rule must NOT count.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { join } from "node:path";
8
+ import markdown from "@eslint/markdown";
9
+ import { Linter } from "eslint";
10
+ import { test } from "vitest";
11
+ import { useTempDir, writeTree } from "../test/support.mjs";
12
+ import stages from "./paper-stages.mjs";
13
+
14
+ const root = useTempDir("paper-stages-");
15
+ // Flat config matches `files` against paths under the linter's cwd; the temp papers live outside
16
+ // the repository, so the cwd is the temp root (otherwise: "No matching configuration found").
17
+ const linter = new Linter({ cwd: root });
18
+
19
+ /** The messages of `rule` on a PIPELINE-STATUS.md with `frontmatter`, in a paper holding `files`. */
20
+ function lint(name, frontmatter, { rule = "stages", files = {} } = {}) {
21
+ const dir = writeTree(join(root, name), files);
22
+ const file = join(dir, "PIPELINE-STATUS.md");
23
+ const msgs = linter.verify(
24
+ `---\n${frontmatter}\n---\n\n# Status\n`,
25
+ [
26
+ {
27
+ files: ["**/*.md"],
28
+ plugins: { markdown, paper: stages },
29
+ language: "markdown/gfm",
30
+ languageOptions: { frontmatter: "yaml" },
31
+ rules: { [`paper/${rule}`]: "error" },
32
+ },
33
+ ],
34
+ file,
35
+ );
36
+ assert.deepEqual(
37
+ msgs.filter((m) => m.fatal),
38
+ [],
39
+ `${name}: the rule threw`,
40
+ );
41
+ return msgs.map((m) => m.message);
42
+ }
43
+
44
+ test("YAML that does not parse is named; paper/source leaves it to paper/stages", () => {
45
+ const [msg] = lint("bad-yaml", "stages: [");
46
+ assert.match(msg, /^the frontmatter does not parse as YAML: /);
47
+ assert.deepEqual(lint("bad-yaml-src", "stages: [", { rule: "source" }), []);
48
+ });
49
+
50
+ test("`stages` must be a list: empty and a scalar are each named", () => {
51
+ assert.deepEqual(lint("empty", "stages:"), [
52
+ "`stages` must be a LIST of entries, not empty — a paper can reach the same stage twice",
53
+ ]);
54
+ assert.deepEqual(lint("scalar", "stages: submitted"), [
55
+ "`stages` must be a LIST of entries, not string — a paper can reach the same stage twice",
56
+ ]);
57
+ });
58
+
59
+ test("each entry: an unknown or missing stage, missing fields, a date that is not a date", () => {
60
+ assert.deepEqual(
61
+ lint(
62
+ "entries",
63
+ [
64
+ "stages:",
65
+ " - stage: reviewed",
66
+ " - date: 2026-08-01",
67
+ " - stage: submitted",
68
+ " date: 2026-08-01",
69
+ " - stage: arxiv",
70
+ " date: soon",
71
+ " pdf: versions/x.pdf",
72
+ " bytes: 1",
73
+ " - stage: camera-ready",
74
+ " date: 20260801",
75
+ " pdf: versions/x.pdf",
76
+ " bytes: 1",
77
+ " - stage: arxiv",
78
+ ' date: "2026-08-03"',
79
+ " pdf: versions/x.pdf",
80
+ " bytes: 1",
81
+ ].join("\n"),
82
+ ),
83
+ [
84
+ "unknown stage «reviewed» — the vocabulary is: submitted · camera-ready · arxiv",
85
+ "unknown stage «» — the vocabulary is: submitted · camera-ready · arxiv",
86
+ "the «submitted» entry has no `pdf` field",
87
+ "the «submitted» entry has no `bytes` field",
88
+ "the date «soon» in the «arxiv» entry is not YYYY-MM-DD",
89
+ "the date «20260801» in the «camera-ready» entry is not YYYY-MM-DD",
90
+ // A quoted date stays a string and is read the same as an unquoted one.
91
+ "stage «arxiv» (2026-08-03) is declared, but `versions/x.pdf` is not on disk",
92
+ ],
93
+ );
94
+ });
95
+
96
+ test("paper/source skips a `stages` that is not a list, and entries whose stage is unknown or missing", () => {
97
+ assert.deepEqual(
98
+ lint("src-scalar", "stages: submitted", { rule: "source" }),
99
+ [],
100
+ );
101
+ assert.deepEqual(
102
+ lint("src-skip", "stages:\n - stage: reviewed\n - date: 2026-08-01", {
103
+ rule: "source",
104
+ }),
105
+ [],
106
+ );
107
+ });
108
+
109
+ test("versions/: only dated stage pdfs count — not other files, STALE ones, odd names or other stages", () => {
110
+ assert.deepEqual(
111
+ lint("versions", "title: x", {
112
+ files: {
113
+ "versions/README.md": "x",
114
+ "versions/2026-08-01-submitted.STALE-wrong.pdf": "x",
115
+ "versions/draft.pdf": "x",
116
+ "versions/2026-08-01-review.pdf": "x",
117
+ "versions/2026-08-02-arxiv.pdf": "x",
118
+ },
119
+ }),
120
+ [
121
+ "`versions/2026-08-02-arxiv.pdf` is frozen, but no «arxiv» stage on 2026-08-02 is declared in `stages` — the artefact ran ahead of the declaration",
122
+ ],
123
+ );
124
+ });
125
+
126
+ test("a frontmatter node without a value (another markdown language) reads as empty YAML", () => {
127
+ const reports = [];
128
+ const context = {
129
+ filename: join(root, "valueless", "PIPELINE-STATUS.md"),
130
+ report: (r) => reports.push(r),
131
+ };
132
+ for (const rule of ["stages", "source"])
133
+ stages.rules[rule].create(context).yaml({ type: "yaml" });
134
+ assert.deepEqual(reports, []);
135
+ });
@@ -201,8 +201,15 @@ function texRuns(nodes, math, src, out) {
201
201
  return out;
202
202
  }
203
203
 
204
- function texVisibleRuns(raw) {
205
- const root = getParser().parse(raw).content;
204
+ /**
205
+ * The visible runs of a LaTeX source. `parse` is unified-latex's parser; a test hands in a tree
206
+ * with the fields the walk tolerates being absent.
207
+ */
208
+ export function texVisibleRuns(
209
+ raw,
210
+ { parse = (s) => getParser().parse(s) } = {},
211
+ ) {
212
+ const root = parse(raw).content;
206
213
  // Only the document body is typeset. A fragment with no `document` environment is all body.
207
214
  const document = root.find(
208
215
  (n) => n.type === "environment" && n.env === "document",
@@ -16,7 +16,7 @@ import { readFileSync } from "node:fs";
16
16
  import { dirname, join } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { texLanguage } from "./latex-language.mjs";
19
- import typography from "./paper-typography.mjs";
19
+ import typography, { texVisibleRuns } from "./paper-typography.mjs";
20
20
  import bib from "./bib-reachable-entry.mjs";
21
21
 
22
22
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -293,6 +293,32 @@ describe("bib/reachable-entry", () => {
293
293
  expect(m.column).toBe(1);
294
294
  expect(m.message).toMatch(/`lost`/);
295
295
  });
296
+
297
+ it("an entry with no key is reported as `?`, not skipped", async () => {
298
+ const [m] = ids(
299
+ await lint(bibDoc("@article{\n title = {x}}")),
300
+ "bib/reachable-entry",
301
+ );
302
+ expect(m.message).toMatch(/`\?`/);
303
+ });
304
+
305
+ it("a source code with only `text`, or with neither text nor raw", () => {
306
+ const run = (sourceCode) => {
307
+ const out = [];
308
+ bib.rules["reachable-entry"]
309
+ .create({
310
+ sourceCode: {
311
+ getLocFromIndex: (i) => ({ line: 1, column: i }),
312
+ ...sourceCode,
313
+ },
314
+ report: (r) => out.push(r.data.key),
315
+ })
316
+ ["root:exit"]();
317
+ return out;
318
+ };
319
+ expect(run({ text: bibDoc("@article{t, title = {x}}") })).toEqual(["t"]);
320
+ expect(run({})).toEqual([]);
321
+ });
296
322
  });
297
323
 
298
324
  describe("the escape hatch — a disable directive in a `%` comment", () => {
@@ -335,3 +361,122 @@ describe("the escape hatch — a disable directive in a `%` comment", () => {
335
361
  ).toHaveLength(1);
336
362
  });
337
363
  });
364
+
365
+ describe("where visible text comes from — the walk's other paths", () => {
366
+ it("a decimal in a braced group, in a typeset math argument, in a table's last argument", async () => {
367
+ const src = doc(
368
+ "In a group {.05}.\n\n\\multicolumn{2}{c}{.35}\n\n$x_{.75}$",
369
+ );
370
+ expect(
371
+ ids(await lint(src), "paper/leading-zero").map((m) =>
372
+ m.message.slice(0, 5),
373
+ ),
374
+ ).toEqual(["`.05`", "`.35`", "`.75`"]);
375
+ });
376
+
377
+ it("🔴 inside `\\frac{}{}` unified-latex gives offsets relative to the argument: the decimal is NOT reported", async () => {
378
+ // Guards: a character whose file offset is unknown gets no report and, above all, no fix at
379
+ // a guessed place. Known false negative, recorded rather than hidden.
380
+ const src = doc("$\\frac{1}{.25}$");
381
+ expect(texVisibleRuns(src).map((r) => [r.text, r.offs])).toContainEqual([
382
+ ".25",
383
+ [null, null, null],
384
+ ]);
385
+ expect(ids(await lint(src), "paper/leading-zero")).toEqual([]);
386
+ });
387
+
388
+ it("a last-argument macro written without its argument ends the run and reports nothing", async () => {
389
+ expect(ids(await lint(doc("See \\href")), "paper/leading-zero")).toEqual(
390
+ [],
391
+ );
392
+ });
393
+
394
+ it("a tree with no content, no args and a non-string environment name is walked, not thrown on", () => {
395
+ const tree = {
396
+ content: [
397
+ { type: "string", content: "a" },
398
+ { type: "group" },
399
+ { type: "environment", env: { weird: true } },
400
+ { type: "inlinemath" },
401
+ { type: "string", content: "b" },
402
+ ],
403
+ };
404
+ expect(texVisibleRuns("ab", { parse: () => tree })).toEqual([
405
+ { text: "a", offs: [null] },
406
+ { text: "b", offs: [null] },
407
+ ]);
408
+ });
409
+ });
410
+
411
+ describe("markdown: offsets that cannot be followed, and rules that are LaTeX-only", () => {
412
+ const mdLint = async (text, rules) => {
413
+ const [res] = await new ESLint({
414
+ cwd: FIX,
415
+ overrideConfigFile: true,
416
+ overrideConfig: [
417
+ {
418
+ files: ["**/*.md"],
419
+ plugins: { markdown, paper: typography },
420
+ language: "markdown/gfm",
421
+ rules,
422
+ },
423
+ ],
424
+ }).lintText(text, { filePath: join(FIX, "inline", "paper.md") });
425
+ expect(res.messages.filter((m) => m.fatal)).toEqual([]);
426
+ return res;
427
+ };
428
+
429
+ it("`§` before prose is reported without a fix", async () => {
430
+ const src = "As § above.\n";
431
+ const res = await mdLint(src, { "paper/section-word": "warn" });
432
+ expect(res.messages.map((m) => [m.ruleId, m.column, m.fix])).toEqual([
433
+ ["paper/section-word", 4, undefined],
434
+ ]);
435
+ });
436
+
437
+ it("a sign or a dot written as a character reference has no source offset: nothing is reported", async () => {
438
+ const res = await mdLint("See § 5 and .25.\n", {
439
+ "paper/section-word": "warn",
440
+ "paper/leading-zero": "warn",
441
+ });
442
+ expect(res.messages).toEqual([]);
443
+ });
444
+
445
+ it("figure-ref-style is LaTeX-only: silent on markdown", async () => {
446
+ const res = await mdLint("Figure~\\ref{a} and Fig.~\\ref{b}.\n", {
447
+ "paper/figure-ref-style": "warn",
448
+ });
449
+ expect(res.messages).toEqual([]);
450
+ });
451
+ });
452
+
453
+ describe("a source code the rules did not expect — the defaults hold", () => {
454
+ const reports = (ruleId, sourceCode) => {
455
+ const out = [];
456
+ const context = {
457
+ sourceCode: {
458
+ getLocFromIndex: (i) => ({ line: 1, column: i }),
459
+ ...sourceCode,
460
+ },
461
+ report: (r) => out.push(r),
462
+ };
463
+ typography.rules[ruleId].create(context)["root:exit"]();
464
+ return out;
465
+ };
466
+
467
+ it("LaTeX source without an AST: no comment ranges, the sign is still found", () => {
468
+ const found = reports("section-word", { raw: "See §5." });
469
+ expect(found.map((r) => [r.messageId, r.loc.start.column])).toEqual([
470
+ ["sign", 4],
471
+ ]);
472
+ });
473
+
474
+ it("markdown text nodes without positions: seen, never reported at a guessed place", () => {
475
+ const ast = {
476
+ type: "root",
477
+ children: [{ type: "text", value: "§5 and .25" }],
478
+ };
479
+ expect(reports("section-word", { text: "§5 and .25", ast })).toEqual([]);
480
+ expect(reports("leading-zero", { text: "§5 and .25", ast })).toEqual([]);
481
+ });
482
+ });
@@ -11,7 +11,6 @@
11
11
  * foreign schema, facts about another PDF), and it is QUIET on a balanced page, a stub, a review
12
12
  * build, a file that is not paper.tex — and when nobody turned it on.
13
13
  */
14
- import assert from "node:assert/strict";
15
14
  import { createHash } from "node:crypto";
16
15
  import {
17
16
  mkdirSync,
@@ -25,6 +24,7 @@ import { dirname, join } from "node:path";
25
24
  import { fileURLToPath } from "node:url";
26
25
  import { ESLint } from "eslint";
27
26
  import { texLanguage } from "./latex-language.mjs";
27
+ import { createChecker } from "../lib/check.mjs";
28
28
 
29
29
  const HERE = dirname(fileURLToPath(import.meta.url));
30
30
  const { parseFacts, judgeColumns, DEFAULT_TOLERANCE_PT } = await import(
@@ -32,11 +32,7 @@ const { parseFacts, judgeColumns, DEFAULT_TOLERANCE_PT } = await import(
32
32
  );
33
33
  const { buildConfig } = await import(join(HERE, "..", "src", "cli.ts"));
34
34
 
35
- let n = 0;
36
- const check = (label, cond, detail = "") => {
37
- assert.ok(cond, detail ? `${label} — ${detail}` : label);
38
- n++;
39
- };
35
+ const check = createChecker();
40
36
 
41
37
  const TEX =
42
38
  "% a paper\n\\documentclass[sigconf]{acmart}\n\\begin{document}x\\end{document}\n";
@@ -179,6 +175,66 @@ try {
179
175
  only(await lint([join(old, "paper.tex")]))[0]?.messageId === "schema",
180
176
  );
181
177
 
178
+ // ── the facts' optional spellings, and a paper.tex the rule cannot place the finding in ──
179
+ check(
180
+ "parseFacts: no schema at all, or JSON null, is schema `null` — named, not crashed on",
181
+ parseFacts("{}").data?.got === "null" &&
182
+ parseFacts("null").data?.got === "null",
183
+ );
184
+ check(
185
+ "parseFacts: facts without a pdf_sha256 are `factsBroken`",
186
+ parseFacts(JSON.stringify(facts(MEASURED(1, 1), { pdf_sha256: undefined })))
187
+ .messageId === "factsBroken",
188
+ );
189
+ const abs = paper("absolute-pdf", null);
190
+ writeFileSync(
191
+ join(abs, "_build", "paper.facts.json"),
192
+ JSON.stringify(
193
+ facts(MEASURED(621.5, 264.8), { pdf: join(abs, "paper.pdf") }),
194
+ ),
195
+ );
196
+ const absMsgs = only(await lint([join(abs, "paper.tex")]));
197
+ check(
198
+ "facts naming the PDF by an absolute path are read from there, and judged",
199
+ absMsgs.length === 1 && absMsgs[0].messageId === "unbalanced",
200
+ JSON.stringify(absMsgs),
201
+ );
202
+ const noClass = paper("no-documentclass", facts(MEASURED(621.5, 264.8)));
203
+ writeFileSync(
204
+ join(noClass, "paper.tex"),
205
+ "\\begin{document}x\\end{document}\n",
206
+ );
207
+ check(
208
+ "a paper.tex with no \\documentclass line: the finding goes on line 1",
209
+ only(await lint([join(noClass, "paper.tex")]))[0]?.line === 1,
210
+ );
211
+ // The text is linted from memory and paper.tex is not on disk: the rule cannot read it back
212
+ // to find the class line, and still reports — on line 1.
213
+ const unsaved = paper("unsaved", facts(MEASURED(621.5, 264.8)));
214
+ rmSync(join(unsaved, "paper.tex"));
215
+ const [fromMemory] = await new ESLint({
216
+ cwd: root,
217
+ overrideConfigFile: true,
218
+ overrideConfig: buildConfig(
219
+ {
220
+ rules: [
221
+ {
222
+ basePath: root,
223
+ files: ["papers/**"],
224
+ rules: { "pdf/last-page-balance": "error" },
225
+ },
226
+ ],
227
+ },
228
+ texLanguage,
229
+ ),
230
+ }).lintText(TEX, { filePath: join(unsaved, "paper.tex") });
231
+ const mem = only(fromMemory.messages);
232
+ check(
233
+ "a paper.tex linted from memory, not on disk: reported on line 1, not crashed on",
234
+ mem.length === 1 && mem[0].line === 1 && mem[0].messageId === "unbalanced",
235
+ JSON.stringify(fromMemory.messages),
236
+ );
237
+
182
238
  // ── quiet ───────────────────────────────────────────────────────────────────────────
183
239
  const bal = paper("balanced", facts(MEASURED(464.2, 461.5)));
184
240
  const stub = paper("stub", facts({ kind: "stub", words: 12 }));
@@ -204,5 +260,5 @@ try {
204
260
  }
205
261
 
206
262
  console.log(
207
- `✓ ${String(n)} assertions passed — pdf/last-page-balance: fires on unbalanced and unjudgeable, quiet on balanced/stub/review/off`,
263
+ `✓ ${String(check.count)} assertions passed — pdf/last-page-balance: fires on unbalanced and unjudgeable, quiet on balanced/stub/review/off`,
208
264
  );
@@ -53,7 +53,8 @@ function describe(e) {
53
53
  return `${where} ${e.message}`;
54
54
  }
55
55
 
56
- const errorsOf = (validate, data) =>
56
+ /** The schema's verdict on `data` as messages. Ajv types `errors` as possibly null; that is tolerated. */
57
+ export const errorsOf = (validate, data) =>
57
58
  validate(data)
58
59
  ? []
59
60
  : (validate.errors ?? []).filter((e) => e.keyword !== "if").map(describe);
@@ -5,7 +5,7 @@
5
5
  import { describe, expect, it } from "vitest";
6
6
  import { ESLint } from "eslint";
7
7
  import markdown from "@eslint/markdown";
8
- import review from "./review-frontmatter.mjs";
8
+ import review, { errorsOf } from "./review-frontmatter.mjs";
9
9
  import sibling from "./sibling-frontmatter.mjs";
10
10
 
11
11
  async function lint(text) {
@@ -81,6 +81,42 @@ describe("review/frontmatter — the findings record", () => {
81
81
  });
82
82
  });
83
83
 
84
+ describe("review/frontmatter — what the parser and the validator may hand back", () => {
85
+ /** The rule's visitors over a fake context; the reports it makes. */
86
+ const visit = (value) => {
87
+ const reports = [];
88
+ const v = review.rules.frontmatter.create({
89
+ report: (r) => reports.push(r),
90
+ });
91
+ v.yaml({ value });
92
+ v["root:exit"]({});
93
+ return reports.map(
94
+ (r) => r.messageId + (r.data?.why ? `: ${r.data.why}` : ""),
95
+ );
96
+ };
97
+
98
+ it("an empty frontmatter block is validated as `{}` — silent", async () => {
99
+ expect(await lint("---\n---\n# Review\n")).toEqual([]);
100
+ expect(visit(undefined)).toEqual([]);
101
+ });
102
+
103
+ it("a parser failure without a YAML reason is reported by its message, or as unparseable", () => {
104
+ const throwing = (thrown) => ({
105
+ toString() {
106
+ throw thrown;
107
+ },
108
+ });
109
+ expect(visit(throwing(new Error("boom")))).toEqual(["malformed: boom"]);
110
+ expect(visit(throwing("not an Error"))).toEqual(["malformed: unparseable"]);
111
+ });
112
+
113
+ it("a failed validation with no errors attached names nothing", () => {
114
+ const validate = () => false;
115
+ validate.errors = null;
116
+ expect(errorsOf(validate, {})).toEqual([]);
117
+ });
118
+ });
119
+
84
120
  describe("sibling/frontmatter — the same mechanism, the package's own sibling-card schema", () => {
85
121
  async function card(text) {
86
122
  const eslint = new ESLint({