paperlint 3.1.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 (284) 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/bin/paperlint.test.mjs +45 -0
  6. package/dist/adapters/node/files.io.d.ts +2 -0
  7. package/dist/adapters/node/files.io.d.ts.map +1 -1
  8. package/dist/adapters/node/files.io.js +3 -2
  9. package/dist/adapters/node/files.io.js.map +1 -1
  10. package/dist/adapters/pdfjs/fill.d.ts +15 -4
  11. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  12. package/dist/adapters/pdfjs/fill.js +31 -21
  13. package/dist/adapters/pdfjs/fill.js.map +1 -1
  14. package/dist/adapters/references/index.d.ts.map +1 -1
  15. package/dist/adapters/references/index.js +14 -5
  16. package/dist/adapters/references/index.js.map +1 -1
  17. package/dist/adapters/references/pool.d.ts +8 -0
  18. package/dist/adapters/references/pool.d.ts.map +1 -0
  19. package/dist/adapters/references/pool.js +19 -0
  20. package/dist/adapters/references/pool.js.map +1 -0
  21. package/dist/build.d.ts +10 -2
  22. package/dist/build.d.ts.map +1 -1
  23. package/dist/build.js +7 -8
  24. package/dist/build.js.map +1 -1
  25. package/dist/cli.d.ts +27 -1
  26. package/dist/cli.d.ts.map +1 -1
  27. package/dist/cli.js +71 -55
  28. package/dist/cli.js.map +1 -1
  29. package/dist/doctor.d.ts +27 -1
  30. package/dist/doctor.d.ts.map +1 -1
  31. package/dist/doctor.js +4 -3
  32. package/dist/doctor.js.map +1 -1
  33. package/dist/domain/result.d.ts +0 -2
  34. package/dist/domain/result.d.ts.map +1 -1
  35. package/dist/domain/result.js +0 -4
  36. package/dist/domain/result.js.map +1 -1
  37. package/dist/hooks-settings.d.ts +8 -5
  38. package/dist/hooks-settings.d.ts.map +1 -1
  39. package/dist/hooks-settings.js +32 -32
  40. package/dist/hooks-settings.js.map +1 -1
  41. package/dist/init.d.ts +8 -0
  42. package/dist/init.d.ts.map +1 -1
  43. package/dist/init.js +3 -3
  44. package/dist/init.js.map +1 -1
  45. package/dist/latex-log.d.ts +0 -2
  46. package/dist/latex-log.d.ts.map +1 -1
  47. package/dist/latex-log.js +7 -13
  48. package/dist/latex-log.js.map +1 -1
  49. package/dist/link-skills.d.ts +15 -2
  50. package/dist/link-skills.d.ts.map +1 -1
  51. package/dist/link-skills.js +36 -20
  52. package/dist/link-skills.js.map +1 -1
  53. package/dist/paper-files.d.ts.map +1 -1
  54. package/dist/paper-files.js +6 -4
  55. package/dist/paper-files.js.map +1 -1
  56. package/dist/pdf-facts.d.ts +11 -0
  57. package/dist/pdf-facts.d.ts.map +1 -1
  58. package/dist/pdf-facts.js +16 -18
  59. package/dist/pdf-facts.js.map +1 -1
  60. package/dist/pdf-geometry.js +1 -1
  61. package/dist/pdf-geometry.js.map +1 -1
  62. package/dist/presets.d.ts +2 -0
  63. package/dist/presets.d.ts.map +1 -1
  64. package/dist/presets.js +3 -1
  65. package/dist/presets.js.map +1 -1
  66. package/dist/references.js +2 -2
  67. package/dist/references.js.map +1 -1
  68. package/dist/tex-requirements.d.ts +8 -0
  69. package/dist/tex-requirements.d.ts.map +1 -1
  70. package/dist/tex-requirements.js +6 -5
  71. package/dist/tex-requirements.js.map +1 -1
  72. package/dist/toolchain.d.ts +3 -2
  73. package/dist/toolchain.d.ts.map +1 -1
  74. package/dist/toolchain.js +4 -5
  75. package/dist/toolchain.js.map +1 -1
  76. package/dist/venue-rules.d.ts.map +1 -1
  77. package/dist/venue-rules.js +11 -9
  78. package/dist/venue-rules.js.map +1 -1
  79. package/docs/prior-art/README.md +1 -1
  80. package/docs/prior-art/test-tooling.md +7 -30
  81. package/eslint-rules/install-path-literals.harness.mjs +17 -1
  82. package/eslint-rules/latex-language.harness.mjs +3 -3
  83. package/eslint-rules/latex-language.mjs +12 -9
  84. package/eslint-rules/latex-language.test.mjs +200 -0
  85. package/eslint-rules/paper-research-question.harness.mjs +73 -7
  86. package/eslint-rules/paper-stages.harness.mjs +9 -14
  87. package/eslint-rules/paper-stages.test.mjs +135 -0
  88. package/eslint-rules/paper-typography.mjs +9 -2
  89. package/eslint-rules/paper-typography.test.mjs +146 -1
  90. package/eslint-rules/pdf-last-page-balance.harness.mjs +63 -7
  91. package/eslint-rules/review-frontmatter.mjs +2 -1
  92. package/eslint-rules/review-frontmatter.test.mjs +37 -1
  93. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  94. package/eslint-rules/tex-build.mjs +13 -13
  95. package/eslint.config.mjs +6 -2
  96. package/hooks/hooks.harness.mjs +6 -11
  97. package/hooks/paper-edit-guard.hook.mjs +2 -2
  98. package/hooks/paper-edit-guard.test.mjs +61 -0
  99. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  100. package/hooks/paper-skills-nudge.test.mjs +38 -0
  101. package/hooks/paper-status-gates.test.mjs +42 -0
  102. package/lib/agent-cli-version.harness.mjs +7 -0
  103. package/lib/check.d.mts +17 -0
  104. package/lib/check.mjs +50 -0
  105. package/lib/check.test.ts +67 -0
  106. package/lib/markdown.mjs +16 -69
  107. package/lib/markdown.test.mjs +246 -0
  108. package/lib/paper-config.harness.mjs +28 -9
  109. package/lib/paper-config.mjs +7 -2
  110. package/lib/skill-checks.mjs +18 -46
  111. package/lib/skill-checks.test.mjs +297 -0
  112. package/lib/skill-corpus.mjs +11 -13
  113. package/lib/skill-corpus.test.mjs +54 -0
  114. package/lib/skill-trigger-cases.harness.mjs +57 -4
  115. package/package.json +6 -5
  116. package/scripts/check.harness.mjs +4 -10
  117. package/scripts/check.mjs +45 -42
  118. package/scripts/check.test.mjs +104 -0
  119. package/scripts/coverage-config.test.ts +406 -0
  120. package/scripts/eslint-report-guard.test.mjs +48 -0
  121. package/scripts/harness-api.frozen.json +0 -2
  122. package/scripts/layer-legacy-frozen.d.mts +9 -1
  123. package/scripts/layer-legacy-frozen.mjs +28 -11
  124. package/scripts/layer-legacy-frozen.test.ts +143 -1
  125. package/scripts/rules-are-content-only.harness.mjs +3 -11
  126. package/scripts/rules-are-content-only.mjs +29 -19
  127. package/scripts/rules-are-content-only.test.mjs +61 -0
  128. package/scripts/rules-see-files.mjs +11 -9
  129. package/scripts/rules-see-files.test.mjs +71 -0
  130. package/scripts/test.mjs +44 -0
  131. package/scripts/test.test.mjs +52 -0
  132. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  133. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  134. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  135. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  136. package/skills/paper-pipeline/scripts/README.md +13 -132
  137. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  138. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  139. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  140. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  141. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  142. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  144. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  145. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  146. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  147. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  148. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  149. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  150. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  151. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
  152. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  153. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  154. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  155. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  156. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  157. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  158. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  159. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  160. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  161. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  162. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  163. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  164. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  165. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  166. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  167. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  168. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  169. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  170. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  171. package/skills/tighten-paper/structure.test.mjs +116 -0
  172. package/skills/verify-citations/SKILL.md +5 -5
  173. package/skills/verify-citations/SKILL.md.spec.ts +4 -4
  174. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +235 -0
  175. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  176. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  177. package/skills/verify-citations/scripts/bib-authors.mjs +2 -1
  178. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  179. package/skills/verify-citations/scripts/verify-cites.mjs +96 -96
  180. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +287 -0
  181. package/skills/verify-citations/scripts/verify-cites.test.mjs +149 -5
  182. package/src/adapters/banal/failure.test.ts +70 -1
  183. package/src/adapters/banal/index.test.ts +16 -0
  184. package/src/adapters/banal/output.test.ts +5 -0
  185. package/src/adapters/curl/download.test.ts +43 -1
  186. package/src/adapters/memory/index.test.ts +25 -0
  187. package/src/adapters/node/files.io.ts +3 -2
  188. package/src/adapters/node/files.test.ts +13 -1
  189. package/src/adapters/node/process.test.ts +38 -0
  190. package/src/adapters/pdfjs/fill.test.ts +34 -0
  191. package/src/adapters/pdfjs/fill.ts +44 -25
  192. package/src/adapters/references/index.test.ts +228 -0
  193. package/src/adapters/references/index.ts +21 -6
  194. package/src/adapters/references/pool.test.ts +31 -0
  195. package/src/adapters/references/pool.ts +24 -0
  196. package/src/build-engine.harness.mjs +3 -7
  197. package/src/build-engine.test.ts +137 -0
  198. package/src/build.harness.mjs +5 -9
  199. package/src/build.test.ts +362 -0
  200. package/src/build.ts +13 -9
  201. package/src/cli.harness.mjs +10 -17
  202. package/src/cli.test.ts +584 -0
  203. package/src/cli.ts +112 -66
  204. package/src/doctor.harness.mjs +3 -8
  205. package/src/doctor.test.ts +64 -0
  206. package/src/doctor.ts +12 -4
  207. package/src/domain/result.test.ts +4 -0
  208. package/src/domain/result.ts +0 -5
  209. package/src/engine.harness.mjs +27 -7
  210. package/src/facts-file.test.ts +110 -0
  211. package/src/hooks-settings.harness.mjs +43 -13
  212. package/src/hooks-settings.ts +41 -35
  213. package/src/init.test.ts +308 -5
  214. package/src/init.ts +3 -3
  215. package/src/latex-log.harness.mjs +32 -7
  216. package/src/latex-log.ts +6 -13
  217. package/src/latex-loop.harness.mjs +8 -7
  218. package/src/link-skills.harness.mjs +3 -8
  219. package/src/link-skills.test.ts +105 -0
  220. package/src/link-skills.ts +75 -21
  221. package/src/new-paper.harness.mjs +3 -7
  222. package/src/new-paper.test.ts +31 -1
  223. package/src/paper-files.test.ts +12 -0
  224. package/src/paper-files.ts +9 -5
  225. package/src/paper-settings.test.ts +20 -0
  226. package/src/pdf-facts.harness.mjs +3 -6
  227. package/src/pdf-facts.test.ts +144 -0
  228. package/src/pdf-facts.ts +17 -19
  229. package/src/pdf-geometry.harness.mjs +3 -7
  230. package/src/pdf-geometry.ts +1 -1
  231. package/src/presets.test.ts +2 -0
  232. package/src/presets.ts +7 -1
  233. package/src/references.test.ts +97 -0
  234. package/src/references.ts +2 -2
  235. package/src/rules-config.harness.mjs +10 -8
  236. package/src/structure.harness.mjs +3 -7
  237. package/src/tex-requirements.harness.mjs +34 -7
  238. package/src/tex-requirements.ts +12 -6
  239. package/src/toolchain.harness.mjs +3 -7
  240. package/src/toolchain.test.ts +280 -0
  241. package/src/toolchain.ts +8 -7
  242. package/src/venue-rules.test.ts +60 -0
  243. package/src/venue-rules.ts +13 -11
  244. package/action.mutations.mjs +0 -162
  245. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  246. package/eslint-rules/latex-language.mutations.mjs +0 -196
  247. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  248. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  249. package/eslint-rules/papers.mutations.mjs +0 -186
  250. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  251. package/eslint-rules/tex-build.mutations.mjs +0 -258
  252. package/hooks/hooks.mutations.mjs +0 -337
  253. package/lib/agent-cli-version.mutations.mjs +0 -109
  254. package/lib/mutation-driver.harness.mjs +0 -227
  255. package/lib/mutation-driver.mjs +0 -397
  256. package/lib/mutation-driver.mutations.mjs +0 -68
  257. package/lib/paper-config.mutations.mjs +0 -143
  258. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  259. package/scripts/check.mutations.mjs +0 -110
  260. package/scripts/exclusive.mjs +0 -138
  261. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  262. package/scripts/mutation-batteries-frozen.mjs +0 -238
  263. package/scripts/mutation-batteries.frozen.json +0 -108
  264. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  265. package/scripts/rules-see-files.mutations.mjs +0 -131
  266. package/scripts/run-mutations.mjs +0 -100
  267. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  268. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  269. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  270. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  271. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  272. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  273. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  274. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  275. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  276. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  277. package/skills/skill-contract.mutations.mjs +0 -179
  278. package/src/build.mutations.mjs +0 -196
  279. package/src/cli.mutations.mjs +0 -414
  280. package/src/doctor.mutations.mjs +0 -175
  281. package/src/hooks-settings.mutations.mjs +0 -99
  282. package/src/link-skills.mutations.mjs +0 -140
  283. package/src/new-paper.mutations.mjs +0 -65
  284. package/src/structure.mutations.mjs +0 -83
@@ -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
  );
@@ -93,8 +93,8 @@ interface Run {
93
93
  */
94
94
  function parseRun(command: string): Run | null {
95
95
  const tokens = command.trim().split(/\s+/).map(bare);
96
- for (let i = 0; i < tokens.length; i++) {
97
- const run = runAt(tokens, i);
96
+ for (const [i, token] of tokens.entries()) {
97
+ const run = runAt(token, tokens.slice(i + 1, i + 3));
98
98
  if (run) return run;
99
99
  }
100
100
  return null;
@@ -108,17 +108,13 @@ function insidePackage(parts: readonly string[]): readonly string[] {
108
108
  return at === -1 ? [] : parts.slice(at + 2);
109
109
  }
110
110
 
111
- /** The run token `i` starts, if it names a paperlint hook. */
112
- function runAt(tokens: readonly string[], i: number): Run | null {
113
- const t = posix.normalize(tokens[i] ?? "");
111
+ /** The run `token` starts, if it names a paperlint hook; `after` is the two tokens that follow it. */
112
+ function runAt(token: string, after: readonly string[]): Run | null {
113
+ const t = posix.normalize(token);
114
114
  const inside = insidePackage(t.split("/"));
115
115
  const inBin = inside[0] === "bin" && inside.length === 2;
116
- const name = tokens[i + 2];
117
- if (
118
- (inBin || basename(t) === PACKAGE_NAME) &&
119
- tokens[i + 1] === "hook" &&
120
- name
121
- )
116
+ const [verb, name] = after;
117
+ if ((inBin || basename(t) === PACKAGE_NAME) && verb === "hook" && name)
122
118
  return { name, ours: t === MANAGED_BY, path: t };
123
119
  if (inside[0] === "hooks" && inside[1]?.endsWith(".hook.mjs"))
124
120
  return { name: basename(inside[1], ".hook.mjs"), ours: false, path: t };
@@ -171,22 +167,33 @@ export function shippedWiring(file: string = WIRING_FILE): Wiring {
171
167
  return { compiled, names: [...new Set(names)] };
172
168
  }
173
169
 
174
- /** How many commands run each shipped hook, split by spelling. */
170
+ /** How many commands run one shipped hook, split by spelling. */
171
+ export interface HookCount {
172
+ readonly name: string;
173
+ readonly ours: number;
174
+ readonly other: number;
175
+ }
176
+
177
+ /** One count per name in `names`, in that order — a name nothing runs has zeros, never a gap. */
175
178
  export function wiredCounts(
176
179
  settings: Settings,
177
180
  names: readonly string[],
178
- ): Map<string, { ours: number; other: number }> {
179
- const counts = new Map(names.map((n) => [n, { ours: 0, other: 0 }]));
180
- for (const { command } of commandsIn(settings)) {
181
+ ): readonly HookCount[] {
182
+ const runs = commandsIn(settings).flatMap(({ command }) => {
181
183
  const run = hookRun(command);
182
- const slot = run ? counts.get(run.name) : undefined;
183
- if (run && slot)
184
- if (run.ours) slot.ours++;
185
- else slot.other++;
186
- }
187
- return counts;
184
+ return run ? [run] : [];
185
+ });
186
+ const count = (name: string, ours: boolean): number =>
187
+ runs.filter((r) => r.name === name && r.ours === ours).length;
188
+ return names.map((name) => ({
189
+ name,
190
+ ours: count(name, true),
191
+ other: count(name, false),
192
+ }));
188
193
  }
189
194
 
195
+ const total = (c: HookCount): number => c.ours + c.other;
196
+
190
197
  export type SettingsRead =
191
198
  | { readonly status: "absent"; readonly path: string; readonly settings: {} }
192
199
  | {
@@ -258,15 +265,16 @@ export function wireHooks(
258
265
  const read = readSettings(root);
259
266
  if (read.status === "unparsable") return read;
260
267
  const { path, settings } = read;
261
- const found = commandsIn(settings)
262
- .map(({ command }) => ({ command, run: hookRun(command) }))
263
- .filter(({ run }) => run && !run.ours && wiring.names.includes(run.name))
264
- .map(({ command, run }) => ({ name: run?.name ?? "", command }));
268
+ const found = commandsIn(settings).flatMap(({ command }) => {
269
+ const run = hookRun(command);
270
+ return run && !run.ours && wiring.names.includes(run.name)
271
+ ? [{ name: run.name, command }]
272
+ : [];
273
+ });
265
274
  if (found.length > 0) {
266
- const counts = wiredCounts(settings, wiring.names);
267
- const missing = wiring.names.filter(
268
- (n) => (counts.get(n)?.ours ?? 0) + (counts.get(n)?.other ?? 0) === 0,
269
- );
275
+ const missing = wiredCounts(settings, wiring.names)
276
+ .filter((c) => total(c) === 0)
277
+ .map((c) => c.name);
270
278
  return { status: "foreign", path, names: wiring.names, found, missing };
271
279
  }
272
280
 
@@ -297,19 +305,17 @@ export function doctorHooks(
297
305
  return out;
298
306
  }
299
307
  const counts = wiredCounts(read.settings, wiring.names);
300
- const total = (n: string): number =>
301
- (counts.get(n)?.ours ?? 0) + (counts.get(n)?.other ?? 0);
302
- const missing = wiring.names.filter((n) => total(n) === 0);
303
- const twice = wiring.names.filter((n) => total(n) > 1);
308
+ const missing = counts.filter((c) => total(c) === 0).map((c) => c.name);
309
+ const twice = counts.filter((c) => total(c) > 1);
304
310
  // `init` refuses to write while any hook is wired under another spelling, so its remedy must
305
311
  // not be offered then — the remedy is to pick ONE form.
306
- const handWired = wiring.names.some((n) => (counts.get(n)?.other ?? 0) > 0);
312
+ const handWired = counts.some((c) => c.other > 0);
307
313
  const remedy = handWired
308
314
  ? `some paperlint hooks are wired by hand under another spelling, and \`npx paperlint init\` writes nothing then — add the missing ones in that same form, or delete the hand-written ones and run \`npx paperlint init\``
309
315
  : `\`npx paperlint init\` adds them`;
310
316
  if (twice.length > 0) {
311
317
  out.push(` ⚠ wired TWICE — each of these runs more than once per event:`);
312
- for (const n of twice) out.push(` ${n} ×${String(total(n))}`);
318
+ for (const c of twice) out.push(` ${c.name} ×${String(total(c))}`);
313
319
  out.push(
314
320
  ` keep one command per hook; \`npx paperlint init\` writes the ${MANAGED_BY} form`,
315
321
  );