paperlint 3.0.0 → 3.1.1

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 (319) hide show
  1. package/.github/workflows/ci.yml +28 -41
  2. package/.github/workflows/dependabot-automerge.yml +1 -1
  3. package/CLAUDE.md +51 -56
  4. package/CONTRIBUTING.md +30 -18
  5. package/README.md +179 -180
  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/index.d.ts.map +1 -1
  16. package/dist/adapters/references/index.js +14 -5
  17. package/dist/adapters/references/index.js.map +1 -1
  18. package/dist/adapters/references/pool.d.ts +8 -0
  19. package/dist/adapters/references/pool.d.ts.map +1 -0
  20. package/dist/adapters/references/pool.js +19 -0
  21. package/dist/adapters/references/pool.js.map +1 -0
  22. package/dist/build-engine.d.ts.map +1 -1
  23. package/dist/build-engine.js +6 -2
  24. package/dist/build-engine.js.map +1 -1
  25. package/dist/build.d.ts +10 -2
  26. package/dist/build.d.ts.map +1 -1
  27. package/dist/build.js +29 -11
  28. package/dist/build.js.map +1 -1
  29. package/dist/cli.d.ts +93 -6
  30. package/dist/cli.d.ts.map +1 -1
  31. package/dist/cli.js +296 -80
  32. package/dist/cli.js.map +1 -1
  33. package/dist/doctor.d.ts +27 -1
  34. package/dist/doctor.d.ts.map +1 -1
  35. package/dist/doctor.js +4 -3
  36. package/dist/doctor.js.map +1 -1
  37. package/dist/domain/result.d.ts +0 -2
  38. package/dist/domain/result.d.ts.map +1 -1
  39. package/dist/domain/result.js +0 -4
  40. package/dist/domain/result.js.map +1 -1
  41. package/dist/engine.d.ts +9 -0
  42. package/dist/engine.d.ts.map +1 -1
  43. package/dist/engine.js +27 -0
  44. package/dist/engine.js.map +1 -1
  45. package/dist/hooks-settings.d.ts +8 -5
  46. package/dist/hooks-settings.d.ts.map +1 -1
  47. package/dist/hooks-settings.js +32 -32
  48. package/dist/hooks-settings.js.map +1 -1
  49. package/dist/init.d.ts +8 -0
  50. package/dist/init.d.ts.map +1 -1
  51. package/dist/init.js +4 -4
  52. package/dist/init.js.map +1 -1
  53. package/dist/latex-log.d.ts +0 -2
  54. package/dist/latex-log.d.ts.map +1 -1
  55. package/dist/latex-log.js +7 -13
  56. package/dist/latex-log.js.map +1 -1
  57. package/dist/link-skills.d.ts +15 -2
  58. package/dist/link-skills.d.ts.map +1 -1
  59. package/dist/link-skills.js +36 -20
  60. package/dist/link-skills.js.map +1 -1
  61. package/dist/new-paper.d.ts +8 -1
  62. package/dist/new-paper.d.ts.map +1 -1
  63. package/dist/new-paper.js +50 -15
  64. package/dist/new-paper.js.map +1 -1
  65. package/dist/paper-files.d.ts +30 -8
  66. package/dist/paper-files.d.ts.map +1 -1
  67. package/dist/paper-files.js +59 -30
  68. package/dist/paper-files.js.map +1 -1
  69. package/dist/pdf-facts.d.ts +11 -0
  70. package/dist/pdf-facts.d.ts.map +1 -1
  71. package/dist/pdf-facts.js +16 -18
  72. package/dist/pdf-facts.js.map +1 -1
  73. package/dist/pdf-geometry.js +1 -1
  74. package/dist/pdf-geometry.js.map +1 -1
  75. package/dist/presets.d.ts +2 -0
  76. package/dist/presets.d.ts.map +1 -1
  77. package/dist/presets.js +3 -1
  78. package/dist/presets.js.map +1 -1
  79. package/dist/references.js +2 -2
  80. package/dist/references.js.map +1 -1
  81. package/dist/structure.js +1 -1
  82. package/dist/structure.js.map +1 -1
  83. package/dist/tex-requirements.d.ts +8 -0
  84. package/dist/tex-requirements.d.ts.map +1 -1
  85. package/dist/tex-requirements.js +6 -5
  86. package/dist/tex-requirements.js.map +1 -1
  87. package/dist/toolchain.d.ts +8 -3
  88. package/dist/toolchain.d.ts.map +1 -1
  89. package/dist/toolchain.js +26 -10
  90. package/dist/toolchain.js.map +1 -1
  91. package/dist/types.d.ts +6 -0
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/venue-rules.d.ts.map +1 -1
  94. package/dist/venue-rules.js +12 -10
  95. package/dist/venue-rules.js.map +1 -1
  96. package/docs/configuration.md +48 -4
  97. package/docs/prior-art/README.md +1 -1
  98. package/docs/prior-art/test-tooling.md +7 -30
  99. package/docs/rules.md +29 -25
  100. package/docs/skills.md +80 -0
  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 +56 -9
  109. package/eslint-rules/paper-typography.test.mjs +186 -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.schema.json +1 -1
  113. package/eslint-rules/review-frontmatter.test.mjs +37 -1
  114. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  115. package/eslint-rules/tex-build.mjs +13 -13
  116. package/eslint.config.mjs +6 -2
  117. package/fixtures/toolchain-mirror/catalog.txt +1 -0
  118. package/fixtures/toolchain-mirror/install-tl +4 -1
  119. package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
  120. package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
  121. package/hooks/hooks.harness.mjs +6 -11
  122. package/hooks/paper-edit-guard.hook.mjs +2 -2
  123. package/hooks/paper-edit-guard.test.mjs +61 -0
  124. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  125. package/hooks/paper-skills-nudge.test.mjs +38 -0
  126. package/hooks/paper-status-gates.test.mjs +42 -0
  127. package/lib/agent-cli-version.harness.mjs +7 -0
  128. package/lib/check.d.mts +17 -0
  129. package/lib/check.mjs +50 -0
  130. package/lib/check.test.ts +67 -0
  131. package/lib/markdown.mjs +16 -69
  132. package/lib/markdown.test.mjs +246 -0
  133. package/lib/paper-config.harness.mjs +28 -9
  134. package/lib/paper-config.mjs +7 -2
  135. package/lib/skill-checks.mjs +18 -46
  136. package/lib/skill-checks.test.mjs +297 -0
  137. package/lib/skill-corpus.mjs +11 -13
  138. package/lib/skill-corpus.test.mjs +54 -0
  139. package/lib/skill-trigger-cases.harness.mjs +57 -4
  140. package/package.json +6 -5
  141. package/scripts/check.harness.mjs +4 -10
  142. package/scripts/check.mjs +45 -42
  143. package/scripts/check.test.mjs +104 -0
  144. package/scripts/coverage-config.test.ts +406 -0
  145. package/scripts/eslint-report-guard.test.mjs +48 -0
  146. package/scripts/harness-api.frozen.json +0 -2
  147. package/scripts/layer-legacy-frozen.d.mts +9 -1
  148. package/scripts/layer-legacy-frozen.mjs +28 -11
  149. package/scripts/layer-legacy-frozen.test.ts +143 -1
  150. package/scripts/rules-are-content-only.harness.mjs +3 -11
  151. package/scripts/rules-are-content-only.mjs +29 -19
  152. package/scripts/rules-are-content-only.test.mjs +61 -0
  153. package/scripts/rules-see-files.mjs +11 -9
  154. package/scripts/rules-see-files.test.mjs +71 -0
  155. package/scripts/test.mjs +44 -0
  156. package/scripts/test.test.mjs +52 -0
  157. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  158. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  159. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  160. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  161. package/skills/paper-pipeline/scripts/README.md +13 -132
  162. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  163. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  164. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  165. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  166. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  167. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  168. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  169. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  170. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  171. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  172. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  173. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  174. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  175. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  176. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
  177. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  178. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  179. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  180. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  181. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  182. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  183. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  184. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  185. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  186. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  187. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  188. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  189. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  190. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  191. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  192. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  193. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  194. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  195. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  196. package/skills/tighten-paper/structure.test.mjs +116 -0
  197. package/skills/verify-citations/SKILL.md +5 -5
  198. package/skills/verify-citations/SKILL.md.spec.ts +4 -4
  199. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +235 -0
  200. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  201. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  202. package/skills/verify-citations/scripts/bib-authors.mjs +2 -1
  203. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  204. package/skills/verify-citations/scripts/verify-cites.mjs +96 -96
  205. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +287 -0
  206. package/skills/verify-citations/scripts/verify-cites.test.mjs +149 -5
  207. package/src/adapters/banal/failure.test.ts +70 -1
  208. package/src/adapters/banal/index.test.ts +16 -0
  209. package/src/adapters/banal/output.test.ts +5 -0
  210. package/src/adapters/curl/download.test.ts +43 -1
  211. package/src/adapters/memory/index.test.ts +25 -0
  212. package/src/adapters/node/files.io.ts +3 -2
  213. package/src/adapters/node/files.test.ts +13 -1
  214. package/src/adapters/node/process.test.ts +38 -0
  215. package/src/adapters/pdfjs/fill.test.ts +34 -0
  216. package/src/adapters/pdfjs/fill.ts +44 -25
  217. package/src/adapters/references/index.test.ts +228 -0
  218. package/src/adapters/references/index.ts +21 -6
  219. package/src/adapters/references/pool.test.ts +31 -0
  220. package/src/adapters/references/pool.ts +24 -0
  221. package/src/build-engine.harness.mjs +3 -7
  222. package/src/build-engine.test.ts +137 -0
  223. package/src/build-engine.ts +6 -1
  224. package/src/build.harness.mjs +51 -11
  225. package/src/build.test.ts +362 -0
  226. package/src/build.ts +40 -14
  227. package/src/cli.harness.mjs +12 -19
  228. package/src/cli.test.ts +584 -0
  229. package/src/cli.ts +409 -94
  230. package/src/doctor.harness.mjs +3 -8
  231. package/src/doctor.test.ts +64 -0
  232. package/src/doctor.ts +12 -4
  233. package/src/domain/result.test.ts +4 -0
  234. package/src/domain/result.ts +0 -5
  235. package/src/engine.harness.mjs +77 -7
  236. package/src/engine.ts +35 -0
  237. package/src/facts-file.test.ts +110 -0
  238. package/src/hooks-settings.harness.mjs +43 -13
  239. package/src/hooks-settings.ts +41 -35
  240. package/src/init.test.ts +308 -5
  241. package/src/init.ts +4 -4
  242. package/src/latex-log.harness.mjs +32 -7
  243. package/src/latex-log.ts +6 -13
  244. package/src/latex-loop.harness.mjs +8 -7
  245. package/src/link-skills.harness.mjs +3 -8
  246. package/src/link-skills.test.ts +105 -0
  247. package/src/link-skills.ts +75 -21
  248. package/src/linted-set.test.ts +118 -0
  249. package/src/new-paper.harness.mjs +5 -7
  250. package/src/new-paper.test.ts +203 -2
  251. package/src/new-paper.ts +84 -17
  252. package/src/paper-files.test.ts +122 -5
  253. package/src/paper-files.ts +99 -39
  254. package/src/paper-settings-commands.test.ts +41 -5
  255. package/src/paper-settings.test.ts +20 -0
  256. package/src/parse-args.test.ts +48 -0
  257. package/src/pdf-facts.harness.mjs +3 -6
  258. package/src/pdf-facts.test.ts +144 -0
  259. package/src/pdf-facts.ts +17 -19
  260. package/src/pdf-geometry.harness.mjs +3 -7
  261. package/src/pdf-geometry.ts +1 -1
  262. package/src/presets.test.ts +2 -0
  263. package/src/presets.ts +7 -1
  264. package/src/references.test.ts +97 -0
  265. package/src/references.ts +2 -2
  266. package/src/rules-config.harness.mjs +10 -8
  267. package/src/rules-off.test.ts +111 -0
  268. package/src/structure.harness.mjs +5 -9
  269. package/src/structure.ts +1 -1
  270. package/src/tex-requirements.harness.mjs +34 -7
  271. package/src/tex-requirements.ts +12 -6
  272. package/src/toolchain.harness.mjs +44 -13
  273. package/src/toolchain.test.ts +280 -0
  274. package/src/toolchain.ts +37 -12
  275. package/src/types.ts +6 -0
  276. package/src/venue-rules.test.ts +63 -0
  277. package/src/venue-rules.ts +14 -12
  278. package/templates/paper/paperlint.json +1 -1
  279. package/action.mutations.mjs +0 -162
  280. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  281. package/eslint-rules/latex-language.mutations.mjs +0 -196
  282. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  283. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  284. package/eslint-rules/papers.mutations.mjs +0 -186
  285. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  286. package/eslint-rules/tex-build.mutations.mjs +0 -258
  287. package/hooks/hooks.mutations.mjs +0 -337
  288. package/lib/agent-cli-version.mutations.mjs +0 -109
  289. package/lib/mutation-driver.harness.mjs +0 -227
  290. package/lib/mutation-driver.mjs +0 -397
  291. package/lib/mutation-driver.mutations.mjs +0 -68
  292. package/lib/paper-config.mutations.mjs +0 -143
  293. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  294. package/scripts/check.mutations.mjs +0 -110
  295. package/scripts/exclusive.mjs +0 -138
  296. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  297. package/scripts/mutation-batteries-frozen.mjs +0 -238
  298. package/scripts/mutation-batteries.frozen.json +0 -108
  299. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  300. package/scripts/rules-see-files.mutations.mjs +0 -131
  301. package/scripts/run-mutations.mjs +0 -100
  302. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  303. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  304. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  305. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  306. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  307. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  308. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  309. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  310. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  311. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  312. package/skills/skill-contract.mutations.mjs +0 -179
  313. package/src/build.mutations.mjs +0 -196
  314. package/src/cli.mutations.mjs +0 -414
  315. package/src/doctor.mutations.mjs +0 -175
  316. package/src/hooks-settings.mutations.mjs +0 -99
  317. package/src/link-skills.mutations.mjs +0 -140
  318. package/src/new-paper.mutations.mjs +0 -79
  319. package/src/structure.mutations.mjs +0 -83
@@ -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
+ });
@@ -50,6 +50,14 @@
50
50
  * inserts the zero there. The arXiv lookbehind stays: in `2310.05736` the dot follows a digit.
51
51
  * A text macro outside the known set (`\hl{.05}`) is read as a parameter and NOT reported — a
52
52
  * miss costs less than a finding on markup.
53
+ *
54
+ * 🔴 A NUMBER AFTER `¶` OR `§` IS A DESIGNATOR, NOT A DECIMAL (#102). An auditing standard is cited
55
+ * by paragraph as `¶¶.42`, and `--fix` rewrote it to `¶¶0.42` — a fix that changed a reference.
56
+ * So a lexeme right after the sign (`¶`, `§`, `\P`, `\S`, spaces or `~` between) is silent. The
57
+ * same sentence then goes on `and .44`: a paragraph number continuing the list, or a decimal —
58
+ * the text does not say which. In a paragraph that already cites `¶.nn` this way, a bare decimal
59
+ * is REPORTED WITHOUT A FIX and the zero is offered as a suggestion: an autofix must never be the
60
+ * one to decide what a number means. A real `.05` in any other paragraph is fixed as before.
53
61
  */
54
62
  import { getParser } from "@unified-latex/unified-latex-util-parse";
55
63
 
@@ -193,8 +201,15 @@ function texRuns(nodes, math, src, out) {
193
201
  return out;
194
202
  }
195
203
 
196
- function texVisibleRuns(raw) {
197
- 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;
198
213
  // Only the document body is typeset. A fragment with no `document` environment is all body.
199
214
  const document = root.find(
200
215
  (n) => n.type === "environment" && n.env === "document",
@@ -312,16 +327,43 @@ function sectionWord(context) {
312
327
  // (2310.05736) out: there the dot follows a digit.
313
328
  const BARE_DECIMAL = /(?<![\d.\w])\.\d{2,}\b/g;
314
329
 
330
+ // A paragraph or section sign — the glyph, or `\P`/`\S` not followed by a letter — and what may
331
+ // sit between it and the number. Both read the SOURCE, because in LaTeX `\P\P.42` reaches the
332
+ // visible runs as `.42` alone: the macros end the run.
333
+ const DESIGNATOR_BEFORE = /(?:[¶§]|\\[PS](?![A-Za-z]))[ \t~]*$/;
334
+ const DESIGNATED = /(?:[¶§]|\\[PS](?![A-Za-z]))[ \t~]*\.\d/;
335
+
336
+ /** The source of the paragraph holding `i`, up to `i`: from the last blank line before it. */
337
+ const paragraphBefore = (src, i) => {
338
+ const head = src.slice(0, i);
339
+ const blank = [...head.matchAll(/\n[ \t]*\n/g)].at(-1);
340
+ return head.slice(blank ? blank.index + blank[0].length : 0);
341
+ };
342
+
315
343
  function leadingZero(context) {
316
- for (const r of visibleRuns(context.sourceCode))
344
+ const sc = context.sourceCode;
345
+ const src = isTex(sc) ? sc.raw : sc.text;
346
+ for (const r of visibleRuns(sc))
317
347
  for (const m of r.text.matchAll(BARE_DECIMAL)) {
318
348
  const dot = r.offs[m.index];
319
349
  if (dot === null || dot === undefined) continue;
320
- at(context, dot, dot + 1, {
321
- messageId: "bare",
322
- data: { n: m[0] },
323
- fix: (f) => f.insertTextBeforeRange([dot, dot], "0"),
324
- });
350
+ const before = paragraphBefore(src, dot);
351
+ if (DESIGNATOR_BEFORE.test(before)) continue;
352
+ const zero = (f) => f.insertTextBeforeRange([dot, dot], "0");
353
+ at(
354
+ context,
355
+ dot,
356
+ dot + 1,
357
+ DESIGNATED.test(before)
358
+ ? {
359
+ messageId: "ambiguous",
360
+ data: { n: m[0] },
361
+ suggest: [
362
+ { messageId: "insertZero", data: { n: m[0] }, fix: zero },
363
+ ],
364
+ }
365
+ : { messageId: "bare", data: { n: m[0] }, fix: zero },
366
+ );
325
367
  }
326
368
  }
327
369
 
@@ -349,13 +391,14 @@ function figureRefStyle(context) {
349
391
  }
350
392
  }
351
393
 
352
- const rule = (description, messages, check) => ({
394
+ const rule = (description, messages, check, meta = {}) => ({
353
395
  meta: {
354
396
  type: "suggestion",
355
397
  fixable: "code",
356
398
  docs: { description },
357
399
  schema: [],
358
400
  messages,
401
+ ...meta,
359
402
  },
360
403
  create: (context) => ({ "root:exit": () => check(context) }),
361
404
  });
@@ -373,8 +416,12 @@ export default {
373
416
  "a decimal below 1 written without its leading zero (IEEE / ISO 80000-1); fixable",
374
417
  {
375
418
  bare: "`{{n}}` has no leading zero — write `0{{n}}` (IEEE / ISO 80000-1); `paperlint lint --fix` inserts it",
419
+ ambiguous:
420
+ "`{{n}}` in a paragraph that cites `¶`/`§` numbers: a decimal without its leading zero, or another paragraph number? Not fixed automatically — write `0{{n}}` for a decimal, or put the sign before a paragraph number",
421
+ insertZero: "It is a decimal: write `0{{n}}`",
376
422
  },
377
423
  leadingZero,
424
+ { hasSuggestions: true },
378
425
  ),
379
426
  "figure-ref-style": rule(
380
427
  "`Fig.~\\ref` and `Figure~\\ref` mixed in one document; fixable to the majority form",
@@ -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));
@@ -203,6 +203,46 @@ describe("paper/leading-zero", () => {
203
203
  });
204
204
  });
205
205
 
206
+ describe("paper/leading-zero — a number after ¶ or § is not a decimal (#102)", () => {
207
+ // From a consumer's bibliography: `--fix` rewrote the paragraph number `¶¶.42` to `¶¶0.42`.
208
+ const CITE =
209
+ "PCAOB. \\emph{Audit of Financial Statements}, ¶¶.42 (design\neffectiveness) and .44 (operating effectiveness).";
210
+
211
+ it.each([
212
+ ["¶¶.42", "Standard 5, ¶¶.42 applies."],
213
+ ["¶ .42", "Standard 5, ¶ .42 applies."],
214
+ ["\\P\\P.42", "Standard 5, \\P\\P.42 applies."],
215
+ ["§.12", "Rule 10b-5, §.12 applies."],
216
+ ])("SILENT: %s is a designator", async (_, body) => {
217
+ // Guards: the defect — the lexeme after a paragraph/section sign was read as a decimal.
218
+ expect(ids(await lint(doc(body)), "paper/leading-zero")).toEqual([]);
219
+ expect(await fixed(doc(body))).toBe(doc(body));
220
+ });
221
+
222
+ it("🔴 the list continuation `and .44` is reported WITHOUT a fix, with the zero as a suggestion", async () => {
223
+ const res = await lint(doc(CITE));
224
+ const found = ids(res, "paper/leading-zero");
225
+ expect(found.map((m) => [m.line, m.message.slice(0, 5)])).toEqual([
226
+ [4, "`.44`"],
227
+ ]);
228
+ expect(found[0].fix).toBeUndefined();
229
+ expect(found[0].suggestions).toHaveLength(1);
230
+ // Guards: --fix changes nothing in an ambiguous paragraph — neither number.
231
+ expect(await fixed(doc(CITE))).toBe(doc(CITE));
232
+ });
233
+
234
+ it("…while a real decimal still fixes, in markdown too, even beside a cited paragraph", async () => {
235
+ const body = `${CITE}\n\nWe use a threshold of .05 throughout.`;
236
+ expect(await fixed(doc(body))).toBe(
237
+ doc(`${CITE}\n\nWe use a threshold of 0.05 throughout.`),
238
+ );
239
+ const md = "See ¶¶.42 and .44.\n\nSignificant at p<.05.\n";
240
+ expect(await fixed(md, "paper.md")).toBe(
241
+ "See ¶¶.42 and .44.\n\nSignificant at p<0.05.\n",
242
+ );
243
+ });
244
+ });
245
+
206
246
  describe("paper/figure-ref-style", () => {
207
247
  it("the minority form is reported and rewritten to the majority", async () => {
208
248
  const src = doc("Figure~\\ref{a}, Figure~\\ref{b} and Fig.~\\ref{c}.");
@@ -253,6 +293,32 @@ describe("bib/reachable-entry", () => {
253
293
  expect(m.column).toBe(1);
254
294
  expect(m.message).toMatch(/`lost`/);
255
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
+ });
256
322
  });
257
323
 
258
324
  describe("the escape hatch — a disable directive in a `%` comment", () => {
@@ -295,3 +361,122 @@ describe("the escape hatch — a disable directive in a `%` comment", () => {
295
361
  ).toHaveLength(1);
296
362
  });
297
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 &#167; 5 and &#46;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);
@@ -2,7 +2,7 @@
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "$id": "urn:paperlint:review-frontmatter",
4
4
  "title": "paperlint review frontmatter",
5
- "description": "The YAML frontmatter of a review report under reviews/. A review with no `findings` key is not a findings record and is not checked for findings. A project adds its own required fields with the `reviewSchema` setting; its schema is applied together with this one.",
5
+ "description": "The YAML frontmatter of a review report under reviews/. A review with no `findings` key is not a findings record and is not checked for findings.",
6
6
  "type": "object",
7
7
  "properties": {
8
8
  "findings": {