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
@@ -0,0 +1,178 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ freshPart,
4
+ EMPTY_LOOKUP_CACHE,
5
+ parseLookupCache,
6
+ serializeLookupCache,
7
+ type LookupCache,
8
+ } from "./lookup-cache.ts";
9
+ import { err, ok } from "./result.ts";
10
+
11
+ const HIT = {
12
+ venue: "ICSE",
13
+ year: "2024",
14
+ type: "Conference and Workshop Papers",
15
+ title: "Good Paper.",
16
+ authors: ["Ada Lovelace"],
17
+ };
18
+ const CACHE: LookupCache = {
19
+ citations: new Map([
20
+ [
21
+ "crossref:doi:10.1/good",
22
+ {
23
+ fetched: "2026-09-27",
24
+ response: { db: "crossref", transport: "ok", query: "doi" },
25
+ },
26
+ ],
27
+ ]),
28
+ dblp: new Map([
29
+ [
30
+ "0123456789abcdef",
31
+ { fetched: "2026-09-26", title: "Good Paper", hits: [HIT] },
32
+ ],
33
+ ]),
34
+ };
35
+ const doc = (o: object): string => JSON.stringify({ schema: 1, ...o });
36
+
37
+ describe("serializeLookupCache / parseLookupCache", () => {
38
+ it("round-trips, keys sorted, one trailing newline", () => {
39
+ const text = serializeLookupCache(CACHE);
40
+ expect(text).toBe(
41
+ `${JSON.stringify(
42
+ {
43
+ schema: 1,
44
+ citations: {
45
+ "crossref:doi:10.1/good": {
46
+ fetched: "2026-09-27",
47
+ response: { db: "crossref", transport: "ok", query: "doi" },
48
+ },
49
+ },
50
+ dblp: {
51
+ ["0123456789abcdef"]: {
52
+ fetched: "2026-09-26",
53
+ title: "Good Paper",
54
+ hits: [HIT],
55
+ },
56
+ },
57
+ },
58
+ null,
59
+ 2,
60
+ )}\n`,
61
+ );
62
+ expect(parseLookupCache(text)).toEqual(ok(CACHE));
63
+ });
64
+
65
+ it("the empty cache writes both sections, and reads back as empty", () => {
66
+ expect(parseLookupCache(serializeLookupCache(EMPTY_LOOKUP_CACHE))).toEqual(
67
+ ok(EMPTY_LOOKUP_CACHE),
68
+ );
69
+ });
70
+ });
71
+
72
+ // Guards: a committed file that is wrong in any way is refused with a NAMED reason. Read as
73
+ // empty, it would silently turn every build into a full online run — the defect this file fixes.
74
+ const REFUSALS: [string, string][] = [
75
+ ["[]", "expected an object with `schema`, `citations` and `dblp`"],
76
+ [doc({ schema: 2 }), "schema 2: this paperlint reads schema 1"],
77
+ [doc({ citations: {}, dblp: {}, x: 1 }), "unknown field `x`"],
78
+ [doc({ dblp: {} }), "`citations`: expected an object"],
79
+ [doc({ citations: {} }), "`dblp`: expected an object"],
80
+ [doc({ citations: { k: 1 }, dblp: {} }), "`citations.k`: expected an object"],
81
+ [
82
+ doc({
83
+ citations: { k: { fetched: "2026-9-1", response: {} } },
84
+ dblp: {},
85
+ }),
86
+ "`citations.k.fetched`: expected a date, YYYY-MM-DD",
87
+ ],
88
+ [
89
+ doc({
90
+ citations: {
91
+ k: { fetched: "2026-09-01", response: { transport: "error" } },
92
+ },
93
+ dblp: {},
94
+ }),
95
+ '`citations.k.response`: expected a successful response ({"transport": "ok", …})',
96
+ ],
97
+ [
98
+ doc({
99
+ citations: {
100
+ k: { fetched: "2026-09-01", response: { transport: "ok" }, y: 1 },
101
+ },
102
+ dblp: {},
103
+ }),
104
+ "`citations.k`: unknown field `y`",
105
+ ],
106
+ [
107
+ doc({
108
+ citations: {},
109
+ dblp: { t: { fetched: "2026-09-01", title: 3, hits: [] } },
110
+ }),
111
+ "`dblp.t.title`: expected a string",
112
+ ],
113
+ [
114
+ doc({
115
+ citations: {},
116
+ dblp: { t: { fetched: "2026-09-01", title: "T", hits: {} } },
117
+ }),
118
+ "`dblp.t.hits`: expected an array",
119
+ ],
120
+ [
121
+ doc({
122
+ citations: {},
123
+ dblp: {
124
+ t: {
125
+ fetched: "2026-09-01",
126
+ title: "T",
127
+ hits: [{ ...HIT, authors: [1] }],
128
+ },
129
+ },
130
+ }),
131
+ "`dblp.t.hits[0]`: expected {venue, year, type, title: string; authors: string[]}",
132
+ ],
133
+ [
134
+ doc({
135
+ citations: {},
136
+ dblp: { t: { fetched: "2026-09-01", title: "T", hits: [3] } },
137
+ }),
138
+ "`dblp.t.hits[0]`: expected {venue, year, type, title: string; authors: string[]}",
139
+ ],
140
+ ];
141
+
142
+ describe("parseLookupCache refusals", () => {
143
+ it("refuses text that is not JSON, saying so", () => {
144
+ const r = parseLookupCache("{");
145
+ // The rest of the message is Node's parser; its wording is not the subject.
146
+ expect(r.ok ? "" : r.error).toMatch(/^not JSON: /);
147
+ });
148
+
149
+ // Guards: see REFUSALS.
150
+ it.each(REFUSALS)("refuses %s with a named reason", (text, why) => {
151
+ expect(parseLookupCache(text)).toEqual(err(why));
152
+ });
153
+ });
154
+
155
+ describe("freshPart", () => {
156
+ const cache = (fetched: string): LookupCache => ({
157
+ citations: new Map([
158
+ [
159
+ "k",
160
+ {
161
+ fetched,
162
+ response: { db: "crossref", transport: "ok", query: "doi" },
163
+ },
164
+ ],
165
+ ]),
166
+ dblp: new Map(),
167
+ });
168
+ // Guards: the age window has two ends. A date in the future (a clock ahead, a typo) has a
169
+ // negative age; read as fresh, a miss would be trusted until that date plus 30 days.
170
+ it.each([
171
+ ["2026-09-27", 1],
172
+ ["2026-08-28", 1],
173
+ ["2026-08-27", 0],
174
+ ["2026-09-28", 0],
175
+ ])("on 2026-09-27, an answer fetched %s is kept %i time(s)", (fetched, n) => {
176
+ expect(freshPart(cache(fetched), "2026-09-27").citations.size).toBe(n);
177
+ });
178
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * THE LOOKUP CACHE — `<paper>/repro/references-cache.json`: what the citation services ANSWERED,
3
+ * kept so that a build asks each question at most once every MAX_AGE_DAYS, not once per build (#107).
4
+ *
5
+ * { "schema": 1,
6
+ * "citations": { "<resolver>:<identifier>": { "fetched": "YYYY-MM-DD", "response": {…} } },
7
+ * "dblp": { "<title key>": { "fetched": "YYYY-MM-DD", "title": "…", "hits": […] } } }
8
+ *
9
+ * `citations` holds verify-cites' per-resolver responses (Crossref, OpenAlex, Semantic Scholar,
10
+ * arXiv, doi.org, NVD), under verify-cites' own cache keys. `dblp` holds the hits DBLP returned for
11
+ * a title, under the references adapter's `dblpTitleKey`. Only successful answers are ever stored: a failed request is not an
12
+ * answer, so the next build asks again.
13
+ *
14
+ * 🔴 RESPONSES, NEVER VERDICTS. The verdicts (`_build/references.json`) are DERIVED from these
15
+ * responses on every build, by the current version of the checkers. A cached verdict would freeze
16
+ * whatever the checker believed the day it ran — a bug fixed in the comparison would never reach a
17
+ * paper whose entries are cached. A cached response is evidence: it cannot be made up, only
18
+ * refetched. The precedent is `extract-ref-facts.mjs`'s `repro/refs-cache.json` ("the cache is
19
+ * evidence, not a second declaration").
20
+ *
21
+ * 🔴 COMMITTED, so CI and every other clone start warm: a build whose entries are all here makes no
22
+ * network request at all, not even the reachability probe. It lives in `repro/`, beside the paper,
23
+ * not in `_build/`, which is build output and gitignored.
24
+ *
25
+ * WHAT INVALIDATES AN ENTRY: its key, or its age. Editing a bib entry's DOI, arXiv id or title
26
+ * changes the keys it is looked up under, so only that entry is asked again. An answer older than
27
+ * MAX_AGE_DAYS (by `fetched`) is asked again too, so a work a service had not indexed yet is found
28
+ * once it is. To refresh an answer sooner, delete its entry, or the file.
29
+ *
30
+ * Parsed here, once, at the boundary: a file that does not match is refused with a named reason,
31
+ * never read as empty.
32
+ */
33
+ import { err, ok, type Result } from "./result.ts";
34
+
35
+ export const LOOKUP_CACHE_SCHEMA = 1;
36
+
37
+ type Json = Readonly<Record<string, unknown>>;
38
+
39
+ /** One resolver's successful answer, as verify-cites shapes it (`{ transport: "ok", … }`). */
40
+ export interface CachedResponse {
41
+ /** When it was fetched, YYYY-MM-DD. */
42
+ readonly fetched: string;
43
+ readonly response: Json;
44
+ }
45
+
46
+ /** One DBLP hit, as bib-authors' `dblpHits` shapes it. */
47
+ export interface DblpHit {
48
+ readonly venue: string;
49
+ readonly year: string;
50
+ readonly type: string;
51
+ readonly title: string;
52
+ readonly authors: readonly string[];
53
+ }
54
+
55
+ export interface CachedDblp {
56
+ readonly fetched: string;
57
+ /** The title as it was asked — for a human reading the file; the key is the adapter's `dblpTitleKey(title)`. */
58
+ readonly title: string;
59
+ readonly hits: readonly DblpHit[];
60
+ }
61
+
62
+ export interface LookupCache {
63
+ readonly citations: ReadonlyMap<string, CachedResponse>;
64
+ readonly dblp: ReadonlyMap<string, CachedDblp>;
65
+ }
66
+
67
+ export const EMPTY_LOOKUP_CACHE: LookupCache = {
68
+ citations: new Map(),
69
+ dblp: new Map(),
70
+ };
71
+
72
+ /**
73
+ * How long a cached answer is used before it is asked again. A registry, doi.org or DBLP that had
74
+ * not indexed a work yet answers "no record"; kept forever, that answer would outlive the work's
75
+ * indexing. The age is judged against `fetched`, so every answer — a hit or a miss — gets the
76
+ * same rule, and nothing has to decide which answers are "negative".
77
+ */
78
+ export const MAX_AGE_DAYS = 30;
79
+
80
+ const dayNumber = (date: string): number =>
81
+ Date.parse(`${date}T00:00:00Z`) / 86_400_000;
82
+
83
+ /** The part of `cache` still young enough to answer from on `today` (YYYY-MM-DD). */
84
+ export function freshPart(cache: LookupCache, today: string): LookupCache {
85
+ // An age below zero is a date in the future (a clock ahead, a typo): not trusted, asked again.
86
+ const young = ({ fetched }: { fetched: string }): boolean => {
87
+ const age = dayNumber(today) - dayNumber(fetched);
88
+ return age >= 0 && age <= MAX_AGE_DAYS;
89
+ };
90
+ return {
91
+ citations: new Map([...cache.citations].filter(([, v]) => young(v))),
92
+ dblp: new Map([...cache.dblp].filter(([, v]) => young(v))),
93
+ };
94
+ }
95
+
96
+ const isObject = (v: unknown): v is Json =>
97
+ typeof v === "object" && v !== null && !Array.isArray(v);
98
+ const unknownField = (o: Json, known: readonly string[]): string | undefined =>
99
+ Object.keys(o).find((k) => !known.includes(k));
100
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
101
+ const isDate = (v: unknown): v is string =>
102
+ typeof v === "string" && DATE.test(v);
103
+
104
+ /** Parse every value of `section` with `one`, or the first named reason. */
105
+ function sectionOf<T>(
106
+ name: string,
107
+ section: unknown,
108
+ known: readonly string[],
109
+ one: (o: Json, at: string) => Result<T, string>,
110
+ ): Result<Map<string, T>, string> {
111
+ if (!isObject(section)) return err(`\`${name}\`: expected an object`);
112
+ const out = new Map<string, T>();
113
+ for (const [key, v] of Object.entries(section)) {
114
+ const at = `\`${name}.${key}`;
115
+ if (!isObject(v)) return err(`${at}\`: expected an object`);
116
+ const extra = unknownField(v, known);
117
+ if (extra !== undefined) return err(`${at}\`: unknown field \`${extra}\``);
118
+ if (!isDate(v["fetched"]))
119
+ return err(`${at}.fetched\`: expected a date, YYYY-MM-DD`);
120
+ const r = one(v, at);
121
+ if (!r.ok) return r;
122
+ out.set(key, r.value);
123
+ }
124
+ return ok(out);
125
+ }
126
+
127
+ const cachedResponse = (o: Json, at: string): Result<CachedResponse, string> =>
128
+ isObject(o["response"]) && o["response"]["transport"] === "ok"
129
+ ? ok({ fetched: o["fetched"] as string, response: o["response"] })
130
+ : err(
131
+ `${at}.response\`: expected a successful response ({"transport": "ok", …})`,
132
+ );
133
+
134
+ const isHit = (h: unknown): h is DblpHit =>
135
+ isObject(h) &&
136
+ ["venue", "year", "type", "title"].every((k) => typeof h[k] === "string") &&
137
+ Array.isArray(h["authors"]) &&
138
+ h["authors"].every((a) => typeof a === "string");
139
+
140
+ function cachedDblp(o: Json, at: string): Result<CachedDblp, string> {
141
+ const { title, hits } = o;
142
+ if (typeof title !== "string") return err(`${at}.title\`: expected a string`);
143
+ if (!Array.isArray(hits)) return err(`${at}.hits\`: expected an array`);
144
+ const bad = hits.findIndex((h) => !isHit(h));
145
+ if (bad !== -1)
146
+ return err(
147
+ `${at}.hits[${String(bad)}]\`: expected {venue, year, type, title: string; authors: string[]}`,
148
+ );
149
+ return ok({
150
+ fetched: o["fetched"] as string,
151
+ title,
152
+ hits: hits as DblpHit[],
153
+ });
154
+ }
155
+
156
+ /** The cache in `text`, or why it cannot be read. */
157
+ export function parseLookupCache(text: string): Result<LookupCache, string> {
158
+ let json: unknown;
159
+ try {
160
+ json = JSON.parse(text);
161
+ } catch (e) {
162
+ return err(`not JSON: ${(e as Error).message}`);
163
+ }
164
+ if (!isObject(json))
165
+ return err("expected an object with `schema`, `citations` and `dblp`");
166
+ if (json["schema"] !== LOOKUP_CACHE_SCHEMA)
167
+ return err(
168
+ `schema ${String(json["schema"])}: this paperlint reads schema ${String(LOOKUP_CACHE_SCHEMA)}`,
169
+ );
170
+ const extra = unknownField(json, ["schema", "citations", "dblp"]);
171
+ if (extra !== undefined) return err(`unknown field \`${extra}\``);
172
+ const citations = sectionOf(
173
+ "citations",
174
+ json["citations"],
175
+ ["fetched", "response"],
176
+ cachedResponse,
177
+ );
178
+ if (!citations.ok) return citations;
179
+ const dblp = sectionOf(
180
+ "dblp",
181
+ json["dblp"],
182
+ ["fetched", "title", "hits"],
183
+ cachedDblp,
184
+ );
185
+ if (!dblp.ok) return dblp;
186
+ return ok({ citations: citations.value, dblp: dblp.value });
187
+ }
188
+
189
+ const sorted = <T>(m: ReadonlyMap<string, T>): Record<string, T> =>
190
+ Object.fromEntries([...m.keys()].sort().map((k) => [k, m.get(k) as T]));
191
+
192
+ /** The cache as the file holds it: keys sorted, so one new answer is a small diff. */
193
+ export const serializeLookupCache = (c: LookupCache): string =>
194
+ `${JSON.stringify(
195
+ {
196
+ schema: LOOKUP_CACHE_SCHEMA,
197
+ citations: sorted(c.citations),
198
+ dblp: sorted(c.dblp),
199
+ },
200
+ null,
201
+ 2,
202
+ )}\n`;
@@ -10,6 +10,10 @@ test("map and andThen run on ok and pass an error through untouched", () => {
10
10
  map(ok(2), (n) => n + 1),
11
11
  ok(3),
12
12
  );
13
+ assert.deepEqual(
14
+ map(half(3), () => assert.fail("mapped after an error")),
15
+ err("3 is odd"),
16
+ );
13
17
  assert.deepEqual(andThen(ok(8), half), ok(4));
14
18
  // Guards: an error short-circuits — the function after it is not called.
15
19
  assert.deepEqual(
@@ -22,8 +22,3 @@ export const match = <T, E, R>(
22
22
  r: Result<T, E>,
23
23
  on: { readonly ok: (t: T) => R; readonly err: (e: E) => R },
24
24
  ): R => (r.ok ? on.ok(r.value) : on.err(r.error));
25
-
26
- /** The `default` of an exhaustive `switch`: a new variant is a compile error, not a silent fall-through. */
27
- export function assertNever(x: never): never {
28
- throw new Error(`unhandled variant: ${JSON.stringify(x)}`);
29
- }
@@ -7,7 +7,6 @@
7
7
  * `probeTree` through a fake runner.
8
8
  * All pure or port-driven: no TeX needed. The real-TeX half is `test/e2e/build.mjs`.
9
9
  */
10
- import assert from "node:assert/strict";
11
10
  import {
12
11
  chmodSync,
13
12
  mkdirSync,
@@ -19,6 +18,7 @@ import {
19
18
  import { tmpdir } from "node:os";
20
19
  import { dirname, join } from "node:path";
21
20
  import { fileURLToPath } from "node:url";
21
+ import { createChecker } from "../lib/check.mjs";
22
22
 
23
23
  const HERE = dirname(fileURLToPath(import.meta.url));
24
24
  const {
@@ -32,11 +32,7 @@ const {
32
32
  supportedPlatform,
33
33
  } = await import(join(HERE, "engine.ts"));
34
34
 
35
- let n = 0;
36
- const check = (label, cond, detail = "") => {
37
- assert.ok(cond, `${label}${detail ? ` — ${detail}` : ""}`);
38
- n++;
39
- };
35
+ const check = createChecker();
40
36
 
41
37
  // ── 1. resolveEngine ────────────────────────────────────────────────────────────────────
42
38
  const tree = (label, missing = []) => ({ label, bin: `/${label}`, missing });
@@ -301,4 +297,28 @@ check(
301
297
  ) === "[]",
302
298
  );
303
299
 
304
- console.log(`engine: ${n} checks passed`);
300
+ check(
301
+ "missingDependencies: a tlmgr that cannot start reports nothing (the tree is judged by probeTree alone)",
302
+ JSON.stringify(
303
+ missingDependencies("/tl/bin", () => ({ error: new Error("ENOENT") })),
304
+ ) === "[]",
305
+ );
306
+ check(
307
+ "missingDependencies: a section that runs to the end of the output, with no header after it",
308
+ JSON.stringify(
309
+ missingDependencies("/tl/bin", () => ({
310
+ stdout:
311
+ "\f DEPENDS WITHOUT PACKAGES:\nunicode-data in: latex-bin\nhyphen-base in: x",
312
+ status: 2,
313
+ })),
314
+ ) === '["hyphen-base","unicode-data"]',
315
+ );
316
+ check(
317
+ "a runner that returns no stdout at all: tlmgr lists nothing, kpsewhich finds nothing",
318
+ JSON.stringify(missingDependencies("/tl/bin", () => ({ status: 0 }))) ===
319
+ "[]" &&
320
+ JSON.stringify(probeTree("/tl/bin", PKGS, () => ({ status: 0 }))) ===
321
+ JSON.stringify(Object.keys(PKGS).sort()),
322
+ );
323
+
324
+ console.log(`engine: ${check.count} checks passed`);
@@ -18,6 +18,7 @@ import {
18
18
  factsDocument,
19
19
  factsPath,
20
20
  measurePaper,
21
+ parseFactsText,
21
22
  writeFactsFile,
22
23
  type MeasureOptions,
23
24
  } from "./facts-file.ts";
@@ -278,3 +279,112 @@ test("declaredVenue: a paperlint.json with an unknown key is refused, naming the
278
279
  });
279
280
  assert.throws(() => declaredVenue(files, PAPER), /unknown key "venu"/);
280
281
  });
282
+
283
+ test("a PDF with no text has an empty font list, not a failed document", () => {
284
+ const d = factsDocument({
285
+ pdf: "paper.pdf",
286
+ sha: "a".repeat(64),
287
+ venue: null,
288
+ kind: null,
289
+ read: {
290
+ pages: 1,
291
+ fonts: { kind: "no-text" },
292
+ last: lastPage(0, 0),
293
+ layout: [],
294
+ },
295
+ geometry: NONE,
296
+ });
297
+ assert.deepEqual(d.fonts, []);
298
+ });
299
+
300
+ test("measure: a PDF gone between pdf.js's read and the hash is an error, not a hash of nothing", async () => {
301
+ const { o, files } = setup();
302
+ files.map.delete(PDF);
303
+ const m = await measurePaper(PAPER, PDF, o);
304
+ assert.equal(!m.ok && m.error, `${PDF}: gone after pdf.js read it`);
305
+ });
306
+
307
+ test("measure: no paperlint.json and no override — venue and kind are null", async () => {
308
+ const { o, files } = setup();
309
+ files.map.delete(`${PAPER}/paperlint.json`);
310
+ const m = await measurePaper(PAPER, PDF, o);
311
+ assert.deepEqual(m.ok && [m.value.facts.venue, m.value.facts.kind], [
312
+ null,
313
+ null,
314
+ ]);
315
+ });
316
+
317
+ // ── reading the file back: every refusal names what is wrong ────────────────────────────
318
+ const written = (geometry: Geometry = MEASURED) => ({
319
+ ...doc(lastPage(60, 30), geometry),
320
+ });
321
+ const parse = (d: unknown) => parseFactsText(JSON.stringify(d));
322
+ const why = (d: unknown): string => {
323
+ const r = parse(d);
324
+ return !r.ok && r.error.kind === "broken" ? r.error.why : "";
325
+ };
326
+
327
+ test("parseFactsText: a written document reads back, with and without geometry", () => {
328
+ const measured = parse(written());
329
+ assert.ok(measured.ok);
330
+ assert.equal(measured.value.geometry?.columns, 2);
331
+ const none = parse(written(NONE));
332
+ assert.equal(none.ok && none.value.geometry, null);
333
+ });
334
+
335
+ test("parseFactsText: every malformed shape is refused with its reason", () => {
336
+ assert.equal(why([1]), "not a JSON object");
337
+ assert.equal(why({ ...written(), pdf: "" }), "no `pdf` path");
338
+ assert.equal(why({ ...written(), pdf: 7 }), "no `pdf` path");
339
+ assert.equal(
340
+ why({ ...written(), pdf_sha256: "xyz" }),
341
+ "no `pdf_sha256` of 64 hex digits",
342
+ );
343
+ assert.equal(
344
+ why({ ...written(), fonts: [{ name: "x" }] }),
345
+ "`fonts` is not a list of { name, type, embedded, program }",
346
+ );
347
+ assert.equal(
348
+ why({ ...written(), fonts: ["Times"] }),
349
+ "`fonts` is not a list of { name, type, embedded, program }",
350
+ );
351
+ assert.equal(
352
+ why({ ...written(), geometry_source: 3 }),
353
+ "`geometry_source` is neither a measurer's name nor null",
354
+ );
355
+ assert.equal(
356
+ why({ ...written(), columns: "two" }),
357
+ "`columns` is not a number",
358
+ );
359
+ assert.equal(
360
+ why({ ...written(), ref_pages: null }),
361
+ "`ref_pages` is not a number",
362
+ );
363
+ assert.equal(
364
+ why({ ...written(), pages_by_type: { body: "3" } }),
365
+ "`pages_by_type` is not an object of page counts",
366
+ );
367
+ assert.equal(
368
+ why({ ...written(), pages_by_type: [3] }),
369
+ "`pages_by_type` is not an object of page counts",
370
+ );
371
+ assert.equal(
372
+ why({ ...written(), fonts: "Times" }),
373
+ "`fonts` is not a list of { name, type, embedded, program }",
374
+ );
375
+ const notJson = parseFactsText("{");
376
+ assert.match(
377
+ !notJson.ok && notJson.error.kind === "broken" ? notJson.error.why : "",
378
+ /^not JSON \(/,
379
+ );
380
+ });
381
+
382
+ test("parseFactsText: a missing schema is reported as null, another schema by its value", () => {
383
+ const missing = parse({ ...written(), schema: undefined });
384
+ assert.deepEqual(!missing.ok && missing.error, {
385
+ kind: "schema",
386
+ got: "null",
387
+ });
388
+ const other = parse({ ...written(), schema: 1 });
389
+ assert.deepEqual(!other.ok && other.error, { kind: "schema", got: "1" });
390
+ });
@@ -15,8 +15,6 @@
15
15
  * ⚠️ Assertions at the TOP LEVEL: `vigiles test` imports the file and counts "did not throw"
16
16
  * as a pass.
17
17
  */
18
- import assert from "node:assert/strict";
19
- import { recordCheck } from "vigiles";
20
18
  import {
21
19
  existsSync,
22
20
  mkdirSync,
@@ -29,6 +27,7 @@ import {
29
27
  import { tmpdir } from "node:os";
30
28
  import { dirname, join } from "node:path";
31
29
  import { fileURLToPath } from "node:url";
30
+ import { createChecker } from "../lib/check.mjs";
32
31
 
33
32
  const HERE = dirname(fileURLToPath(import.meta.url));
34
33
  const {
@@ -44,12 +43,7 @@ const {
44
43
  const { claudeCodeHookProtocol } = await import("vigiles/claude-code");
45
44
  const merge = (e, c, m) => claudeCodeHookProtocol.mergeRegistrations(e, c, m);
46
45
 
47
- let n = 0;
48
- const check = (label, cond) => {
49
- n++;
50
- assert.ok(cond, label);
51
- recordCheck(label);
52
- };
46
+ const check = createChecker();
53
47
 
54
48
  // ── the contract with vigiles, checked before anything relies on it ──────────────────────
55
49
  check(
@@ -64,6 +58,40 @@ check(
64
58
  wiring.names.join() ===
65
59
  "paper-edit-guard,paper-skills-nudge,paper-status-gates",
66
60
  );
61
+ // ── settings written by hand: shapes Claude Code would ignore are skipped, not crashed on ──
62
+ check(
63
+ "an event that is not a list, an entry with no `hooks` list, a handler with no command → no commands",
64
+ JSON.stringify(
65
+ wiredCounts(
66
+ {
67
+ hooks: {
68
+ PreToolUse: "npx paperlint hook paper-edit-guard",
69
+ PostToolUse: [{ matcher: "Edit" }, null, { hooks: "x" }],
70
+ Stop: [{ hooks: [{ type: "command" }, null] }],
71
+ },
72
+ },
73
+ wiring.names,
74
+ ),
75
+ ) ===
76
+ JSON.stringify(wiring.names.map((name) => ({ name, ours: 0, other: 0 }))),
77
+ );
78
+ {
79
+ const file = join(tmpdir(), `paperlint-no-hooks-${String(process.pid)}.json`);
80
+ writeFileSync(file, "{}\n");
81
+ let thrown = "";
82
+ try {
83
+ shippedWiring(file);
84
+ } catch (e) {
85
+ thrown = e.message;
86
+ }
87
+ rmSync(file);
88
+ check(
89
+ "a wiring file without `hooks` names no hook — and THROWS instead of wiring nothing",
90
+ thrown === `${file} names no paperlint hook — there is nothing to wire`,
91
+ thrown,
92
+ );
93
+ }
94
+
67
95
  const work = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-hooks-")));
68
96
  try {
69
97
  const empty = join(work, "empty-hooks.json");
@@ -135,7 +163,7 @@ try {
135
163
  check(
136
164
  "no settings file → it is created with the three hooks",
137
165
  r.status === "written" &&
138
- [...wiredCounts(s, wiring.names).values()].every(
166
+ wiredCounts(s, wiring.names).every(
139
167
  (c) => c.ours === 1 && c.other === 0,
140
168
  ),
141
169
  );
@@ -172,7 +200,7 @@ try {
172
200
  );
173
201
  check(
174
202
  "and ours are there, once each",
175
- [...wiredCounts(s, wiring.names).values()].every((c) => c.ours === 1),
203
+ wiredCounts(s, wiring.names).every((c) => c.ours === 1),
176
204
  );
177
205
  }
178
206
 
@@ -224,7 +252,9 @@ try {
224
252
  // The control: without the check, vigiles' merge WOULD add a second copy. This is the
225
253
  // reason the check exists, measured rather than asserted.
226
254
  const merged = merge(handWired, wiring.compiled, MANAGED_BY);
227
- const c = wiredCounts(merged, wiring.names).get("paper-edit-guard");
255
+ const c = wiredCounts(merged, wiring.names).find(
256
+ (c) => c.name === "paper-edit-guard",
257
+ );
228
258
  check(
229
259
  "(control) vigiles' merge alone would wire paper-edit-guard TWICE here",
230
260
  c.ours === 1 && c.other === 1,
@@ -247,7 +277,7 @@ try {
247
277
 
248
278
  // ── doctor's section ───────────────────────────────────────────────────────────────────
249
279
  const doc = (settings) => {
250
- const dir = project(`doc-${String(n)}`, settings);
280
+ const dir = project(`doc-${String(check.count)}`, settings);
251
281
  return doctorHooks(dir, wiring).join("\n");
252
282
  };
253
283
  const wiredOnce = merge({}, wiring.compiled, MANAGED_BY);
@@ -306,5 +336,5 @@ try {
306
336
  }
307
337
 
308
338
  console.log(
309
- `✓ ${String(n)} assertions passed — the hooks land in .claude/settings.json once`,
339
+ `✓ ${String(check.count)} assertions passed — the hooks land in .claude/settings.json once`,
310
340
  );