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
@@ -0,0 +1,232 @@
1
+ /**
2
+ * status.mjs — the paper's status view, derived from the ledger. Every section it can print is
3
+ * driven by rows written for the purpose: fresh and stale answers, open findings with and without
4
+ * a report path, retired-vocabulary rows (an acquittal and a counted one), an orphan check, a check
5
+ * that has run and never found anything, and the all-answered footer.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { rmSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { afterEach, test, vi } from "vitest";
12
+ import { runNode, useTempDir, writeTree } from "../../../test/support.mjs";
13
+
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+ const root = useTempDir("status-");
16
+ const consumer = join(root, "consumer");
17
+ const LEDGER = join(root, "runs.jsonl");
18
+ const paper = join(consumer, "papers", "p");
19
+ writeTree(paper, { "paper.md": "# P\n\ntext\n" });
20
+ Object.assign(process.env, {
21
+ CLAUDE_PROJECT_DIR: consumer,
22
+ PIPELINE_LEDGER: LEDGER,
23
+ });
24
+ const { inputHash } = await import("./ledger.mjs");
25
+ const { EXPECTED_GATES, main, unlistedGates } = await import("./status.mjs");
26
+ afterEach(() => rmSync(LEDGER, { force: true }));
27
+
28
+ const NOW = inputHash(paper);
29
+ const TS = "2026-09-01T10:30:00.000Z";
30
+ /** A ledger row for `key` on paper p. No skill is installed here, so its current hash is null. */
31
+ const row = (key, fields) => {
32
+ const [skill, check] = key.split("/");
33
+ return {
34
+ ts: TS,
35
+ skill,
36
+ check: check ?? skill,
37
+ paper: "p",
38
+ inputSha: NOW,
39
+ skillSha: null,
40
+ ...fields,
41
+ };
42
+ };
43
+ const NO_WITNESS = { kind: "ABSTAINED", reason: "no-witness", findings: 0 };
44
+
45
+ function view(rows) {
46
+ writeFileSync(LEDGER, rows.map((r) => JSON.stringify(r)).join("\n") + "\n");
47
+ const lines = [];
48
+ const spy = vi
49
+ .spyOn(console, "log")
50
+ .mockImplementation((...a) => lines.push(a.join(" ")));
51
+ let code;
52
+ try {
53
+ code = main(["node", "status.mjs", paper]);
54
+ } finally {
55
+ spy.mockRestore();
56
+ }
57
+ return { code, text: lines.join("\n") };
58
+ }
59
+ /** The table line for one check, with its padding collapsed. */
60
+ const tableLine = (text, key) =>
61
+ text
62
+ .split("\n")
63
+ .find((l) => l.includes(` ${key} `))
64
+ ?.trim()
65
+ .replace(/\s{2,}/g, " | ");
66
+ /** A section: from the line that starts with `head` to the next blank line. */
67
+ const section = (text, head) => {
68
+ const lines = text.split("\n");
69
+ const i = lines.findIndex((l) => l.trim().startsWith(head));
70
+ if (i < 0) return null;
71
+ const end = lines.findIndex((l, j) => j > i && l.trim() === "");
72
+ return lines.slice(i, end < 0 ? undefined : end).map((l) => l.trim());
73
+ };
74
+
75
+ const RAN = [
76
+ "research-ideate",
77
+ "find-venue",
78
+ "map-prior-work",
79
+ "sweep-design-space",
80
+ "plan-paper-timeline",
81
+ "build-benchmark",
82
+ "draft-paper",
83
+ ];
84
+
85
+ test("every kind of row, and every section it triggers", () => {
86
+ const { code, text } = view([
87
+ row("research-ideate", NO_WITNESS),
88
+ row("find-venue", {
89
+ kind: "FINDING",
90
+ findings: 3,
91
+ blocking: true,
92
+ report: "r.md",
93
+ }),
94
+ row("map-prior-work", { kind: "FINDING", findings: 2, inputSha: "older" }),
95
+ row("sweep-design-space", { ...NO_WITNESS, skillSha: "a-different-skill" }),
96
+ row("plan-paper-timeline", { verdict: "PASS" }),
97
+ row("build-benchmark", { verdict: "FINDINGS", findings: 2 }),
98
+ row("draft-paper", { kind: "FINDING", findings: 0 }),
99
+ row("mystery-check", NO_WITNESS),
100
+ row("paper-status", NO_WITNESS),
101
+ ]);
102
+ assert.deepEqual(
103
+ {
104
+ code,
105
+ rows: [
106
+ "research-ideate",
107
+ "find-venue",
108
+ "map-prior-work",
109
+ "sweep-design-space",
110
+ "plan-paper-timeline",
111
+ "build-benchmark",
112
+ "draft-paper",
113
+ "camera-ready",
114
+ ].map((k) => tableLine(text, k)),
115
+ stale: text
116
+ .split("\n")
117
+ .filter((l) => /^\s+(🟠|🟡) [\w/-]+: /.test(l))
118
+ .map((l) => l.trim()),
119
+ never: section(text, "⬜ never run:"),
120
+ mute: section(text, "🔇"),
121
+ legacy: section(text, "🏷️"),
122
+ orphans: section(text, "❗"),
123
+ open: section(text, "📌"),
124
+ quiet: section(text, "🤍"),
125
+ footer: text.trim().split("\n").pop().trim(),
126
+ },
127
+ {
128
+ code: 0,
129
+ rows: [
130
+ "🟢 research-ideate | FRESH | abstained:no-witness | 1 | 2026-09-01 10:30",
131
+ "🟢 find-venue | FRESH | 🔴 3 found | 1 | 2026-09-01 10:30",
132
+ "🟠 map-prior-work | STALE-PAPER | • 2 found | 1 | 2026-09-01 10:30",
133
+ "🟡 sweep-design-space | STALE-SKILL | abstained:no-witness | 1 | 2026-09-01 10:30",
134
+ "🟢 plan-paper-timeline | FRESH | legacy:PASS | 1 | 2026-09-01 10:30",
135
+ "🟢 build-benchmark | FRESH | • 2 found | 1 | 2026-09-01 10:30",
136
+ "🟢 draft-paper | FRESH | no row | 1 | 2026-09-01 10:30",
137
+ "⬜ camera-ready | NEVER-RUN | — | 0 | —",
138
+ ],
139
+ stale: [
140
+ // In EXPECTED_GATES order.
141
+ "🟡 sweep-design-space: the check itself changed since it ran — that answer came from a different tool",
142
+ "🟠 map-prior-work: the paper changed since it ran — its answer is about a document that is gone",
143
+ ],
144
+ never: [
145
+ `⬜ never run: ${EXPECTED_GATES.filter((k) => !RAN.includes(k)).join(", ")}`,
146
+ "nobody has asked this question about this paper — an unasked question reads exactly like a clean one in prose.",
147
+ ],
148
+ mute: [
149
+ "🔇 has run and has NEVER recorded a finding: research-ideate, sweep-design-space, plan-paper-timeline",
150
+ "Borrowed from mutation testing: a test that kills no mutant is not a test. This is",
151
+ "not proof the check is broken — it is the reason to go plant a defect and watch.",
152
+ "Precedent here: a tightening pass returned KEEP on 80 of 81 sections and a",
153
+ 'justification pass never once recommended a deletion. Both "ran".',
154
+ ],
155
+ legacy: [
156
+ "🏷️ answered in the RETIRED vocabulary: plan-paper-timeline=PASS, build-benchmark=FINDINGS",
157
+ "Rows written before 2026-08-10 are kept verbatim and are NOT translated into the",
158
+ "current constructors — a silent reinterpretation is the class of defect this",
159
+ "pipeline removed. A retired FINDINGS/FAIL row still shows its count above, because",
160
+ "it carries evidence and discarding evidence is the destructive direction.",
161
+ "plan-paper-timeline=PASS carry NOTHING forward:",
162
+ "they recorded that nothing was wrong, with no evidence attached, which is exactly",
163
+ "the value that was deleted. Re-run those checks for an answer in a live vocabulary.",
164
+ ],
165
+ // `paper-status` records too, but is the view itself, so it is never an orphan.
166
+ orphans: [
167
+ "❗ recording but NOT IN THE TABLE: mystery-check",
168
+ "These ran and said something, and this view showed you nothing. Add them to",
169
+ "EXPECTED_GATES — an invisible row is the failure this directory exists to end.",
170
+ ],
171
+ open: [
172
+ "📌 open findings: 7 across 3 check(s)",
173
+ "• map-prior-work: 2 (no report path recorded — the count is all there is) [STALE-PAPER: this count describes an older text]",
174
+ "🔴 find-venue: 3 → r.md",
175
+ "• build-benchmark: 2 (no report path recorded — the count is all there is)",
176
+ "Nothing here proves anyone acted on these. The ledger records what a check SAID,",
177
+ "never what was done about it — that lives in the session, not in a file.",
178
+ ],
179
+ quiet: [
180
+ "🤍 ran against the current bytes and recorded nothing: 1 check(s)",
181
+ "research-ideate (no-witness)",
182
+ "Read this as the ABSENCE of a finding, which is what it is. `no-witness` means the",
183
+ "check ran and has no witness for the negative answer — unlike a certifying",
184
+ "algorithm (McConnell et al. 2011), which ships one for both answers. Our discipline",
185
+ "is strictly weaker than theirs and this line is where that shows.",
186
+ "no-witness: ran to completion, produced no finding — NOT a claim that nothing is wrong",
187
+ ],
188
+ footer: "🔴 29 check(s) not answering for the current text",
189
+ },
190
+ );
191
+ });
192
+
193
+ test("every expected check answered on the current bytes: the green footer, and nothing to flag", () => {
194
+ const { code, text } = view(
195
+ EXPECTED_GATES.map((k) =>
196
+ row(k, { kind: "FINDING", findings: 1, report: "r.md" }),
197
+ ),
198
+ );
199
+ assert.deepEqual(
200
+ {
201
+ code,
202
+ sections: ["🟠", "🟡", "⬜ never", "🔇", "🏷️", "❗", "🤍"].map((h) =>
203
+ section(text, h),
204
+ ),
205
+ footer: text.trim().split("\n").pop().trim(),
206
+ },
207
+ {
208
+ code: 0,
209
+ sections: [null, null, null, null, null, null, null],
210
+ footer: "🟢 every expected check answered for the current text",
211
+ },
212
+ );
213
+ });
214
+
215
+ test("unlistedGates names checks the table does not list, except the view's own rows", () => {
216
+ assert.deepEqual(
217
+ unlistedGates([
218
+ { skill: "find-venue", check: "find-venue" },
219
+ { skill: "x", check: "y" },
220
+ { skill: "paper-status", check: "paper-status" },
221
+ ]),
222
+ ["x/y"],
223
+ );
224
+ });
225
+
226
+ test("as a process, with no argument, it reads the current directory and exits 0", () => {
227
+ const r = runNode(join(HERE, "status.mjs"), [], {
228
+ cwd: paper,
229
+ env: { CLAUDE_PROJECT_DIR: consumer, PIPELINE_LEDGER: LEDGER },
230
+ });
231
+ assert.deepEqual([r.status, r.stdout.split("\n")[1]], [0, " p"]);
232
+ });
@@ -343,8 +343,8 @@ try {
343
343
  // matters more than it looks. The first version of this guard keyed on `evt_fake_`,
344
344
  // a string the stand-in prints only on SUCCESS — so a perfectly correct schema
345
345
  // REJECTION arrived with no marker and was reported as "something else served this
346
- // tool". The guard meant to protect the verdict was hiding it, and it took a
347
- // mutation run to notice (2026-08-17). The log is written on both paths, so a
346
+ // tool". The guard meant to protect the verdict was hiding it, and that went
347
+ // unnoticed until 2026-08-17. The log is written on both paths, so a
348
348
  // non-empty log means our server handled the call, whatever it decided.
349
349
  const logged = existsSync(callLog)
350
350
  ? readFileSync(callLog, "utf8")
@@ -10,7 +10,6 @@
10
10
  *
11
11
  * The shim imports the compiled package (`dist/`), so `npm run build` runs before this.
12
12
  */
13
- import assert from "node:assert/strict";
14
13
  import { spawnSync } from "node:child_process";
15
14
  import {
16
15
  cpSync,
@@ -25,16 +24,13 @@ import {
25
24
  import { tmpdir } from "node:os";
26
25
  import { dirname, join, resolve } from "node:path";
27
26
  import { fileURLToPath } from "node:url";
27
+ import { createChecker } from "../../lib/check.mjs";
28
28
 
29
29
  const HERE = dirname(fileURLToPath(import.meta.url));
30
30
  const SHIM = join(HERE, "extract-pdf-facts.mjs");
31
31
  const FIX = resolve(HERE, "..", "..", "fixtures", "pdf-facts");
32
32
 
33
- let n = 0;
34
- const check = (label, cond, detail = "") => {
35
- assert.ok(cond, detail ? `${label} — ${detail}` : label);
36
- n++;
37
- };
33
+ const check = createChecker();
38
34
 
39
35
  const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-extract-")));
40
36
  /**
@@ -49,6 +45,16 @@ const shim = (args, env = {}) =>
49
45
  PATH: process.env.PATH,
50
46
  HOME: root,
51
47
  CLAUDE_PROJECT_DIR: root,
48
+ // Forwarded, not chosen: under `npm run coverage` these carry the coverage directory and the
49
+ // preload that resolves dist/ to src/. Dropping them measured the shim's dist/ imports as a
50
+ // second copy of each module (the function map remapped through source maps, with shifted
51
+ // columns), so every function it did not call counted as uncovered in src/.
52
+ ...(process.env.NODE_OPTIONS
53
+ ? { NODE_OPTIONS: process.env.NODE_OPTIONS }
54
+ : {}),
55
+ ...(process.env.NODE_V8_COVERAGE
56
+ ? { NODE_V8_COVERAGE: process.env.NODE_V8_COVERAGE }
57
+ : {}),
52
58
  ...env,
53
59
  },
54
60
  });
@@ -157,10 +163,53 @@ try {
157
163
  missing.status === 3 && /not built/.test(said(missing)),
158
164
  said(missing),
159
165
  );
166
+
167
+ // ── a PDF named directly, in a directory with no paperlint.json, no project dir set ──
168
+ const loose = join(root, "papers", "loose");
169
+ mkdirSync(loose, { recursive: true });
170
+ cpSync(join(FIX, "t3-mixed.pdf"), join(loose, "draft.pdf"));
171
+ const direct = shim([join(loose, "draft.pdf")], { CLAUDE_PROJECT_DIR: "" });
172
+ const looseFacts = join(loose, "_build", "paper.facts.json");
173
+ check(
174
+ "a PDF path: facts go to its directory's _build/, the path printed relative to the cwd",
175
+ direct.status === 0 &&
176
+ direct.stdout.startsWith(
177
+ "✅ draft.pdf → papers/loose/_build/paper.facts.json (",
178
+ ) &&
179
+ JSON.parse(readFileSync(looseFacts, "utf8")).venue === null,
180
+ said(direct),
181
+ );
182
+ // The project directory IS the facts file (nonsense, but an environment can say it): the
183
+ // relative path would be empty, so the absolute one is printed instead of nothing.
184
+ const same = shim([join(loose, "draft.pdf")], {
185
+ CLAUDE_PROJECT_DIR: looseFacts,
186
+ });
187
+ check(
188
+ "a project dir equal to the facts file prints the absolute path, never an empty one",
189
+ same.stdout.startsWith(`✅ draft.pdf → ${looseFacts} (`),
190
+ said(same),
191
+ );
192
+
193
+ // ── a file that is not a PDF: refused, strict or not, in the words for the mode ──
194
+ writeFileSync(join(loose, "broken.pdf"), "not a pdf at all\n");
195
+ const brokenStrict = shim([join(loose, "broken.pdf"), "--strict"]);
196
+ const brokenLocal = shim([join(loose, "broken.pdf")]);
197
+ check(
198
+ "an unreadable PDF: --strict exits 1 as an environment error; locally exit 0, saying THE PDF IS NOT CHECKED",
199
+ brokenStrict.status === 1 &&
200
+ /🛑 facts not taken: .* — in CI this is an environment error/.test(
201
+ brokenStrict.stderr,
202
+ ) &&
203
+ brokenLocal.status === 0 &&
204
+ /⏭️ {2}facts not taken: .*THE PDF IS NOT CHECKED/.test(
205
+ brokenLocal.stderr,
206
+ ),
207
+ `${said(brokenStrict)}\n---\n${said(brokenLocal)}`,
208
+ );
160
209
  } finally {
161
210
  rmSync(root, { recursive: true, force: true });
162
211
  }
163
212
 
164
213
  console.log(
165
- `✓ ${String(n)} assertions passed — extract-pdf-facts: the exit-code contract, schema 2, and the no-no-yes font`,
214
+ `✓ ${String(check.count)} assertions passed — extract-pdf-facts: the exit-code contract, schema 2, and the no-no-yes font`,
166
215
  );
@@ -0,0 +1,133 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`--section=1 keeps section 1 and its sub-sections, and nothing before it 1`] = `
4
+ "
5
+ === full.md — outline, in order ===
6
+ own total share section
7
+ 10 16 53.3% 1 Introduction
8
+ ↳ [7/10 KEEP] carries: the claim
9
+ 4 1.1 Detail
10
+ ↳ carries: a sub-point
11
+ 2 1.2 Bare sub
12
+ ↳ 🔴 no carries: note — nobody has said why this section is here
13
+
14
+ body total 30 words across 4 sections
15
+
16
+ === outside the page limit ===
17
+ 40 Limitations
18
+ ↳ 🔴 no carries: note — and no page limit forcing the question
19
+ 5 Scope
20
+ ↳ carries: why the scope note
21
+ 30 Appendix A
22
+ ↳ carries: the raw tables
23
+
24
+ free total 75 words = 250% of the body
25
+ 🔴 The unbudgeted half is LARGER than the paper. Nothing prices these sections, which
26
+ is exactly why prose settles here. Ask of each: would a reviewer miss it?
27
+
28
+ The rest is judgement and stays with the skill: is this the ORDER a stranger needs, does each
29
+ heading name a thing, and does each section earn its place. Read the headings as a set — a
30
+ stranger should be able to reconstruct the argument from them alone.
31
+ "
32
+ `;
33
+
34
+ exports[`the inventory of body-only.md 1`] = `
35
+ "
36
+ === body-only.md — outline, in order ===
37
+ own total share section
38
+ 8 8 66.7% 1 Intro
39
+ ↳ 🔴 no carries: note — nobody has said why this section is here
40
+ 4 4 33.3% 2 Rest
41
+ ↳ 🔴 no carries: note — nobody has said why this section is here
42
+ 0 0 0.0% 3 Empty
43
+ ↳ 🔴 no carries: note — nobody has said why this section is here
44
+
45
+ body total 12 words across 3 sections
46
+
47
+ The rest is judgement and stays with the skill: is this the ORDER a stranger needs, does each
48
+ heading name a thing, and does each section earn its place. Read the headings as a set — a
49
+ stranger should be able to reconstruct the argument from them alone.
50
+ "
51
+ `;
52
+
53
+ exports[`the inventory of full.md 1`] = `
54
+ "
55
+ === full.md — outline, in order ===
56
+ own total share section
57
+ 3 Orphan sub
58
+ 10 16 53.3% 1 Introduction
59
+ ↳ [7/10 KEEP] carries: the claim
60
+ 4 1.1 Detail
61
+ ↳ carries: a sub-point
62
+ 2 1.2 Bare sub
63
+ ↳ 🔴 no carries: note — nobody has said why this section is here
64
+ 6 6 20.0% 2 Method
65
+ ↳ 🔴 no carries: note — nobody has said why this section is here
66
+ 5 5 16.7% 3 Results
67
+ ↳ [?/10 CUT] carries: only a verdict
68
+ 3 3 10.0% Discussion
69
+ ↳ [4/10 NO VERDICT] carries: score only
70
+
71
+ body total 30 words across 4 sections
72
+
73
+ === outside the page limit ===
74
+ 40 Limitations
75
+ ↳ 🔴 no carries: note — and no page limit forcing the question
76
+ 5 Scope
77
+ ↳ carries: why the scope note
78
+ 30 Appendix A
79
+ ↳ carries: the raw tables
80
+
81
+ free total 75 words = 250% of the body
82
+ 🔴 The unbudgeted half is LARGER than the paper. Nothing prices these sections, which
83
+ is exactly why prose settles here. Ask of each: would a reviewer miss it?
84
+
85
+ The rest is judgement and stays with the skill: is this the ORDER a stranger needs, does each
86
+ heading name a thing, and does each section earn its place. Read the headings as a set — a
87
+ stranger should be able to reconstruct the argument from them alone.
88
+ "
89
+ `;
90
+
91
+ exports[`the inventory of no-appendix.md 1`] = `
92
+ "
93
+ === no-appendix.md — outline, in order ===
94
+ own total share section
95
+ 8 8 100.0% 1 Intro
96
+ ↳ 🔴 no carries: note — nobody has said why this section is here
97
+
98
+ body total 8 words across 1 sections
99
+
100
+ === outside the page limit ===
101
+ 3 Limitations
102
+ ↳ 🔴 no carries: note — and no page limit forcing the question
103
+
104
+ free total 3 words = 38% of the body
105
+
106
+ The rest is judgement and stays with the skill: is this the ORDER a stranger needs, does each
107
+ heading name a thing, and does each section earn its place. Read the headings as a set — a
108
+ stranger should be able to reconstruct the argument from them alone.
109
+ "
110
+ `;
111
+
112
+ exports[`the inventory of refs-first.md 1`] = `
113
+ "
114
+ === refs-first.md — outline, in order ===
115
+ own total share section
116
+ 8 8 100.0% 1 Intro
117
+ ↳ 🔴 no carries: note — nobody has said why this section is here
118
+
119
+ body total 8 words across 1 sections
120
+
121
+ === outside the page limit ===
122
+ 2 References
123
+ ↳ 🔴 no carries: note — and no page limit forcing the question
124
+ 2 Ethical note
125
+ ↳ 🔴 no carries: note — and no page limit forcing the question
126
+
127
+ free total 4 words = 50% of the body
128
+
129
+ The rest is judgement and stays with the skill: is this the ORDER a stranger needs, does each
130
+ heading name a thing, and does each section earn its place. Read the headings as a set — a
131
+ stranger should be able to reconstruct the argument from them alone.
132
+ "
133
+ `;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * structure.mjs — the section inventory the tighten-paper skill prints for a human to read. No
3
+ * threshold, no finding: the table IS the product, so the whole printed table is compared.
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { expect, test } from "vitest";
9
+ import { runNode, useTempDir, writeTree } from "../../test/support.mjs";
10
+
11
+ const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "structure.mjs");
12
+ const root = useTempDir("structure-");
13
+ const words = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(" ");
14
+
15
+ writeTree(root, {
16
+ // Every row shape: a sub-section before any section, carries notes with a score and a verdict,
17
+ // with only one of them, with neither, a numbered section with no note, and a free half — after
18
+ // Limitations, between References and the Appendix — larger than the body.
19
+ "full.md": `---
20
+ title: x
21
+ ---
22
+ Preamble before any heading.
23
+
24
+ ### Orphan sub
25
+ ${words(3)}
26
+
27
+ <!-- carries: the claim · score: 7/10 · verdict: keep -->
28
+ ## 1 Introduction
29
+ ${words(10)}
30
+
31
+ <!-- carries: a sub-point -->
32
+ ### 1.1 Detail
33
+ ${words(4)}
34
+
35
+ ### 1.2 Bare sub
36
+ ${words(2)}
37
+
38
+ ## 2 Method
39
+ ${words(6)}
40
+
41
+ <!-- carries: only a verdict · verdict: CUT -->
42
+ ## 3 Results
43
+ ${words(5)}
44
+
45
+ <!-- carries: score only · score: 4 -->
46
+ ## Discussion
47
+ ${words(3)}
48
+
49
+ ## Limitations
50
+ ${words(40)}
51
+
52
+ <!-- carries: why the scope note -->
53
+ ### Scope
54
+ ${words(5)}
55
+
56
+ ## References
57
+ [1] Not counted.
58
+
59
+ <!-- carries: the raw tables -->
60
+ ## Appendix A
61
+ ${words(30)}
62
+ `,
63
+ // References come first, so the free half runs from it to the end; no appendix.
64
+ "refs-first.md": `## 1 Intro\n${words(8)}\n\n## References\n[1] x\n\n## Ethical note\n${words(2)}\n`,
65
+ // Limitations then References and no appendix: the free half stops at References.
66
+ "no-appendix.md": `## 1 Intro\n${words(8)}\n\n## Limitations\n${words(3)}\n\n## References\n[1] x\n`,
67
+ // No free half at all.
68
+ // ...and a section with no words at all.
69
+ "body-only.md": `## 1 Intro\n${words(8)}\n\n## 2 Rest\n${words(4)}\n\n## 3 Empty\n`,
70
+ });
71
+
72
+ const run = (...args) => {
73
+ const r = runNode(
74
+ SCRIPT,
75
+ args.map((a) => (a.startsWith("--") ? a : join(root, a))),
76
+ );
77
+ return { ...r, stdout: r.stdout.replaceAll(root, "<root>") };
78
+ };
79
+
80
+ test("no file: the usage line, exit 0", () => {
81
+ assert.deepEqual(run(), {
82
+ status: 0,
83
+ stdout: "",
84
+ stderr:
85
+ "usage: node structure.mjs <paper.md> [--section=N] (--flags-only is accepted and stays silent)\n",
86
+ });
87
+ });
88
+
89
+ test("--flags-only stays silent and exits 0 — every check moved to ESLint", () => {
90
+ assert.deepEqual(run("--flags-only", "full.md"), {
91
+ status: 0,
92
+ stdout: "",
93
+ stderr: "",
94
+ });
95
+ });
96
+
97
+ test.each(["full.md", "refs-first.md", "no-appendix.md", "body-only.md"])(
98
+ "the inventory of %s",
99
+ (file) => {
100
+ const r = run(file);
101
+ assert.deepEqual(
102
+ { status: r.status, stderr: r.stderr },
103
+ { status: 0, stderr: "" },
104
+ );
105
+ expect(r.stdout).toMatchSnapshot();
106
+ },
107
+ );
108
+
109
+ test("--section=1 keeps section 1 and its sub-sections, and nothing before it", () => {
110
+ const r = run("full.md", "--section=1");
111
+ assert.deepEqual(
112
+ { status: r.status, stderr: r.stderr },
113
+ { status: 0, stderr: "" },
114
+ );
115
+ expect(r.stdout).toMatchSnapshot();
116
+ });
@@ -4,7 +4,7 @@ description: Verify every citation is a real work with correct metadata, and tha
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, WebSearch, WebFetch, Agent]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:2bb9f6f1233094cf compiled from skills/verify-citations/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:9973bd8fef7e0927 compiled from skills/verify-citations/SKILL.md.spec.ts -->
8
8
 
9
9
  # verify-citations — every cite real, the delta explicit
10
10
 
@@ -259,7 +259,7 @@ the same one MedSci Skills (arXiv:2606.09500) closes with `verify_refs.py`.
259
259
  | year | ±1 on a title search | exact, and only against the record that dates the cited version |
260
260
  | preprint→published | not asked | arXiv `journal_ref`/`doi` present and no venue named → say so |
261
261
  | what it could not check | folded into `unresolvable` | **enumerated, one rule per reason, never counted as verified** |
262
- | tests | `verify-cites.test.mjs` — not a `*.harness.mjs`, so `npx vigiles test` never runs it | `eslint-rules/ref-facts.harness.mjs` + `.claude/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs`, plus `eslint-rules/ref-facts.mutations.mjs` |
262
+ | tests | `verify-cites.test.mjs` — not a `*.harness.mjs`, so `npx vigiles test` never runs it | `src/references.test.ts` (the `refs/*` rules in `src/reference-rules.ts`) + `skills/paper-pipeline/scripts/extract-ref-facts.test.mjs` and `.harness.mjs`, under the package's 100% line and branch coverage gate |
263
263
 
264
264
  **The uncovered set is the point.** A reference with no resolvable identifier is a FINDING here
265
265
  (`refs/no-identifier`), and every partial comparison is another (`refs/partial-check`, one messageId per
@@ -274,9 +274,9 @@ and its `.bib` regex required a closing `}` on its own line, which neither real
274
274
  references`). The `.bib` half now goes through `@retorquere/bibtex-parser`. Two of the three papers are
275
275
  therefore being checked for the first time.
276
276
 
277
- **Every check is watched failing.** `node eslint-rules/ref-facts.mutations.mjs` neuters each rule in turn
278
- and requires the harness to go red at the named case — 21 mutations, all killed, and three of them were
279
- findings about the TEST rather than the rule. Run it after any edit to the rules.
277
+ **Every check is exercised.** `src/references.test.ts` drives the `refs/*` rules, and `npm run coverage`
278
+ fails CI if any line or branch of them goes unexercised. Run `npx vitest run src/references.test.ts`
279
+ after any edit to the rules.
280
280
 
281
281
  ## 6. Record the verdict
282
282
 
@@ -275,7 +275,7 @@ the same one MedSci Skills (arXiv:2606.09500) closes with \`verify_refs.py\`.
275
275
  | year | ±1 on a title search | exact, and only against the record that dates the cited version |
276
276
  | preprint→published | not asked | arXiv \`journal_ref\`/\`doi\` present and no venue named → say so |
277
277
  | what it could not check | folded into \`unresolvable\` | **enumerated, one rule per reason, never counted as verified** |
278
- | tests | \`verify-cites.test.mjs\` — not a \`*.harness.mjs\`, so \`npx vigiles test\` never runs it | \`eslint-rules/ref-facts.harness.mjs\` + \`.claude/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs\`, plus \`eslint-rules/ref-facts.mutations.mjs\` |
278
+ | tests | \`verify-cites.test.mjs\` — not a \`*.harness.mjs\`, so \`npx vigiles test\` never runs it | \`src/references.test.ts\` (the \`refs/*\` rules in \`src/reference-rules.ts\`) + \`skills/paper-pipeline/scripts/extract-ref-facts.test.mjs\` and \`.harness.mjs\`, under the package's 100% line and branch coverage gate |
279
279
 
280
280
  **The uncovered set is the point.** A reference with no resolvable identifier is a FINDING here
281
281
  (\`refs/no-identifier\`), and every partial comparison is another (\`refs/partial-check\`, one messageId per
@@ -290,9 +290,9 @@ and its \`.bib\` regex required a closing \`}\` on its own line, which neither r
290
290
  references\`). The \`.bib\` half now goes through \`@retorquere/bibtex-parser\`. Two of the three papers are
291
291
  therefore being checked for the first time.
292
292
 
293
- **Every check is watched failing.** \`node eslint-rules/ref-facts.mutations.mjs\` neuters each rule in turn
294
- and requires the harness to go red at the named case — 21 mutations, all killed, and three of them were
295
- findings about the TEST rather than the rule. Run it after any edit to the rules.
293
+ **Every check is exercised.** \`src/references.test.ts\` drives the \`refs/*\` rules, and \`npm run coverage\`
294
+ fails CI if any line or branch of them goes unexercised. Run \`npx vitest run src/references.test.ts\`
295
+ after any edit to the rules.
296
296
 
297
297
  ## 6. Record the verdict
298
298