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
@@ -56,7 +56,8 @@ const HERE = dirname(fileURLToPath(import.meta.url));
56
56
  //
57
57
  // This is exactly the "four forms of a reference" class from `CLAUDE.md`: a file that moves changes
58
58
  // the DEPTH of `../` to the root, and a grep over the path does not show it. All fifteen neighbours
59
- // in the directory (`*.harness.mjs`, `*.mutations.mjs`) got four `..` during the resettlement; this
59
+ // in the directory (`*.harness.mjs` and the since-removed `*.mutations.mjs`) got four `..` during
60
+ // the resettlement; this
60
61
  // file did not, because it was the only one that was not a test and nobody ran it.
61
62
  const ROOT = consumerRoot();
62
63
 
@@ -418,7 +419,7 @@ function writeReport(dir, g, count, body) {
418
419
  return rel;
419
420
  }
420
421
 
421
- function main(argv) {
422
+ export function main(argv) {
422
423
  const dir = resolve(argv[2] || ".");
423
424
  if (!existsSync(join(dir, "paper.md"))) {
424
425
  console.error(`no paper.md in ${dir}`);
@@ -437,9 +438,17 @@ function main(argv) {
437
438
  }
438
439
 
439
440
  let worst = 0;
441
+ // 🔴 A FACT CHECK THAT DID NOT RUN IS NOT A CLEAN FACT CHECK. Until 2026-09-27 the summary looked
442
+ // only at blocking findings, so a run where every checker crashed printed "🟢 no FACT check
443
+ // recorded a blocking finding" and exited 0 — a counter reporting on work it never did. FACT rows
444
+ // are the ones whose verdict can block (`exit`, and `eslint` through severity 2).
445
+ const isFact = (g) => g.read === "exit" || g.read === "eslint";
446
+ const factCrashed = [];
447
+ let factJudged = 0;
440
448
  for (const g of GATES) {
441
449
  const key = `${g.skill}/${g.check}`;
442
450
  const abstain = (reason, note) => {
451
+ if (reason === "crashed" && isFact(g)) factCrashed.push(key);
443
452
  console.log(
444
453
  `\n⬜ ${key} — abstained (${reason}): ${ABSTENTIONS.get(reason)}`,
445
454
  );
@@ -570,6 +579,7 @@ function main(argv) {
570
579
  count = stated ? Number(stated[1]) : lines > 1 ? lines : 0;
571
580
  }
572
581
 
582
+ if (isFact(g)) factJudged++;
573
583
  if (count === 0) {
574
584
  // 🔴 NOT a pass. The check ran and produced no finding, and it has no witness for the
575
585
  // negative — so it abstains and the reader derives cleanliness from the absence, if they
@@ -612,13 +622,27 @@ function main(argv) {
612
622
  if (blocking) worst = 1;
613
623
  }
614
624
 
615
- console.log(
616
- `\n${worst === 0 ? "🟢 no FACT check recorded a blocking finding" : "🔴 a FACT check found something — see above"}`,
617
- );
625
+ // Exit codes, extending the contract above (2 already meant "could not judge this paper at all"):
626
+ // 1 — a FACT check found a blocking defect (wins: it is certain and actionable);
627
+ // 2 — otherwise, a FACT check's checker crashed, so that fact is unknown;
628
+ // 0 — every FACT check that had its input judged it clean, or none had its input.
629
+ const summary = [];
630
+ if (worst) summary.push("🔴 a FACT check found something — see above");
631
+ if (factCrashed.length)
632
+ summary.push(
633
+ `🔴 ${factCrashed.length} FACT check(s) could not run: ${factCrashed.join(", ")} — what they check is UNKNOWN, not clean`,
634
+ );
635
+ if (!summary.length)
636
+ summary.push(
637
+ factJudged
638
+ ? "🟢 no FACT check recorded a blocking finding"
639
+ : "⬜ no FACT check ran — each one lacked its input, so nothing here was judged",
640
+ );
641
+ console.log(`\n${summary.join("\n")}`);
618
642
  console.log(
619
643
  ` Judgement checks report and never fail the run; read them, and never grep this for green.\n`,
620
644
  );
621
- return worst;
645
+ return worst || (factCrashed.length ? 2 : 0);
622
646
  }
623
647
 
624
648
  if (isMain(import.meta.url)) process.exit(main(process.argv));
@@ -0,0 +1,468 @@
1
+ /**
2
+ * run-mechanical.mjs — every row against a fake consumer whose checkers are stand-ins with a
3
+ * scripted answer, so each way of READING a result (`exit`, `flags`, `json`, `eslint`) and each way
4
+ * of NOT getting one (input missing, checker not installed, output that is not what the row reads)
5
+ * is driven on purpose. What is compared is what the run tells a reader (stdout) and what it
6
+ * records (the ledger rows, minus hashes and timestamps).
7
+ *
8
+ * The consumer root and the ledger are set BEFORE import: the module resolves both at load time.
9
+ */
10
+ import assert from "node:assert/strict";
11
+ import {
12
+ chmodSync,
13
+ existsSync,
14
+ readFileSync,
15
+ rmSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { afterEach, test, vi } from "vitest";
21
+ import { runNode, useTempDir, writeTree } from "../../../test/support.mjs";
22
+
23
+ const HERE = dirname(fileURLToPath(import.meta.url));
24
+ const SCRIPT = join(HERE, "run-mechanical.mjs");
25
+ const root = useTempDir("run-mechanical-");
26
+ const consumer = join(root, "consumer");
27
+ const LEDGER = join(root, "runs.jsonl");
28
+
29
+ /** A node stand-in that prints what FAKE_<NAME> says and exits with FAKE_<NAME>_EXIT. */
30
+ const nodeFake = (name) =>
31
+ `const out = process.env.FAKE_${name} ?? "";\n` +
32
+ `process.stdout.write(out);\n` +
33
+ `process.exit(Number(process.env.FAKE_${name}_EXIT ?? 0));\n`;
34
+ /** The same, in python — the citation checkers and the repro scripts are python. */
35
+ const pyFake = (name) =>
36
+ `import os, sys\n` +
37
+ `sys.stdout.write(os.environ.get("FAKE_${name}", ""))\n` +
38
+ `sys.exit(int(os.environ.get("FAKE_${name}_EXIT", "0")))\n`;
39
+
40
+ writeTree(consumer, {
41
+ "paperlint.json": JSON.stringify({ citeChecks: "cite" }),
42
+ "cite/report-submission.py": pyFake("REPORT"),
43
+ "cite/uncited_refs.py": pyFake("UNCITED"),
44
+ "eslint-rules/paper-structure.mjs":
45
+ 'export default { "section-lead": {}, "subsection-size": {} };\n',
46
+ "node_modules/.bin/eslint": `#!${process.execPath}\n${nodeFake("ESLINT")}`,
47
+ // The stand-in hands over to the REAL prose-lint when asked, so the count the ledger records can
48
+ // be checked against what the real checker printed.
49
+ ".claude/skills/grade-paper-writing/prose-lint.mjs":
50
+ `if (process.env.FAKE_PROSE_REAL) await import(${JSON.stringify(
51
+ join(HERE, "..", "..", "grade-paper-writing", "prose-lint.mjs"),
52
+ )});\n` + nodeFake("PROSE"),
53
+ ".claude/skills/verify-citations/scripts/verify-cites.test.mjs":
54
+ nodeFake("CITES"),
55
+ // Paper A: a submission — a venue limit, a build, every repro checker present.
56
+ "papers/a/paper.md": "# A\n\nA plain paragraph.\n",
57
+ "papers/a/.body-limit": "8\n",
58
+ "papers/a/build/paper.log": "log\n",
59
+ "papers/a/build/paper.aux": "aux\n",
60
+ "papers/a/build/acl_latex.pdf": "%PDF\n",
61
+ "papers/a/repro/arm_permutation.py": pyFake("ARM"),
62
+ "papers/a/repro/delivered_pdf.py": pyFake("DELIVERED"),
63
+ "papers/a/repro/textidote_check.py": pyFake("TEXTIDOTE"),
64
+ "papers/a/rounds/.keep": "",
65
+ // Paper B: built in place, no venue, nothing optional.
66
+ "papers/b/paper.md": "# B\n\nAnother paragraph.\n",
67
+ // Paper C: one figure whose caption breaks BOTH caption limits — two prose-lint findings.
68
+ "papers/c/paper.md": "# C\n\nA paragraph of prose.\n",
69
+ "papers/c/figures/f.tex": `\\caption{${"word ".repeat(110)}}`,
70
+ "papers/b/paper.log": "log\n",
71
+ "textidote.jar": "",
72
+ });
73
+ chmodSync(join(consumer, "node_modules/.bin/eslint"), 0o755);
74
+
75
+ process.env.CLAUDE_PROJECT_DIR = consumer;
76
+ process.env.PIPELINE_LEDGER = LEDGER;
77
+ process.env.TEXTIDOTE_JAR = join(consumer, "textidote.jar");
78
+ const { GATES, main } = await import("./run-mechanical.mjs");
79
+
80
+ const ESLINT_FINDINGS = JSON.stringify([
81
+ {
82
+ filePath: "paper.md",
83
+ messages: [
84
+ {
85
+ ruleId: "paper/section-lead",
86
+ severity: 2,
87
+ line: 1,
88
+ column: 1,
89
+ message: "no lead",
90
+ },
91
+ {
92
+ ruleId: "paper/subsection-size",
93
+ severity: 1,
94
+ line: 3,
95
+ column: 1,
96
+ message: "long",
97
+ },
98
+ {
99
+ ruleId: "paper/other-row",
100
+ severity: 2,
101
+ line: 4,
102
+ column: 1,
103
+ message: "not ours",
104
+ },
105
+ ],
106
+ },
107
+ ]);
108
+
109
+ /**
110
+ * Run `main` on a paper with the given stand-in answers. Returns the exit code, the full stdout,
111
+ * the closing summary (the lines after the last blank line — what a reader acts on), and the
112
+ * ledger rows written, one compact string each: `skill/check KIND reason|findings [blocking]`.
113
+ */
114
+ function run(paper, fakes = {}) {
115
+ rmSync(LEDGER, { force: true });
116
+ const saved = { ...process.env };
117
+ Object.assign(process.env, fakes);
118
+ const lines = [];
119
+ const log = vi.spyOn(console, "log").mockImplementation((...a) => {
120
+ lines.push(a.join(" "));
121
+ });
122
+ const err = vi.spyOn(console, "error").mockImplementation((...a) => {
123
+ lines.push(`stderr: ${a.join(" ")}`);
124
+ });
125
+ let code;
126
+ try {
127
+ code = main(["node", SCRIPT, paper]);
128
+ } finally {
129
+ log.mockRestore();
130
+ err.mockRestore();
131
+ process.env = saved;
132
+ }
133
+ const rows = existsSync(LEDGER)
134
+ ? readFileSync(LEDGER, "utf8")
135
+ .trim()
136
+ .split("\n")
137
+ .map((l) => JSON.parse(l))
138
+ .map(
139
+ ({ skill, check, kind, reason, findings, blocking }) =>
140
+ `${skill}/${check} ${kind} ${kind === "FINDING" ? findings : reason}` +
141
+ (blocking ? " blocking" : ""),
142
+ )
143
+ : [];
144
+ const out = lines.join("\n").replaceAll(root, "<root>");
145
+ const summary = out.trimEnd().split("\n\n").pop().split("\n");
146
+ return { code, out, summary, rows };
147
+ }
148
+
149
+ afterEach(() => rmSync(LEDGER, { force: true }));
150
+
151
+ const NOTE =
152
+ " Judgement checks report and never fail the run; read them, and never grep this for green.";
153
+
154
+ test("a directory with no paper.md: exit 2, named", () => {
155
+ const { code, out, rows } = run(join(root, "nowhere"));
156
+ assert.deepEqual(
157
+ { code, out, rows },
158
+ { code: 2, out: "stderr: no paper.md in <root>/nowhere", rows: [] },
159
+ );
160
+ });
161
+
162
+ test("two rows sharing a key are refused before anything runs", () => {
163
+ GATES.push({ ...GATES[0] });
164
+ try {
165
+ const { code, out, rows } = run(join(consumer, "papers", "a"));
166
+ assert.deepEqual(
167
+ { code, out, rows },
168
+ {
169
+ code: 2,
170
+ out: 'stderr: two checks share the row key "render-paper/report-submission" — they would take turns being the answer',
171
+ rows: [],
172
+ },
173
+ );
174
+ } finally {
175
+ GATES.pop();
176
+ }
177
+ });
178
+
179
+ test("paper A, every checker answering with findings: exit 1 on the blocking FACT findings", () => {
180
+ const { code, summary, rows } = run(join(consumer, "papers", "a"), {
181
+ FAKE_REPORT: "overfull box\nunresolved ref\n",
182
+ FAKE_REPORT_EXIT: "1",
183
+ FAKE_ESLINT: ESLINT_FINDINGS,
184
+ FAKE_PROSE: "✍️ prose-lint — paper.md:\n a long caption\n",
185
+ FAKE_PROSE_EXIT: "1",
186
+ FAKE_CITES: "1 fabricated entry\n",
187
+ FAKE_CITES_EXIT: "1",
188
+ FAKE_ARM: JSON.stringify([{ q: 1 }, { q: 2 }]),
189
+ FAKE_DELIVERED: "not json",
190
+ FAKE_TEXTIDOTE: "[]",
191
+ FAKE_UNCITED: JSON.stringify(["smith2020"]),
192
+ });
193
+ assert.deepEqual(
194
+ { code, summary, rows },
195
+ {
196
+ code: 1,
197
+ summary: ["🔴 a FACT check found something — see above", NOTE],
198
+ rows: [
199
+ "render-paper/report-submission FINDING 2 blocking",
200
+ "render-paper/report-submission-citations ABSTAINED input-missing",
201
+ // Two of the three messages are this row's rules; the third belongs to another row.
202
+ "tighten-paper/structure FINDING 2 blocking",
203
+ // No stated count: the stand-in's two lines are counted.
204
+ "grade-paper-writing/prose-lint FINDING 2",
205
+ "verify-citations/verify-cites FINDING 1 blocking",
206
+ "harden-paper/artifact-coverage ABSTAINED no-witness",
207
+ "build-benchmark/check-provenance ABSTAINED no-witness",
208
+ "build-benchmark/arm-permutation FINDING 2",
209
+ "build-benchmark/delivered-pdf ABSTAINED crashed",
210
+ "build-benchmark/generated-code ABSTAINED no-witness",
211
+ "grade-paper-writing/textidote ABSTAINED no-witness",
212
+ "verify-citations/uncited-refs FINDING 1",
213
+ "draft-paper/population-map ABSTAINED no-witness",
214
+ "tighten-paper/round-diff FINDING 1",
215
+ ],
216
+ },
217
+ );
218
+ });
219
+
220
+ test("a finding's lines reach the reader, capped at twelve", () => {
221
+ const { out } = run(join(consumer, "papers", "a"), {
222
+ FAKE_ESLINT: "[]",
223
+ FAKE_UNCITED: JSON.stringify(Array.from({ length: 20 }, (_, i) => `e${i}`)),
224
+ });
225
+ const block = out
226
+ .split("\n\n")
227
+ .find((b) => b.includes("uncited-refs — 20 finding(s)"));
228
+ assert.deepEqual(block.split("\n"), [
229
+ "🟠 verify-citations/uncited-refs — 20 finding(s) → reviews/mechanical/verify-citations--uncited-refs.md",
230
+ " a bibliography entry no \\cite in the paper points at",
231
+ " [",
232
+ ...Array.from({ length: 11 }, (_, i) => ` "e${i}",`),
233
+ ]);
234
+ });
235
+
236
+ test("a FACT check whose checker crashed makes the run exit 2, and the summary says so — not green", () => {
237
+ const { code, summary, rows } = run(join(consumer, "papers", "a"), {
238
+ FAKE_ESLINT: "Oops! Something went wrong",
239
+ FAKE_PROSE: "✍️ prose-lint — paper.md:\n",
240
+ FAKE_UNCITED: "{}",
241
+ });
242
+ assert.deepEqual(
243
+ { code, summary, rows },
244
+ {
245
+ code: 2,
246
+ summary: [
247
+ "🔴 1 FACT check(s) could not run: tighten-paper/structure — what they check is UNKNOWN, not clean",
248
+ NOTE,
249
+ ],
250
+ rows: [
251
+ "render-paper/report-submission ABSTAINED no-witness",
252
+ "render-paper/report-submission-citations ABSTAINED input-missing",
253
+ // eslint printed something that is not JSON.
254
+ "tighten-paper/structure ABSTAINED crashed",
255
+ // A flags header alone is not a finding.
256
+ "grade-paper-writing/prose-lint ABSTAINED no-witness",
257
+ "verify-citations/verify-cites ABSTAINED no-witness",
258
+ "harden-paper/artifact-coverage ABSTAINED no-witness",
259
+ "build-benchmark/check-provenance ABSTAINED no-witness",
260
+ // Empty output where a JSON array is documented.
261
+ "build-benchmark/arm-permutation ABSTAINED crashed",
262
+ "build-benchmark/delivered-pdf ABSTAINED crashed",
263
+ "build-benchmark/generated-code ABSTAINED no-witness",
264
+ "grade-paper-writing/textidote ABSTAINED crashed",
265
+ // A JSON object where an array is documented.
266
+ "verify-citations/uncited-refs ABSTAINED crashed",
267
+ "draft-paper/population-map ABSTAINED no-witness",
268
+ "tighten-paper/round-diff FINDING 1",
269
+ ],
270
+ },
271
+ );
272
+ });
273
+
274
+ // Three full runs of the script: 1.6 s alone, past vitest's 5 s default under c8 with the whole
275
+ // suite in parallel (measured in `npm run check`), so the budget is stated here.
276
+ test(
277
+ "eslint that crashed on the file, and eslint that ignored it, are not clean runs",
278
+ {
279
+ timeout: 30_000,
280
+ },
281
+ () => {
282
+ const fatal = JSON.stringify([
283
+ {
284
+ filePath: "p",
285
+ messages: [{ ruleId: null, fatal: true, message: "Parsing error" }],
286
+ },
287
+ ]);
288
+ const ignored = JSON.stringify([
289
+ {
290
+ filePath: "p",
291
+ messages: [
292
+ {
293
+ ruleId: null,
294
+ message: "File ignored because outside of base path.",
295
+ },
296
+ ],
297
+ },
298
+ ]);
299
+ const structure = (fakes) =>
300
+ run(join(consumer, "papers", "a"), fakes).rows.find((x) =>
301
+ x.startsWith("tighten-paper/structure "),
302
+ );
303
+ assert.deepEqual(
304
+ [
305
+ structure({ FAKE_ESLINT: fatal }),
306
+ structure({ FAKE_ESLINT: ignored }),
307
+ structure({ FAKE_ESLINT: "[]" }),
308
+ ],
309
+ [
310
+ "tighten-paper/structure ABSTAINED crashed",
311
+ "tighten-paper/structure ABSTAINED input-missing",
312
+ "tighten-paper/structure ABSTAINED no-witness",
313
+ ],
314
+ );
315
+ },
316
+ );
317
+
318
+ test("paper B: every row whose input is absent abstains as input-missing; the rest judge", () => {
319
+ const { code, summary, rows } = run(join(consumer, "papers", "b"), {
320
+ FAKE_ESLINT: "[]",
321
+ });
322
+ assert.deepEqual(
323
+ { code, summary, rows },
324
+ {
325
+ code: 0,
326
+ summary: ["🟢 no FACT check recorded a blocking finding", NOTE],
327
+ rows: [
328
+ "render-paper/report-submission ABSTAINED input-missing",
329
+ "render-paper/report-submission-citations ABSTAINED no-witness",
330
+ "tighten-paper/structure ABSTAINED no-witness",
331
+ "grade-paper-writing/prose-lint ABSTAINED no-witness",
332
+ "verify-citations/verify-cites ABSTAINED no-witness",
333
+ "harden-paper/artifact-coverage ABSTAINED no-witness",
334
+ "build-benchmark/check-provenance ABSTAINED no-witness",
335
+ "build-benchmark/arm-permutation ABSTAINED input-missing",
336
+ "build-benchmark/delivered-pdf ABSTAINED input-missing",
337
+ "build-benchmark/generated-code ABSTAINED no-witness",
338
+ "grade-paper-writing/textidote ABSTAINED input-missing",
339
+ "verify-citations/uncited-refs ABSTAINED input-missing",
340
+ "draft-paper/population-map ABSTAINED no-witness",
341
+ "tighten-paper/round-diff ABSTAINED input-missing",
342
+ ],
343
+ },
344
+ );
345
+ });
346
+
347
+ test("no checker installed: every row that ran is `crashed`, never a finding, and the run is not green", () => {
348
+ const { code, summary, rows } = run(join(consumer, "papers", "b"), {
349
+ PATH: join(root, "empty-path"),
350
+ });
351
+ assert.deepEqual(
352
+ { code, summary, rows },
353
+ {
354
+ code: 2,
355
+ summary: [
356
+ "🔴 3 FACT check(s) could not run: render-paper/report-submission-citations, tighten-paper/structure, verify-citations/verify-cites — what they check is UNKNOWN, not clean",
357
+ NOTE,
358
+ ],
359
+ rows: [
360
+ "render-paper/report-submission ABSTAINED input-missing",
361
+ "render-paper/report-submission-citations ABSTAINED crashed",
362
+ "tighten-paper/structure ABSTAINED crashed",
363
+ "grade-paper-writing/prose-lint ABSTAINED crashed",
364
+ "verify-citations/verify-cites ABSTAINED crashed",
365
+ "harden-paper/artifact-coverage ABSTAINED crashed",
366
+ "build-benchmark/check-provenance ABSTAINED crashed",
367
+ "build-benchmark/arm-permutation ABSTAINED input-missing",
368
+ "build-benchmark/delivered-pdf ABSTAINED input-missing",
369
+ "build-benchmark/generated-code ABSTAINED crashed",
370
+ "grade-paper-writing/textidote ABSTAINED input-missing",
371
+ "verify-citations/uncited-refs ABSTAINED input-missing",
372
+ "draft-paper/population-map ABSTAINED crashed",
373
+ "tighten-paper/round-diff ABSTAINED input-missing",
374
+ ],
375
+ },
376
+ );
377
+ });
378
+
379
+ test("as a process, in a consumer that declares nothing: no FACT check can run, and it says so", () => {
380
+ const bare = join(root, "bare");
381
+ writeTree(bare, { "papers/p/paper.md": "# P\n\nText.\n" });
382
+ writeFileSync(join(bare, "package.json"), "{}\n");
383
+ const r = runNode(SCRIPT, [join(bare, "papers", "p")], {
384
+ env: {
385
+ CLAUDE_PROJECT_DIR: bare,
386
+ PIPELINE_LEDGER: join(root, "bare.jsonl"),
387
+ },
388
+ });
389
+ assert.deepEqual(
390
+ {
391
+ status: r.status,
392
+ stderr: r.stderr,
393
+ summary: r.stdout.trimEnd().split("\n\n").pop().split("\n"),
394
+ },
395
+ {
396
+ status: 0,
397
+ stderr: "",
398
+ summary: [
399
+ "⬜ no FACT check ran — each one lacked its input, so nothing here was judged",
400
+ NOTE,
401
+ ],
402
+ },
403
+ );
404
+ });
405
+
406
+ test("the prose-lint row records the number of findings prose-lint found, not its line count", () => {
407
+ const row = run(join(consumer, "papers", "c"), {
408
+ FAKE_PROSE_REAL: "1",
409
+ }).rows.find((x) => x.startsWith("grade-paper-writing/prose-lint "));
410
+ assert.equal(row, "grade-paper-writing/prose-lint FINDING 2");
411
+ });
412
+
413
+ test("no directory argument means the current directory", () => {
414
+ const { code, out, rows } = run(undefined);
415
+ assert.deepEqual(
416
+ { code, out, rows },
417
+ { code: 2, out: `stderr: no paper.md in ${process.cwd()}`, rows: [] },
418
+ );
419
+ });
420
+
421
+ test("a blocking finding with no output still gets a report a reader can open", () => {
422
+ const r = run(join(consumer, "papers", "a"), { FAKE_REPORT_EXIT: "1" });
423
+ const report = readFileSync(
424
+ join(
425
+ consumer,
426
+ "papers",
427
+ "a",
428
+ "reviews",
429
+ "mechanical",
430
+ "render-paper--report-submission.md",
431
+ ),
432
+ "utf8",
433
+ ).replace(/^created: .*$/m, "created: <date>");
434
+ assert.deepEqual(
435
+ { row: r.rows[0], report },
436
+ {
437
+ row: "render-paper/report-submission FINDING 1 blocking",
438
+ report: [
439
+ "---",
440
+ 'title: "render-paper/report-submission — mechanical check output"',
441
+ "created: <date>",
442
+ "findings: 1",
443
+ "---",
444
+ "",
445
+ "Written by `run-mechanical.mjs`. What this check looks at: body pages, overfull boxes, unresolved refs, dropped characters, anonymity, artifact URLs",
446
+ "",
447
+ "```",
448
+ "(the check produced no output)",
449
+ "```",
450
+ "",
451
+ ].join("\n"),
452
+ },
453
+ );
454
+ });
455
+
456
+ test("an empty $TEXTIDOTE_JAR is no declaration: the row looks at /opt/textidote, like the checker", () => {
457
+ // The answer depends on this machine (some images ship the jar in /opt), so the expectation is
458
+ // derived from the same fact the row reads rather than hard-coded.
459
+ const atOpt = existsSync("/opt/textidote/textidote.jar");
460
+ const row = run(join(consumer, "papers", "a"), {
461
+ TEXTIDOTE_JAR: "",
462
+ FAKE_TEXTIDOTE: "[]",
463
+ }).rows.find((x) => x.startsWith("grade-paper-writing/textidote "));
464
+ assert.equal(
465
+ row,
466
+ `grade-paper-writing/textidote ABSTAINED ${atOpt ? "no-witness" : "input-missing"}`,
467
+ );
468
+ });
@@ -120,12 +120,12 @@ function produced(r) {
120
120
  return "no row";
121
121
  }
122
122
 
123
- function main(argv) {
123
+ export function main(argv) {
124
124
  const dir = resolve(argv[2] || ".");
125
125
  const rows = status(dir, { gates: EXPECTED_GATES });
126
126
 
127
127
  console.log(`\n ${basename(dir)}\n`);
128
- const pad = (s, n) => String(s ?? "").padEnd(n);
128
+ const pad = (s, n) => String(s).padEnd(n); // every cell is a string or a count
129
129
  console.log(
130
130
  ` ${pad("check", 36)}${pad("freshness", 14)}${pad("last run produced", 22)}${pad("runs", 6)}when`,
131
131
  );