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
@@ -124,11 +124,11 @@ export const UNEXPRESSED = [
124
124
  ],
125
125
  [
126
126
  "draft ← study",
127
- 'draft-paper/SKILL.md: "**FAIL** — it declined to draft because a required input was missing: no numbers from build-benchmark"',
127
+ 'draft-paper/SKILL.md: "**FINDING** — it declined to draft because a required input was missing: no numbers from `build-benchmark`"',
128
128
  ],
129
129
  [
130
130
  "priorwork ← idea",
131
- 'paper-pipeline/SKILL.md SETUP: map-prior-work runs "right after `research-ideate` says go"',
131
+ 'map-prior-work/SKILL.md, the `priorwork` row: "SETUP, right after `research-ideate` says go"',
132
132
  ],
133
133
  ];
134
134
 
@@ -0,0 +1,73 @@
1
+ /**
2
+ * pipeline-edges.mjs admits each edge on a SENTENCE from a skill's own SKILL.md, quoted in the
3
+ * table so the judgement stays auditable. A quote is only evidence while the skill still says it,
4
+ * so every quote is looked up in the SKILL.md it names. A skill reworded since — or a quote that
5
+ * was never verbatim — fails here, naming the edge.
6
+ *
7
+ * Matching tolerates what quoting legitimately changes: runs of whitespace, `**` emphasis, and an
8
+ * elision `…`, which splits a quote into fragments that must each appear. The values are prose,
9
+ * so reading them with a pattern is reading prose, not parsing a format.
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { test } from "vitest";
16
+ import { CANONICAL_INPUTS, NOT_EDGES, UNEXPRESSED } from "./pipeline-edges.mjs";
17
+
18
+ const SKILLS = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
19
+ const norm = (s) => s.replace(/\*\*/g, "").replace(/\s+/g, " ");
20
+
21
+ /** [edge, skill, quote] for every `<skill>/SKILL.md…: "<quote>"` in the three tables. */
22
+ function quotes() {
23
+ const cells = [
24
+ ...Object.entries(CANONICAL_INPUTS).flatMap(([row, { requires }]) =>
25
+ Object.entries(requires).map(([input, why]) => [
26
+ `${row} ← ${input}`,
27
+ why,
28
+ ]),
29
+ ),
30
+ ...UNEXPRESSED,
31
+ ...NOT_EDGES,
32
+ ];
33
+ return cells.flatMap(([edge, why]) =>
34
+ quotesIn(why).map(([skill, quote]) => [edge, skill, quote]),
35
+ );
36
+ }
37
+
38
+ /** [skill, quote] for every `<skill>/SKILL.md…: "<quote>"` in one cell. */
39
+ function quotesIn(why) {
40
+ return [...why.matchAll(/([a-z-]+)\/SKILL\.md[^"]*"((?:[^"\\]|\\.)*)"/g)].map(
41
+ (m) => [m[1], m[2]],
42
+ );
43
+ }
44
+
45
+ // Guards: the pattern below is not vacuous — a cell that names a SKILL.md yields its quote.
46
+ test("every cell that names a SKILL.md yields a quote to check", () => {
47
+ const cells = [
48
+ ...Object.values(CANONICAL_INPUTS).flatMap((v) =>
49
+ Object.values(v.requires),
50
+ ),
51
+ ...UNEXPRESSED.map(([, why]) => why),
52
+ ...NOT_EDGES.map(([, why]) => why),
53
+ ];
54
+ const unparsed = cells.filter(
55
+ (why) => why.includes("/SKILL.md") && quotesIn(why).length === 0,
56
+ );
57
+ assert.deepEqual(unparsed, []);
58
+ });
59
+
60
+ test("every quoted sentence is still in the SKILL.md it is attributed to", () => {
61
+ const stale = quotes().flatMap(([edge, skill, quote]) => {
62
+ const file = join(SKILLS, skill, "SKILL.md");
63
+ if (!existsSync(file))
64
+ return [{ edge, skill, missing: "the skill itself" }];
65
+ const text = norm(readFileSync(file, "utf8"));
66
+ return norm(quote)
67
+ .split("…")
68
+ .map((f) => f.trim())
69
+ .filter((f) => f.length > 0 && !text.includes(f))
70
+ .map((missing) => ({ edge, skill, missing }));
71
+ });
72
+ assert.deepEqual(stale, []);
73
+ });
@@ -8,29 +8,10 @@
8
8
  * THE REGISTRY ITSELF (`repro/populations.tsv`), both have no address in the paper, and so cannot be expressed by ESLint,
9
9
  * which reports only to the linted file.
10
10
  *
11
- * WHY THIS FILE AND NOT MORE CASES IN THE SELF-TEST. `population-map.selftest.mjs` is thorough —
12
- * 7 planted cases, both directions. Nothing here duplicates it. Two things were wrong with it
13
- * anyway:
14
- *
15
- * 1. IT IS INVISIBLE TO THE MEASUREMENT. `vigiles audit` scores this repo `Tested: 0`, and the
16
- * metric counts `*.harness.mjs` / `*.eval.mjs`. Verified 2026-08-07: bare `npx vigiles test`
17
- * reports no harness files found — its glob does not descend into dot-dirs AND the self-test
18
- * does not carry a name the collector looks for. Given an explicit path it runs
19
- * fine, which is the trap: it is green, correct, and uncounted, so the repo reads
20
- * as having no test for its own registry hygiene.
21
- *
22
- * 2. IT ONLY EXERCISES `findings()`. Everything between the command line and that function —
23
- * finding paper.md, finding repro/populations.tsv, `--flags-only` staying silent on a clean
24
- * paper — is what the CI step and `run-mechanical.mjs` actually invoke, and none of it was
25
- * under test. This repository's three dead hooks all had correct logic; what was broken was
26
- * the wiring around it.
27
- *
28
- * 🔴 THE SELF-TEST IS RUN AS A SUBPROCESS, NEVER IMPORTED. It ends in `process.exit(fail ? 1 : 0)`.
29
- * Import it and a PASSING self-test terminates this process at that line — every assertion below it
30
- * never runs, and `vigiles test` reports ✓ because nothing threw. That is trap #1 from
31
- * hooks.harness.mjs wearing a different hat, and it would have been undetectable from the output.
32
- *
33
- * Every fixture is a throwaway in a temp dir; nothing here reads or writes a real paper.
11
+ * The findings themselves are planted in `population-map.test.mjs` (vitest; until #52 a
12
+ * self-test script this harness spawned). This file keeps what only the command shows: finding
13
+ * paper.md and repro/populations.tsv, and `--flags-only` staying silent on a clean paper — the
14
+ * wiring the CI step and `run-mechanical.mjs` actually invoke.
34
15
  */
35
16
  import assert from "node:assert/strict";
36
17
  import { execFileSync } from "node:child_process";
@@ -49,7 +30,6 @@ const HERE = dirname(fileURLToPath(import.meta.url));
49
30
 
50
31
  const ROOT = consumerRoot();
51
32
  const SCRIPT = join(HERE, "population-map.mjs");
52
- const SELFTEST = join(HERE, "population-map.selftest.mjs");
53
33
  const tmp = realpathSync(mkdtempSync(join(tmpdir(), "popmap-harness-")));
54
34
 
55
35
  /** Run a node script; never throws on a non-zero exit. */
@@ -87,29 +67,6 @@ const REG = [
87
67
  "census\t134\tdisjoint\tbroad\ta separate sample",
88
68
  ].join("\n");
89
69
 
90
- // ── 1. the self-test still kills every mutant it claims to ─────────────────────────────
91
- // Owning it from a *.harness.mjs is the whole point: the metric now counts it, and a regression in
92
- // the self-test itself fails this file rather than passing unnoticed in a step nobody reads.
93
- {
94
- const r = run([SELFTEST]);
95
- assert.equal(r.code, 0, "population-map.selftest.mjs failed:\n" + r.out);
96
- const m = r.out.match(/(\d+) passed, (\d+) failed/);
97
- assert.ok(
98
- m,
99
- "the self-test printed no result line — it may have exited before running:\n" +
100
- r.out,
101
- );
102
- // A self-test that runs ZERO cases also prints "0 failed" and exits 0. That shape is exactly how
103
- // this repo's earlier harness reported ✓ on `assert.equal(1, 2)`, so the count is asserted too.
104
- // Threshold lowered 13 → 7 along with the move of two findings to ESLint. The assertion on COUNT is kept:
105
- // a self-test that ran zero cases also prints «0 failed» and exits zero.
106
- assert.ok(
107
- Number(m[1]) >= 7,
108
- `the self-test ran only ${m[1]} case(s) — cases have gone missing`,
109
- );
110
- assert.equal(Number(m[2]), 0, "the self-test reported failures:\n" + r.out);
111
- }
112
-
113
70
  // ── 2. the CLI finds the paper and the registry, and fires on a stale registry row ─────
114
71
  // 🔴 FIXTURE REWRITTEN 2026-08-26. Before that it planted an UNLINKED POPULATION, and that
115
72
  // finding moved to `paper/population-untied` — meaning the old «dirty» input became clean, and
@@ -94,7 +94,7 @@ export function parseRegistry(tsv) {
94
94
  if (!line.trim() || line.startsWith("#")) continue;
95
95
  const [id, print, relation, relatedTo, gloss] = line
96
96
  .split("\t")
97
- .map((c) => (c ?? "").trim());
97
+ .map((c) => c.trim()); // split yields strings; a missing column is `undefined` in the destructuring
98
98
  if (!id || id === "id") continue;
99
99
  rows.push({ id, print, relation, relatedTo, gloss });
100
100
  }
@@ -108,6 +108,12 @@ export function findings(md, tsv) {
108
108
  const out = [];
109
109
 
110
110
  for (const row of reg) {
111
+ // A row with no printed quantity has nothing to look for in the body. It used to reach
112
+ // `printed(undefined)` and crash the checker; it is a defect of the TSV, reported as one.
113
+ if (!row.print) {
114
+ out.push({ kind: "incomplete", row });
115
+ continue;
116
+ }
111
117
  const appears = printed(row.print).test(body);
112
118
  if (!appears) {
113
119
  // A registry row for a quantity the body no longer prints. Same discipline as the
@@ -124,7 +130,7 @@ export function findings(md, tsv) {
124
130
  return out;
125
131
  }
126
132
 
127
- /* Importable above this line; the CLI only runs when this file is the entry point, so the selftest
133
+ /* Importable above this line; the CLI only runs when this file is the entry point, so the tests
128
134
  * can exercise findings() without the argv handling firing. */
129
135
  if (!isMain(import.meta.url)) {
130
136
  // imported — nothing to do
@@ -175,6 +181,10 @@ function main() {
175
181
  console.log(
176
182
  ` 🔴 ${f.row.id} says it relates to "${f.row.relatedTo}", which is not a row in populations.tsv`,
177
183
  );
184
+ } else {
185
+ console.log(
186
+ ` 🔴 ${f.row.id} has no printed quantity — the row cannot be checked against the body`,
187
+ );
178
188
  }
179
189
  }
180
190
  process.exit(0);
@@ -0,0 +1,154 @@
1
+ /**
2
+ * population-map.mjs — the registry's own findings (`stale`, `badref`), planted in both directions,
3
+ * and the command around them. Until #52 the seven cases below were `population-map.selftest.mjs`,
4
+ * a script the harness spawned and whose "N passed" line it parsed; they are vitest cases now.
5
+ *
6
+ * 🔴 ONE CASE WAS DELETED BEFORE THE MOVE, NOT MOVED, and that is worth remembering: it ran a
7
+ * HAND-COPIED COPY of the checker's regex over a string. A change to the real `printed()` cannot
8
+ * touch such a test by construction. The property is pinned by behaviour instead (case 6).
9
+ */
10
+ import assert from "node:assert/strict";
11
+ import { dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { test } from "vitest";
14
+ import { runNode, useTempDir, writeTree } from "../../../test/support.mjs";
15
+ import { bodyOf, findings } from "./population-map.mjs";
16
+
17
+ const SCRIPT = join(
18
+ dirname(fileURLToPath(import.meta.url)),
19
+ "population-map.mjs",
20
+ );
21
+ const doc = (bodyLines) =>
22
+ `## Abstract\n\n${bodyLines}\n\n## References\n\n[1] x.\n`;
23
+ const kinds = (md, reg) =>
24
+ findings(md, reg).map((f) => `${f.kind}:${f.row.print}`);
25
+ const REG = [
26
+ "id\tprinted\trelation\trelated_to\tgloss",
27
+ "broad\t1,921\troot\t\tthe corpus",
28
+ "sub\t1,836\tsubset\tbroad\tthose it could read",
29
+ ].join("\n");
30
+ const HEAD = "id\tprinted\trelation\trelated_to\tgloss\n";
31
+
32
+ // 1. The direction that matters more: a checker firing on a paper that already obeys gets muted.
33
+ test("a registry that matches the body is silent", () => {
34
+ assert.deepEqual(
35
+ kinds(
36
+ doc(
37
+ "The resolver ran over all 1,921 repositories, and read 1,836 of them.",
38
+ ),
39
+ REG,
40
+ ),
41
+ [],
42
+ );
43
+ });
44
+
45
+ // 2. A row for a quantity the paper no longer prints is a claim nobody can check.
46
+ test("a stale registry row fires", () => {
47
+ assert.deepEqual(
48
+ kinds(doc("The resolver ran over all 1,921 repositories."), REG),
49
+ ["stale:1,836"],
50
+ );
51
+ });
52
+
53
+ // 3. `retired` is what keeps a set declared after it was cut from the paper.
54
+ test("a retired row does not fire", () => {
55
+ assert.deepEqual(
56
+ kinds(
57
+ doc("The resolver ran over all 1,921 repositories, and read 1,836."),
58
+ `${REG}\ngold\t7,310\tretired\t\ta public gold set`,
59
+ ),
60
+ [],
61
+ );
62
+ });
63
+
64
+ // 4. A relation pointing at an id that is not a row — a defect of the TSV alone.
65
+ test("a dangling related_to fires", () => {
66
+ assert.deepEqual(
67
+ kinds(
68
+ doc("We read 1,836 repositories."),
69
+ `${HEAD}sub\t1,836\tsubset\tnosuch\tx`,
70
+ ),
71
+ ["badref:1,836"],
72
+ );
73
+ });
74
+
75
+ // 5. A row whose number is absent is `stale`, never also `badref`.
76
+ test("a row that is absent is stale, not badref", () => {
77
+ assert.deepEqual(
78
+ kinds(doc("Nothing numeric here."), `${HEAD}sub\t1,836\tsubset\tnosuch\tx`),
79
+ ["stale:1,836"],
80
+ );
81
+ });
82
+
83
+ // 6. `printed()` boundaries through behaviour: 189 is not printed inside 1,189.
84
+ test('189 is not "printed" inside 1,189', () => {
85
+ assert.deepEqual(
86
+ kinds(
87
+ doc("A follow-up read covered 1,189 records, of all 1,921."),
88
+ `${HEAD}broad\t1,921\troot\t\tthe corpus\nspread\t189\tsubset\tbroad\tenumerable`,
89
+ ),
90
+ ["stale:189"],
91
+ );
92
+ });
93
+
94
+ // 7. The body stops at the bibliography; with no Abstract it starts at the top, with no
95
+ // References it runs to the end.
96
+ test("bodyOf: from Abstract to References, or the whole text when either is missing", () => {
97
+ assert.deepEqual(
98
+ [
99
+ bodyOf(doc("nothing here")).includes("[1] x."),
100
+ bodyOf("pre\n\n## References\n\n[1] x.\n"),
101
+ bodyOf("## Abstract\n\nall of it\n"),
102
+ ],
103
+ [false, "pre\n\n", "## Abstract\n\nall of it\n"],
104
+ );
105
+ });
106
+
107
+ test("a registry row with no printed quantity is reported, not a crash", () => {
108
+ assert.deepEqual(
109
+ findings(doc("x"), `${HEAD}short\n`).map((f) => [f.kind, f.row.id]),
110
+ [["incomplete", "short"]],
111
+ );
112
+ });
113
+
114
+ // ── the command ─────────────────────────────────────────────────────────────────────────
115
+ const root = useTempDir("population-map-");
116
+ const paper = (name, body, tsv) =>
117
+ writeTree(join(root, name), {
118
+ "paper.md": doc(body),
119
+ ...(tsv === undefined ? {} : { "repro/populations.tsv": tsv }),
120
+ });
121
+
122
+ test("the command: usage, a paper without markdown, a clean registry, and every finding kind", () => {
123
+ assert.deepEqual(runNode(SCRIPT, []), {
124
+ status: 0,
125
+ stdout: "",
126
+ stderr: "usage: node population-map.mjs <paper-dir> [--flags-only]\n",
127
+ });
128
+ const tex = writeTree(join(root, "tex"), { "paper.tex": "x" });
129
+ assert.match(
130
+ runNode(SCRIPT, [tex]).stderr,
131
+ /population map SKIPPED for .* — no paper\.md\/draft\.md — this checker reads markdown only/,
132
+ );
133
+ const clean = paper(
134
+ "clean",
135
+ "All 1,921 of them.",
136
+ `${HEAD}broad\t1,921\troot\t\tx`,
137
+ );
138
+ assert.equal(
139
+ runNode(SCRIPT, [clean]).stdout,
140
+ "population map: the registry matches the body — no stale rows, no dangling related_to.\n",
141
+ );
142
+ assert.equal(runNode(SCRIPT, [clean, "--flags-only"]).stdout, "");
143
+ const bad = paper(
144
+ "bad",
145
+ "We read 1,836.",
146
+ `${HEAD}sub\t1,836\tsubset\tnosuch\tx\nold\t7,310\tsubset\tsub\tx\nshort\n`,
147
+ );
148
+ assert.deepEqual(runNode(SCRIPT, [bad]).stdout.split("\n").slice(1), [
149
+ ' 🔴 sub says it relates to "nosuch", which is not a row in populations.tsv',
150
+ " · 7,310 (old) is declared and the body no longer prints it — drop the row or mark it retired",
151
+ " 🔴 short has no printed quantity — the row cannot be checked against the body",
152
+ "",
153
+ ]);
154
+ });
@@ -234,6 +234,32 @@ const NOTES = [
234
234
  assert.match(out, /clean/, "clean run did not print a verdict:\n" + out);
235
235
  }
236
236
 
237
+ // ── 6. a registry with no ratchet file: every registered quantity sourced, nothing still raw ──
238
+ {
239
+ const d = paper("no-ratchet", {
240
+ body: "Nothing bolded here.",
241
+ notes: NOTES,
242
+ numbers: "# id\tvalue\n a\t1\n",
243
+ });
244
+ const { out } = kinds(d);
245
+ assert.match(
246
+ out,
247
+ /1 quantities are sourced and guarded, 0 are still raw/,
248
+ "no numbers-grandfathered.txt must count as zero still raw:\n" + out,
249
+ );
250
+ }
251
+
252
+ // ── 7. no argument: the directory it is run from ──────────────────────────────────────────
253
+ {
254
+ const d = paper("from-cwd", { body: "Nothing bolded here.", notes: NOTES });
255
+ const out = execFileSync("node", [SCRIPT], { cwd: d, encoding: "utf8" });
256
+ assert.match(
257
+ out,
258
+ /clean/,
259
+ "run with no argument must judge its cwd:\n" + out,
260
+ );
261
+ }
262
+
237
263
  rmSync(tmp, { recursive: true, force: true });
238
264
  console.log(
239
265
  "✓ check-provenance: no-numbers-gate fires, coverage is measured, both guards are pinned, clean run speaks",
@@ -14,8 +14,7 @@
14
14
  * on the mutation-testing principle that a test which kills no mutant is not a test. On the live
15
15
  * paper this check reports one finding (`no-round-ledger` — there are no rounds on file yet), so the
16
16
  * live corpus cannot distinguish "the other twelve checks work" from "the other twelve checks are
17
- * dead code". Its evidence of life is here, and its evidence that THIS FILE has teeth is
18
- * `round-diff.mutations.mjs`.
17
+ * dead code". Its evidence of life is here: every case below was seen failing before it passed.
19
18
  *
20
19
  * 🔴 ASSERTIONS RUN AT MODULE TOP LEVEL. `vigiles test` imports the file and treats "did not throw"
21
20
  * as a pass; an earlier harness in this directory exported a `tests` object, nothing ran, and the
@@ -65,7 +65,7 @@
65
65
  */
66
66
  import { readFileSync, existsSync, readdirSync } from "node:fs";
67
67
  import { spawnSync } from "node:child_process";
68
- import { join, resolve, relative } from "node:path";
68
+ import { basename, join, resolve, relative } from "node:path";
69
69
  import { stripFrontmatter, frontmatterBlock } from "../../../lib/markdown.mjs";
70
70
  import { isMain } from "./consumer.mjs";
71
71
 
@@ -274,7 +274,7 @@ const unquote = (s) => s.replace(/^["']|["']$/g, "");
274
274
  * The first version fell through to the substring rule for numeric entries too, so `touches: ["2"]`
275
275
  * matched "Section 12", "2026", and every heading containing the character 2 — a one-character
276
276
  * declaration authorising most of the paper, with `overbroad-scope` silent because the LIST was
277
- * short. Found on 2026-08-10 by mutation, not by reading: disabling the nesting rule changed no
277
+ * short. Measured on 2026-08-10, not read off the code: disabling the nesting rule changed no
278
278
  * verdict, because the substring fallback was quietly doing its job for it.
279
279
  */
280
280
  export function covers(entry, heading) {
@@ -318,6 +318,8 @@ export function check(dir, { since } = {}) {
318
318
  const at = (rev) => {
319
319
  if (!repo) return { ok: false, err: "not a git repository" };
320
320
  const r = git(repo, "show", `${rev}:${rel}`);
321
+ // A git that fails without a word on stderr (killed, or a wrapper that swallows it) still
322
+ // leaves the reader the revision and path it could not read.
321
323
  return r.ok
322
324
  ? { ok: true, src: r.out }
323
325
  : { ok: false, err: r.err || `git show ${rev}:${rel} failed` };
@@ -342,6 +344,10 @@ export function check(dir, { since } = {}) {
342
344
  manifests.push(man);
343
345
  }
344
346
  manifests.sort((a, b) => Number(a.round ?? 0) - Number(b.round ?? 0));
347
+ // A manifest names its round in `round:`; one that does not is named by its file — printing
348
+ // `round undefined` would point the reader at nothing.
349
+ const label = (m) =>
350
+ m.round === undefined ? relOf(dir, m.path) : `round ${m.round}`;
345
351
  const open = manifests.filter((m) => !m.closed);
346
352
 
347
353
  if (!since && manifests.length === 0) {
@@ -353,7 +359,7 @@ export function check(dir, { since } = {}) {
353
359
  if (open.length > 1) {
354
360
  add(
355
361
  "two-open-rounds",
356
- `${open.length} manifests have no \`closed:\` (${open.map((m) => `round ${m.round}`).join(", ")}) — with two open rounds every budget below is checked against the wrong base.`,
362
+ `${open.length} manifests have no \`closed:\` (${open.map((m) => label(m)).join(", ")}) — with two open rounds every budget below is checked against the wrong base.`,
357
363
  );
358
364
  }
359
365
  if (!since && manifests.length > 0 && open.length === 0) {
@@ -410,13 +416,13 @@ export function check(dir, { since } = {}) {
410
416
  if (cum > total) {
411
417
  add(
412
418
  "ratchet-cumulative",
413
- `across ${manifests.length} rounds the body grew ${sign(cum)} words against ${sign(total)} of declared budget (${c0.bodyWords} → ${nowC.bodyWords}, from round ${first.round}'s base \`${first.base}\`). Every individual round may have been inside its own budget; this is the drift they compound into.`,
419
+ `across ${manifests.length} rounds the body grew ${sign(cum)} words against ${sign(total)} of declared budget (${c0.bodyWords} → ${nowC.bodyWords}, from ${label(first)}'s base \`${first.base}\`). Every individual round may have been inside its own budget; this is the drift they compound into.`,
414
420
  );
415
421
  }
416
422
  } else {
417
423
  add(
418
424
  "unresolvable-base",
419
- `round ${first.round}'s base \`${first.base}\` does not resolve (${g0.err}), so cumulative drift across ${manifests.length} rounds is UNMEASURED — not zero.`,
425
+ `${label(first)}'s base \`${first.base}\` does not resolve (${g0.err}), so cumulative drift across ${manifests.length} rounds is UNMEASURED — not zero.`,
420
426
  );
421
427
  }
422
428
  }
@@ -522,7 +528,8 @@ export function check(dir, { since } = {}) {
522
528
  const num = (v, d) =>
523
529
  v === undefined || v === null || v === "" || isNaN(Number(v)) ? d : Number(v);
524
530
  const sign = (n) => (n > 0 ? `+${n}` : String(n));
525
- const relOf = (dir, p) => relative(resolve(dir, ".."), p) || p;
531
+ /** `p`, which lies inside the paper directory `dir`, named from the paper's own name down. */
532
+ const relOf = (dir, p) => join(basename(dir), relative(dir, p));
526
533
 
527
534
  // ── CLI ──────────────────────────────────────────────────────────────────────────────────────
528
535
 
@@ -0,0 +1,159 @@
1
+ /**
2
+ * round-diff.mjs beyond its harness: the manifest reader's edge lines, `*` coverage, the census of
3
+ * an empty text, and — in real git repositories — manifests without a round number, a first base
4
+ * that does not resolve, a removed section that is declared, more than eight new numbers, and the
5
+ * command with no git on PATH, no paper.md, and no directory argument.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { spawnSync } from "node:child_process";
9
+ import { chmodSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { test } from "vitest";
13
+ import { runNode, useTempDir, writeTree } from "../../../test/support.mjs";
14
+ import { census, check, covers, parseManifest } from "./round-diff.mjs";
15
+
16
+ const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "round-diff.mjs");
17
+ const root = useTempDir("round-diff-");
18
+ let n = 0;
19
+
20
+ /** A git repo whose HEAD holds `base` as paper.md; the working tree then holds `now`. */
21
+ function repo(base, now = base, rounds = {}) {
22
+ const dir = join(root, `r${String(n++)}`, "paper");
23
+ writeTree(dir, { "paper.md": base });
24
+ const g = (...a) => spawnSync("git", a, { cwd: dir, encoding: "utf8" });
25
+ g("init", "-q");
26
+ g(
27
+ "-c",
28
+ "user.name=t",
29
+ "-c",
30
+ "user.email=t@t",
31
+ "commit",
32
+ "-qm",
33
+ "base",
34
+ "--allow-empty",
35
+ );
36
+ g("add", "paper.md");
37
+ g("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "paper");
38
+ const head = g("rev-parse", "HEAD").stdout.trim();
39
+ writeTree(dir, {
40
+ "paper.md": now,
41
+ ...Object.fromEntries(
42
+ Object.entries(rounds).map(([f, body]) => [
43
+ `rounds/${f}`,
44
+ body.replaceAll("HEAD_SHA", head),
45
+ ]),
46
+ ),
47
+ });
48
+ return dir;
49
+ }
50
+ const kinds = (r) => r.findings.map((f) => f.kind);
51
+
52
+ test("manifest: no frontmatter, comments and blanks, a list item before any key, lists both ways", () => {
53
+ assert.deepEqual(parseManifest("no frontmatter", "m.md"), {
54
+ error: "no frontmatter block",
55
+ path: "m.md",
56
+ });
57
+ assert.deepEqual(
58
+ parseManifest(
59
+ [
60
+ "---",
61
+ "- stray item",
62
+ "# a comment",
63
+ "",
64
+ "base: abc # the base",
65
+ "touches:",
66
+ " - 1",
67
+ " - Intro",
68
+ "allows: [new-number, 'new-citation']",
69
+ "not a key line",
70
+ "notes:",
71
+ "---",
72
+ "",
73
+ ].join("\n"),
74
+ "m.md",
75
+ ),
76
+ {
77
+ path: "m.md",
78
+ touches: ["1", "Intro"],
79
+ allows: ["new-number", "new-citation"],
80
+ base: "abc",
81
+ notes: [],
82
+ },
83
+ );
84
+ });
85
+
86
+ test("`*` covers every heading; an empty text has no hedge density", () => {
87
+ assert.equal(covers("*", "7. Anything"), true);
88
+ assert.equal(census("").hedgeDensity, 0);
89
+ });
90
+
91
+ const P = (...secs) => secs.map(([h, t]) => `## ${h}\n\n${t}\n`).join("\n");
92
+ // Untouched sections, so a round that touches one or two is not an over-broad scope.
93
+ const REST = ["3. Three", "4. Four", "5. Five", "6. Six"].map((h) => [
94
+ h,
95
+ "Same.",
96
+ ]);
97
+ const round = (r, base, touches, extra = "") =>
98
+ `---\n${r === null ? "" : `round: ${r}\n`}base: ${base}\ntouches: [${touches}]\nbudget: 1000\n${extra}---\n`;
99
+
100
+ test("a manifest without a round number is named by its file, not as `round undefined`; its unresolvable base is named", () => {
101
+ const text = P(["1. Intro", "Words here."], ...REST);
102
+ const dir = repo(text, text, {
103
+ "a.md": round(null, "deadbeef", "1", "closed: true\n"),
104
+ "b.md": round(null, "HEAD_SHA", "1"),
105
+ });
106
+ const r = check(dir);
107
+ assert.deepEqual(kinds(r), ["unresolvable-base"]);
108
+ assert.match(
109
+ r.findings[0].msg,
110
+ /^paper\/rounds\/a\.md's base `deadbeef` does not resolve/,
111
+ );
112
+ });
113
+
114
+ test("a removed section the round declares is not a finding; more than eight new numbers are elided", () => {
115
+ const base = P(["1. Intro", "Words."], ["2. Gone", "Old words."], ...REST);
116
+ const now = P(["1. Intro", "Words 1 2 3 4 5 6 7 8 9 10."], ...REST);
117
+ const dir = repo(base, now, { "a.md": round(1, "HEAD_SHA", "1, 2") });
118
+ const r = check(dir);
119
+ assert.deepEqual(kinds(r), ["unauthorised-number"]);
120
+ assert.match(
121
+ r.findings[0].msg,
122
+ /^10 numeric literal\(s\) new to the body this round \(1, 2, 3, 4, 5, 6, 7, 8, …\)/,
123
+ );
124
+ });
125
+
126
+ test("the command: no paper.md is a note; no git on PATH is 'not a git repository'; no argument is the cwd", () => {
127
+ const empty = writeTree(join(root, "empty"), { "x.txt": "" });
128
+ const none = runNode(SCRIPT, [], { cwd: empty });
129
+ assert.equal(none.status, 0);
130
+ assert.match(none.stdout, new RegExp(`^ {3}no paper\\.md in ${empty}$`, "m"));
131
+ const dir = writeTree(join(root, "nogit"), { "paper.md": P(["1. A", "x"]) });
132
+ const r = runNode(SCRIPT, [dir, "--json", "--since=HEAD"], {
133
+ env: { PATH: dirname(process.execPath) },
134
+ });
135
+ assert.deepEqual(JSON.parse(r.stdout), [
136
+ {
137
+ kind: "unresolvable-base",
138
+ msg: "cannot read paper.md at `HEAD`: not a git repository. A round whose base does not resolve is a round with no gate, and it must not read as a clean run.",
139
+ },
140
+ ]);
141
+ });
142
+
143
+ test("a git that fails without a word still names the revision it could not read", () => {
144
+ // A stand-in `git` first on PATH: it knows the repository root, and `show` exits 1 silently.
145
+ const bin = writeTree(join(root, "quiet-git-bin"), {
146
+ git: '#!/bin/sh\ncase "$1" in rev-parse) pwd ;; show) exit 1 ;; *) exit 0 ;; esac\n',
147
+ });
148
+ chmodSync(join(bin, "git"), 0o755);
149
+ const dir = writeTree(join(root, "quiet"), { "paper.md": P(["1. A", "x"]) });
150
+ const r = runNode(SCRIPT, [dir, "--json", "--since=abc"], {
151
+ env: { PATH: `${bin}:${dirname(process.execPath)}` },
152
+ });
153
+ assert.deepEqual(JSON.parse(r.stdout), [
154
+ {
155
+ kind: "unresolvable-base",
156
+ msg: "cannot read paper.md at `abc`: git show abc:paper.md failed. A round whose base does not resolve is a round with no gate, and it must not read as a clean run.",
157
+ },
158
+ ]);
159
+ });