paperlint 3.0.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/.github/workflows/ci.yml +28 -41
  2. package/.github/workflows/dependabot-automerge.yml +1 -1
  3. package/CLAUDE.md +51 -56
  4. package/CONTRIBUTING.md +30 -18
  5. package/README.md +179 -180
  6. package/bin/paperlint.test.mjs +45 -0
  7. package/dist/adapters/node/files.io.d.ts +2 -0
  8. package/dist/adapters/node/files.io.d.ts.map +1 -1
  9. package/dist/adapters/node/files.io.js +3 -2
  10. package/dist/adapters/node/files.io.js.map +1 -1
  11. package/dist/adapters/pdfjs/fill.d.ts +15 -4
  12. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  13. package/dist/adapters/pdfjs/fill.js +31 -21
  14. package/dist/adapters/pdfjs/fill.js.map +1 -1
  15. package/dist/adapters/references/index.d.ts.map +1 -1
  16. package/dist/adapters/references/index.js +14 -5
  17. package/dist/adapters/references/index.js.map +1 -1
  18. package/dist/adapters/references/pool.d.ts +8 -0
  19. package/dist/adapters/references/pool.d.ts.map +1 -0
  20. package/dist/adapters/references/pool.js +19 -0
  21. package/dist/adapters/references/pool.js.map +1 -0
  22. package/dist/build-engine.d.ts.map +1 -1
  23. package/dist/build-engine.js +6 -2
  24. package/dist/build-engine.js.map +1 -1
  25. package/dist/build.d.ts +10 -2
  26. package/dist/build.d.ts.map +1 -1
  27. package/dist/build.js +29 -11
  28. package/dist/build.js.map +1 -1
  29. package/dist/cli.d.ts +93 -6
  30. package/dist/cli.d.ts.map +1 -1
  31. package/dist/cli.js +296 -80
  32. package/dist/cli.js.map +1 -1
  33. package/dist/doctor.d.ts +27 -1
  34. package/dist/doctor.d.ts.map +1 -1
  35. package/dist/doctor.js +4 -3
  36. package/dist/doctor.js.map +1 -1
  37. package/dist/domain/result.d.ts +0 -2
  38. package/dist/domain/result.d.ts.map +1 -1
  39. package/dist/domain/result.js +0 -4
  40. package/dist/domain/result.js.map +1 -1
  41. package/dist/engine.d.ts +9 -0
  42. package/dist/engine.d.ts.map +1 -1
  43. package/dist/engine.js +27 -0
  44. package/dist/engine.js.map +1 -1
  45. package/dist/hooks-settings.d.ts +8 -5
  46. package/dist/hooks-settings.d.ts.map +1 -1
  47. package/dist/hooks-settings.js +32 -32
  48. package/dist/hooks-settings.js.map +1 -1
  49. package/dist/init.d.ts +8 -0
  50. package/dist/init.d.ts.map +1 -1
  51. package/dist/init.js +4 -4
  52. package/dist/init.js.map +1 -1
  53. package/dist/latex-log.d.ts +0 -2
  54. package/dist/latex-log.d.ts.map +1 -1
  55. package/dist/latex-log.js +7 -13
  56. package/dist/latex-log.js.map +1 -1
  57. package/dist/link-skills.d.ts +15 -2
  58. package/dist/link-skills.d.ts.map +1 -1
  59. package/dist/link-skills.js +36 -20
  60. package/dist/link-skills.js.map +1 -1
  61. package/dist/new-paper.d.ts +8 -1
  62. package/dist/new-paper.d.ts.map +1 -1
  63. package/dist/new-paper.js +50 -15
  64. package/dist/new-paper.js.map +1 -1
  65. package/dist/paper-files.d.ts +30 -8
  66. package/dist/paper-files.d.ts.map +1 -1
  67. package/dist/paper-files.js +59 -30
  68. package/dist/paper-files.js.map +1 -1
  69. package/dist/pdf-facts.d.ts +11 -0
  70. package/dist/pdf-facts.d.ts.map +1 -1
  71. package/dist/pdf-facts.js +16 -18
  72. package/dist/pdf-facts.js.map +1 -1
  73. package/dist/pdf-geometry.js +1 -1
  74. package/dist/pdf-geometry.js.map +1 -1
  75. package/dist/presets.d.ts +2 -0
  76. package/dist/presets.d.ts.map +1 -1
  77. package/dist/presets.js +3 -1
  78. package/dist/presets.js.map +1 -1
  79. package/dist/references.js +2 -2
  80. package/dist/references.js.map +1 -1
  81. package/dist/structure.js +1 -1
  82. package/dist/structure.js.map +1 -1
  83. package/dist/tex-requirements.d.ts +8 -0
  84. package/dist/tex-requirements.d.ts.map +1 -1
  85. package/dist/tex-requirements.js +6 -5
  86. package/dist/tex-requirements.js.map +1 -1
  87. package/dist/toolchain.d.ts +8 -3
  88. package/dist/toolchain.d.ts.map +1 -1
  89. package/dist/toolchain.js +26 -10
  90. package/dist/toolchain.js.map +1 -1
  91. package/dist/types.d.ts +6 -0
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/venue-rules.d.ts.map +1 -1
  94. package/dist/venue-rules.js +12 -10
  95. package/dist/venue-rules.js.map +1 -1
  96. package/docs/configuration.md +48 -4
  97. package/docs/prior-art/README.md +1 -1
  98. package/docs/prior-art/test-tooling.md +7 -30
  99. package/docs/rules.md +29 -25
  100. package/docs/skills.md +80 -0
  101. package/eslint-rules/install-path-literals.harness.mjs +17 -1
  102. package/eslint-rules/latex-language.harness.mjs +3 -3
  103. package/eslint-rules/latex-language.mjs +12 -9
  104. package/eslint-rules/latex-language.test.mjs +200 -0
  105. package/eslint-rules/paper-research-question.harness.mjs +73 -7
  106. package/eslint-rules/paper-stages.harness.mjs +9 -14
  107. package/eslint-rules/paper-stages.test.mjs +135 -0
  108. package/eslint-rules/paper-typography.mjs +56 -9
  109. package/eslint-rules/paper-typography.test.mjs +186 -1
  110. package/eslint-rules/pdf-last-page-balance.harness.mjs +63 -7
  111. package/eslint-rules/review-frontmatter.mjs +2 -1
  112. package/eslint-rules/review-frontmatter.schema.json +1 -1
  113. package/eslint-rules/review-frontmatter.test.mjs +37 -1
  114. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  115. package/eslint-rules/tex-build.mjs +13 -13
  116. package/eslint.config.mjs +6 -2
  117. package/fixtures/toolchain-mirror/catalog.txt +1 -0
  118. package/fixtures/toolchain-mirror/install-tl +4 -1
  119. package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
  120. package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
  121. package/hooks/hooks.harness.mjs +6 -11
  122. package/hooks/paper-edit-guard.hook.mjs +2 -2
  123. package/hooks/paper-edit-guard.test.mjs +61 -0
  124. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  125. package/hooks/paper-skills-nudge.test.mjs +38 -0
  126. package/hooks/paper-status-gates.test.mjs +42 -0
  127. package/lib/agent-cli-version.harness.mjs +7 -0
  128. package/lib/check.d.mts +17 -0
  129. package/lib/check.mjs +50 -0
  130. package/lib/check.test.ts +67 -0
  131. package/lib/markdown.mjs +16 -69
  132. package/lib/markdown.test.mjs +246 -0
  133. package/lib/paper-config.harness.mjs +28 -9
  134. package/lib/paper-config.mjs +7 -2
  135. package/lib/skill-checks.mjs +18 -46
  136. package/lib/skill-checks.test.mjs +297 -0
  137. package/lib/skill-corpus.mjs +11 -13
  138. package/lib/skill-corpus.test.mjs +54 -0
  139. package/lib/skill-trigger-cases.harness.mjs +57 -4
  140. package/package.json +6 -5
  141. package/scripts/check.harness.mjs +4 -10
  142. package/scripts/check.mjs +45 -42
  143. package/scripts/check.test.mjs +104 -0
  144. package/scripts/coverage-config.test.ts +406 -0
  145. package/scripts/eslint-report-guard.test.mjs +48 -0
  146. package/scripts/harness-api.frozen.json +0 -2
  147. package/scripts/layer-legacy-frozen.d.mts +9 -1
  148. package/scripts/layer-legacy-frozen.mjs +28 -11
  149. package/scripts/layer-legacy-frozen.test.ts +143 -1
  150. package/scripts/rules-are-content-only.harness.mjs +3 -11
  151. package/scripts/rules-are-content-only.mjs +29 -19
  152. package/scripts/rules-are-content-only.test.mjs +61 -0
  153. package/scripts/rules-see-files.mjs +11 -9
  154. package/scripts/rules-see-files.test.mjs +71 -0
  155. package/scripts/test.mjs +44 -0
  156. package/scripts/test.test.mjs +52 -0
  157. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  158. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  159. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  160. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  161. package/skills/paper-pipeline/scripts/README.md +13 -132
  162. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  163. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  164. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  165. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  166. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  167. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  168. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  169. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  170. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  171. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  172. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  173. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  174. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  175. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  176. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
  177. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  178. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  179. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  180. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  181. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  182. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  183. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  184. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  185. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  186. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  187. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  188. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  189. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  190. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  191. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  192. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  193. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  194. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  195. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  196. package/skills/tighten-paper/structure.test.mjs +116 -0
  197. package/skills/verify-citations/SKILL.md +5 -5
  198. package/skills/verify-citations/SKILL.md.spec.ts +4 -4
  199. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +235 -0
  200. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  201. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  202. package/skills/verify-citations/scripts/bib-authors.mjs +2 -1
  203. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  204. package/skills/verify-citations/scripts/verify-cites.mjs +96 -96
  205. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +287 -0
  206. package/skills/verify-citations/scripts/verify-cites.test.mjs +149 -5
  207. package/src/adapters/banal/failure.test.ts +70 -1
  208. package/src/adapters/banal/index.test.ts +16 -0
  209. package/src/adapters/banal/output.test.ts +5 -0
  210. package/src/adapters/curl/download.test.ts +43 -1
  211. package/src/adapters/memory/index.test.ts +25 -0
  212. package/src/adapters/node/files.io.ts +3 -2
  213. package/src/adapters/node/files.test.ts +13 -1
  214. package/src/adapters/node/process.test.ts +38 -0
  215. package/src/adapters/pdfjs/fill.test.ts +34 -0
  216. package/src/adapters/pdfjs/fill.ts +44 -25
  217. package/src/adapters/references/index.test.ts +228 -0
  218. package/src/adapters/references/index.ts +21 -6
  219. package/src/adapters/references/pool.test.ts +31 -0
  220. package/src/adapters/references/pool.ts +24 -0
  221. package/src/build-engine.harness.mjs +3 -7
  222. package/src/build-engine.test.ts +137 -0
  223. package/src/build-engine.ts +6 -1
  224. package/src/build.harness.mjs +51 -11
  225. package/src/build.test.ts +362 -0
  226. package/src/build.ts +40 -14
  227. package/src/cli.harness.mjs +12 -19
  228. package/src/cli.test.ts +584 -0
  229. package/src/cli.ts +409 -94
  230. package/src/doctor.harness.mjs +3 -8
  231. package/src/doctor.test.ts +64 -0
  232. package/src/doctor.ts +12 -4
  233. package/src/domain/result.test.ts +4 -0
  234. package/src/domain/result.ts +0 -5
  235. package/src/engine.harness.mjs +77 -7
  236. package/src/engine.ts +35 -0
  237. package/src/facts-file.test.ts +110 -0
  238. package/src/hooks-settings.harness.mjs +43 -13
  239. package/src/hooks-settings.ts +41 -35
  240. package/src/init.test.ts +308 -5
  241. package/src/init.ts +4 -4
  242. package/src/latex-log.harness.mjs +32 -7
  243. package/src/latex-log.ts +6 -13
  244. package/src/latex-loop.harness.mjs +8 -7
  245. package/src/link-skills.harness.mjs +3 -8
  246. package/src/link-skills.test.ts +105 -0
  247. package/src/link-skills.ts +75 -21
  248. package/src/linted-set.test.ts +118 -0
  249. package/src/new-paper.harness.mjs +5 -7
  250. package/src/new-paper.test.ts +203 -2
  251. package/src/new-paper.ts +84 -17
  252. package/src/paper-files.test.ts +122 -5
  253. package/src/paper-files.ts +99 -39
  254. package/src/paper-settings-commands.test.ts +41 -5
  255. package/src/paper-settings.test.ts +20 -0
  256. package/src/parse-args.test.ts +48 -0
  257. package/src/pdf-facts.harness.mjs +3 -6
  258. package/src/pdf-facts.test.ts +144 -0
  259. package/src/pdf-facts.ts +17 -19
  260. package/src/pdf-geometry.harness.mjs +3 -7
  261. package/src/pdf-geometry.ts +1 -1
  262. package/src/presets.test.ts +2 -0
  263. package/src/presets.ts +7 -1
  264. package/src/references.test.ts +97 -0
  265. package/src/references.ts +2 -2
  266. package/src/rules-config.harness.mjs +10 -8
  267. package/src/rules-off.test.ts +111 -0
  268. package/src/structure.harness.mjs +5 -9
  269. package/src/structure.ts +1 -1
  270. package/src/tex-requirements.harness.mjs +34 -7
  271. package/src/tex-requirements.ts +12 -6
  272. package/src/toolchain.harness.mjs +44 -13
  273. package/src/toolchain.test.ts +280 -0
  274. package/src/toolchain.ts +37 -12
  275. package/src/types.ts +6 -0
  276. package/src/venue-rules.test.ts +63 -0
  277. package/src/venue-rules.ts +14 -12
  278. package/templates/paper/paperlint.json +1 -1
  279. package/action.mutations.mjs +0 -162
  280. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  281. package/eslint-rules/latex-language.mutations.mjs +0 -196
  282. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  283. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  284. package/eslint-rules/papers.mutations.mjs +0 -186
  285. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  286. package/eslint-rules/tex-build.mutations.mjs +0 -258
  287. package/hooks/hooks.mutations.mjs +0 -337
  288. package/lib/agent-cli-version.mutations.mjs +0 -109
  289. package/lib/mutation-driver.harness.mjs +0 -227
  290. package/lib/mutation-driver.mjs +0 -397
  291. package/lib/mutation-driver.mutations.mjs +0 -68
  292. package/lib/paper-config.mutations.mjs +0 -143
  293. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  294. package/scripts/check.mutations.mjs +0 -110
  295. package/scripts/exclusive.mjs +0 -138
  296. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  297. package/scripts/mutation-batteries-frozen.mjs +0 -238
  298. package/scripts/mutation-batteries.frozen.json +0 -108
  299. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  300. package/scripts/rules-see-files.mutations.mjs +0 -131
  301. package/scripts/run-mutations.mjs +0 -100
  302. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  303. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  304. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  305. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  306. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  307. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  308. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  309. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  310. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  311. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  312. package/skills/skill-contract.mutations.mjs +0 -179
  313. package/src/build.mutations.mjs +0 -196
  314. package/src/cli.mutations.mjs +0 -414
  315. package/src/doctor.mutations.mjs +0 -175
  316. package/src/hooks-settings.mutations.mjs +0 -99
  317. package/src/link-skills.mutations.mjs +0 -140
  318. package/src/new-paper.mutations.mjs +0 -79
  319. package/src/structure.mutations.mjs +0 -83
@@ -5,7 +5,7 @@
5
5
  import { describe, expect, it } from "vitest";
6
6
  import { ESLint } from "eslint";
7
7
  import markdown from "@eslint/markdown";
8
- import review from "./review-frontmatter.mjs";
8
+ import review, { errorsOf } from "./review-frontmatter.mjs";
9
9
  import sibling from "./sibling-frontmatter.mjs";
10
10
 
11
11
  async function lint(text) {
@@ -81,6 +81,42 @@ describe("review/frontmatter — the findings record", () => {
81
81
  });
82
82
  });
83
83
 
84
+ describe("review/frontmatter — what the parser and the validator may hand back", () => {
85
+ /** The rule's visitors over a fake context; the reports it makes. */
86
+ const visit = (value) => {
87
+ const reports = [];
88
+ const v = review.rules.frontmatter.create({
89
+ report: (r) => reports.push(r),
90
+ });
91
+ v.yaml({ value });
92
+ v["root:exit"]({});
93
+ return reports.map(
94
+ (r) => r.messageId + (r.data?.why ? `: ${r.data.why}` : ""),
95
+ );
96
+ };
97
+
98
+ it("an empty frontmatter block is validated as `{}` — silent", async () => {
99
+ expect(await lint("---\n---\n# Review\n")).toEqual([]);
100
+ expect(visit(undefined)).toEqual([]);
101
+ });
102
+
103
+ it("a parser failure without a YAML reason is reported by its message, or as unparseable", () => {
104
+ const throwing = (thrown) => ({
105
+ toString() {
106
+ throw thrown;
107
+ },
108
+ });
109
+ expect(visit(throwing(new Error("boom")))).toEqual(["malformed: boom"]);
110
+ expect(visit(throwing("not an Error"))).toEqual(["malformed: unparseable"]);
111
+ });
112
+
113
+ it("a failed validation with no errors attached names nothing", () => {
114
+ const validate = () => false;
115
+ validate.errors = null;
116
+ expect(errorsOf(validate, {})).toEqual([]);
117
+ });
118
+ });
119
+
84
120
  describe("sibling/frontmatter — the same mechanism, the package's own sibling-card schema", () => {
85
121
  async function card(text) {
86
122
  const eslint = new ESLint({
@@ -146,6 +146,55 @@ const cases = [];
146
146
  );
147
147
  }
148
148
 
149
+ // ── 5b. `realpathSync.native(…)` resolves too — as a bare import and through a namespace. A
150
+ // `.native(…)` on anything else is NOT a resolve: another object, a computed member, a call
151
+ // result, a bare `native` function. Each of those still gets its finding.
152
+ {
153
+ const root = 'mkdtempSync(join(tmpdir(), "probe-"))';
154
+ const silent = await on(
155
+ `${HEAD}import * as fs from "node:fs";\n` +
156
+ `const a = realpathSync.native(${root});\nconst b = fs.realpathSync.native(${root});\n`,
157
+ );
158
+ assert.deepEqual(
159
+ silent,
160
+ [],
161
+ `realpathSync.native must exempt the root; got: ${JSON.stringify(silent)}`,
162
+ );
163
+ for (const wrap of [
164
+ "keep",
165
+ "other.native",
166
+ "fs[name].native",
167
+ "pick().native",
168
+ "native",
169
+ ]) {
170
+ const m = await on(`${HEAD}const r = ${wrap}(${root});\n`);
171
+ assert.equal(
172
+ m.length,
173
+ 1,
174
+ `${wrap}(…) does not resolve the path — one finding expected, got ${JSON.stringify(m)}`,
175
+ );
176
+ }
177
+ cases.push(
178
+ "realpathSync.native / fs.realpathSync.native exempt; keep, other.native, fs[name].native, pick().native, native do not",
179
+ );
180
+ }
181
+
182
+ // ── 5c. The walk over the arguments survives the shapes that are not plain nodes: an array hole
183
+ // (null) and a regex literal's `regex` object (no `type`). Neither is a `tmpdir()` call.
184
+ {
185
+ const m = await on(
186
+ `${HEAD}const a = mkdtempSync([, /tmp/g]);\nconst b = mkdtempSync([, /x/, tmpdir()]);\n`,
187
+ );
188
+ assert.deepEqual(
189
+ m.map((x) => x.line),
190
+ [5],
191
+ `only the second call names tmpdir(); got: ${JSON.stringify(m)}`,
192
+ );
193
+ cases.push(
194
+ "an array hole and a regex literal in the arguments are walked, not mistaken",
195
+ );
196
+ }
197
+
149
198
  // ── 6. 🔴 THE CORPUS ITSELF IS CLEAN. The "silent" half on a made-up string says nothing
150
199
  // about the repository: the rule could also be silent because it was never handed a
151
200
  // single file (the recorded class — `scripts/rules-see-files.mjs`). So this check runs
@@ -156,10 +156,10 @@ const futurePromise = {
156
156
  return {
157
157
  root() {
158
158
  // 🔴 ONE GUARD, NOT THREE. The first draft had `if (!file || !existsSync(file))`
159
- // BEFORE the read — and a mutation run on 2026-09-07 showed that removing that line is
160
- // caught by NOTHING: the `try/catch` around the read covers exactly the same cases (no
161
- // path, a `<text>` run through stdin, no permission). Two guards doing one job is a
162
- // guard no mutation can kill, i.e. a dead assertion dressed as robustness.
159
+ // BEFORE the read — removing that line on 2026-09-07 showed it is caught by NOTHING:
160
+ // the `try/catch` around the read covers exactly the same cases (no path, a `<text>` run
161
+ // through stdin, no permission). Two guards doing one job is a guard the harness can
162
+ // never fail on, i.e. a dead assertion dressed as robustness.
163
163
  // What is left is the read inside `try`: stay quiet rather than judge content we never
164
164
  // saw.
165
165
  let text;
@@ -264,7 +264,7 @@ const acmFrontmatterOverride = {
264
264
  root() {
265
265
  // Same single guard as the sibling rule, and for the same measured reason: a separate
266
266
  // `existsSync` before the read is covered by this `catch` in every case it could
267
- // fire, i.e. it is an assertion no mutation can kill.
267
+ // fire, i.e. it is an assertion the harness can never fail on.
268
268
  let text;
269
269
  try {
270
270
  text = readFileSync(context.filename, "utf8");
@@ -289,14 +289,14 @@ const acmFrontmatterOverride = {
289
289
  for (const { re, removes } of FRONTMATTER_OVERRIDES) {
290
290
  // 🔴 NO `re.lastIndex = 0` HERE, AND THAT IS A MEASUREMENT. These regexes are
291
291
  // module-level and `/g`, so they carry `lastIndex` across files, and the obvious
292
- // defensive line is to reset it. A mutation run on 2026-09-11 removed that line
293
- // and the harness stayed GREEN — because the loop below always drains the regex,
294
- // and a `/g` `exec` that returns `null` resets `lastIndex` to 0 by itself. The
295
- // reset was a guard no mutation could kill, i.e. the same dead assertion dressed
296
- // as robustness that the sibling rule's single-`try` comment describes. What
297
- // keeps the property honest instead is an ASSERTION, not a line of code: the
298
- // harness lints the same input twice in one process and requires the two runs to
299
- // agree. Add a `break` to this loop and that assertion is what will fail.
292
+ // defensive line is to reset it. Removing that line on 2026-09-11 left the harness
293
+ // GREEN — because the loop below always drains the regex, and a `/g` `exec` that
294
+ // returns `null` resets `lastIndex` to 0 by itself. The reset was a guard the
295
+ // harness could never fail on, i.e. the same dead assertion dressed as robustness
296
+ // that the sibling rule's single-`try` comment describes. What keeps the property
297
+ // honest instead is an ASSERTION, not a line of code: the harness lints the same
298
+ // input twice in one process and requires the two runs to agree. Add a `break` to
299
+ // this loop and that assertion is what will fail.
300
300
  let m;
301
301
  while ((m = re.exec(line)) !== null) {
302
302
  const column = m.index + 1;
package/eslint.config.mjs CHANGED
@@ -138,6 +138,9 @@ export const layerBoundaries = (root) => ({
138
138
  partialMatch: false,
139
139
  capture: ["kind"],
140
140
  },
141
+ // test/support.mjs — shared helpers for tests. Only the `test` category may import it: the
142
+ // policies below allow tests any element, and allow no other file this one.
143
+ { type: "test-support", pattern: "test", partialMatch: false },
141
144
  ],
142
145
  "boundaries/files": [
143
146
  // `exclusive`: the root also matches the app pattern below, and is only the root.
@@ -369,8 +372,9 @@ export default [
369
372
  * 🔴 THIS BLOCK COVERS THE PACKAGE ITSELF, and before 2026-09-15 it was not here: the config held only
370
373
  * one block for `.tex` (I don't quote the glob inside this comment: the sequence
371
374
  * "star-slash" would close the comment itself — which is where I tripped), and a file with no block gets simply IGNORED by ESLint 9.
372
- * Meaning: 133 own `.mjs` — mutation engine, three hooks, scripts for 24 skills — went unchecked,
373
- * with green `npm run lint`. A tool that checks others' papers and not itself.
375
+ * Meaning: 133 own `.mjs` — a since-removed mutation-testing engine, three hooks, scripts for
376
+ * 24 skills — went unchecked, with green `npm run lint`. A tool that checks others' papers and
377
+ * not itself.
374
378
  *
375
379
  * Measurement on the first run: 15 files with dead imports (`resolve`, `pathToFileURL`) and
376
380
  * orphaned constants — remnants of a move from `mine`, where those names were needed. All
@@ -3,3 +3,4 @@ acmart acmart.cls
3
3
  libertine libertine.sty
4
4
  texcount bin:texcount
5
5
  liar other.sty
6
+ unicode-data GraphemeBreakProperty.txt
@@ -17,11 +17,14 @@ if [ -n "${FAKE_INSTALL_TL_FAIL:-}" ]; then
17
17
  exit 1
18
18
  fi
19
19
  mkdir -p "$dir/bin/x86_64-linux"
20
- for t in pdflatex kpsewhich tlmgr; do
20
+ for t in pdflatex kpsewhich tlmgr fmtutil-sys; do
21
21
  cp "$here/stub-$t" "$dir/bin/x86_64-linux/$t"
22
22
  chmod +x "$dir/bin/x86_64-linux/$t"
23
23
  done
24
24
  : > "$dir/files.txt"
25
25
  # The real install-tl leaves release-texlive.txt at TEXDIR; stub-tlmgr reads the tree's year there.
26
26
  cp "$here/release-texlive.txt" "$dir/release-texlive.txt"
27
+ # The real install-tl, when a mirror fails to serve a package, prints "continuing anyway" and
28
+ # exits as if nothing happened. FAKE_INSTALL_TL_DROP names the package it lost.
29
+ if [ -n "${FAKE_INSTALL_TL_DROP:-}" ]; then echo "$FAKE_INSTALL_TL_DROP" > "$dir/missing-deps.txt"; fi
27
30
  echo "fake install-tl: scheme-basic into $dir"
@@ -0,0 +1,4 @@
1
+ #!/bin/sh
2
+ # Stands in for fmtutil-sys: records its arguments in fmtutil.log at the tree root.
3
+ bin=$(cd "$(dirname "$0")" && pwd)
4
+ echo "$*" >> "$bin/../../fmtutil.log"
@@ -9,6 +9,15 @@ while [ $# -gt 0 ]; do
9
9
  case "$1" in
10
10
  --repository) repo=$2; shift 2 ;;
11
11
  install) shift; break ;;
12
+ # `check depends` in tlmgr's own layout: every name in missing-deps.txt (what the stub
13
+ # install-tl was told to drop) is a dependency without a package. Not logged: it installs nothing.
14
+ check)
15
+ if [ -s "$tree/missing-deps.txt" ]; then
16
+ echo " DEPENDS WITHOUT PACKAGES:"
17
+ sed 's/$/ in: collection-basic latex-bin/' "$tree/missing-deps.txt"
18
+ exit 2
19
+ fi
20
+ exit 0 ;;
12
21
  *) shift ;;
13
22
  esac
14
23
  done
@@ -34,6 +43,12 @@ for p in "$@"; do
34
43
  status=1
35
44
  continue
36
45
  fi
46
+ # Installing a dropped dependency repairs the tree.
47
+ # Portable on purpose: macOS sed -i takes a backup suffix, GNU sed does not.
48
+ if [ -f "$tree/missing-deps.txt" ]; then
49
+ grep -vx "$p" "$tree/missing-deps.txt" > "$tree/missing-deps.tmp" || true
50
+ mv "$tree/missing-deps.tmp" "$tree/missing-deps.txt"
51
+ fi
37
52
  for item in ${line#* }; do
38
53
  case "$item" in
39
54
  bin:*) : > "$bin/${item#bin:}"; chmod +x "$bin/${item#bin:}" ;;
@@ -21,9 +21,7 @@
21
21
  * `.ts` source would be: a twin can drift from its build, and there is no twin here to drift.
22
22
  *
23
23
  * Run: `npx vigiles test hooks/hooks.harness.mjs`
24
- * Killed by: `hooks/hooks.mutations.mjs`
25
24
  */
26
- import assert from "node:assert/strict";
27
25
  import { runHook } from "vigiles";
28
26
  import { checkHookImports } from "vigiles/hook";
29
27
  import {
@@ -40,6 +38,7 @@ import { tmpdir } from "node:os";
40
38
  import { dirname, join, resolve } from "node:path";
41
39
  import { fileURLToPath } from "node:url";
42
40
  import { PAPERS_DIR_FIELD } from "../lib/paper-config.mjs";
41
+ import { createChecker } from "../lib/check.mjs";
43
42
 
44
43
  const HOOKS = dirname(fileURLToPath(import.meta.url));
45
44
  const ROOT = resolve(HOOKS, "..");
@@ -51,11 +50,7 @@ const SHIPPED = readdirSync(HOOKS)
51
50
  .filter((f) => f.endsWith(".hook.mjs"))
52
51
  .sort();
53
52
 
54
- let n = 0;
55
- const check = (label, cond) => {
56
- assert.ok(cond, label);
57
- n++;
58
- };
53
+ const check = createChecker();
59
54
 
60
55
  /**
61
56
  * A throwaway consumer repository: a root `paperlint.json` holding the given settings (none when
@@ -492,7 +487,7 @@ try {
492
487
  // 🔴 AND SILENT ON THE DEFAULT ROOT TOO — this is the half that catches `??`. With
493
488
  // `declared ?? DEFAULT` an explicit `null` is read as «nothing was declared», the prefix
494
489
  // becomes `papers/`, and the nudge starts firing about a tree the consumer never named.
495
- // Without this case that mutation survives, because the assertion above passes either way.
490
+ // Without this case that defect survives, because the assertion above passes either way.
496
491
  const d = at(
497
492
  broken,
498
493
  "paper-skills-nudge",
@@ -629,8 +624,8 @@ try {
629
624
  );
630
625
  // 🔴 THE NASTY NAME NEEDS A REAL DIRECTORY BEHIND IT, or the assertion cannot fail. First
631
626
  // draft just passed `a;b` and checked for silence — but a name that names nothing is silent
632
- // anyway, for want of a status file, so the mutation that DELETES the name check survived
633
- // green. The fixture now contains a paper literally called `a;b` with a status file in it:
627
+ // anyway, for want of a status file, so deleting the name check would leave this harness
628
+ // green regardless. The fixture now contains a paper literally called `a;b` with a status file in it:
634
629
  // with the check, silence; without it, the verdict comes out. Measured 2026-09-12.
635
630
  mkdirSync(join(dir, "docs/papers/a;b"), { recursive: true });
636
631
  writeFileSync(
@@ -648,7 +643,7 @@ try {
648
643
  );
649
644
  }
650
645
 
651
- console.log(`hooks.harness: ${n} assertions passed`);
646
+ console.log(`hooks.harness: ${check.count} assertions passed`);
652
647
  } finally {
653
648
  for (const d of tmps) rmSync(d, { recursive: true, force: true });
654
649
  }
@@ -355,8 +355,8 @@ const MUTATORS = [
355
355
  * `runs()` now sees through `env`, `env -C`, `command`, `nice`, `xargs`, `sudo`, `time`, a
356
356
  * repeated space and a quoted head — every form tried. So the crutch defended nothing, which is
357
357
  * not a judgement but a measurement: removing it left all 78 harness assertions GREEN, and no
358
- * command form could be found that it alone catches. A line no mutation can kill is a line
359
- * documenting a defence that does not exist, so it was deleted rather than kept for comfort.
358
+ * command form could be found that it alone catches. A line the harness can never fail on is a
359
+ * line documenting a defence that does not exist, so it was deleted rather than kept for comfort.
360
360
  *
361
361
  * ⚠️ WHAT THIS DOES NOT FIX: rows 3 and 4 above. A wrapper that moves the working directory is
362
362
  * still invisible, because `touches`/`namesPaperSource` compare against a repo-root prefix and
@@ -0,0 +1,61 @@
1
+ /**
2
+ * paper-edit-guard's Bash scan on the shapes the hooks harness does not carry: a `>` inside quotes
3
+ * or escaped (not a redirection), a target spelled with backslashes (read through them), a target
4
+ * ended by a delimiter, and a mutator that moves a paper source AWAY — a write `writesTo` does not
5
+ * see, which the `touches` fallback catches. Each case runs the hook the way a consumer's settings
6
+ * run it: `vigiles hook-runtime run-program`, from a throwaway project.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { test } from "vitest";
10
+ import {
11
+ hookConsumer,
12
+ onBash,
13
+ runShippedHook,
14
+ } from "../test/hook-consumer.mjs";
15
+ import { useTempDir } from "../test/support.mjs";
16
+
17
+ const dir = hookConsumer(useTempDir("paper-edit-guard-"), {
18
+ "papers/alpha/PIPELINE-STATUS.md": "# S\n",
19
+ });
20
+ /** `>` built rather than typed: a literal one here would be a redirection to the guard's own scan. */
21
+ const GT = String.fromCharCode(62);
22
+ const P = "papers/alpha/paper.md";
23
+
24
+ /** The guard's exit code for one Bash command: 2 denies, 0 lets it through. */
25
+ const guard = (command) =>
26
+ runShippedHook("paper-edit-guard", onBash(command), dir).exitCode;
27
+
28
+ test("a `>` inside quotes, after an escaped quote, or escaped itself is not a redirection", () => {
29
+ assert.deepEqual(
30
+ [
31
+ guard(`echo "a ${GT} ${P}"`),
32
+ guard(`echo "a \\" ${GT} b"`),
33
+ guard(`echo a\\${GT} ${P}`),
34
+ ],
35
+ [0, 0, 0],
36
+ );
37
+ });
38
+
39
+ test("a redirection target is read through backslashes, quoted or not, and ends at a delimiter", () => {
40
+ assert.deepEqual(
41
+ [
42
+ guard(`echo x ${GT} "papers/alpha/pa\\per.md"`),
43
+ guard(`echo x ${GT} papers/alpha/pa\\per.md`),
44
+ guard(`echo x ${GT}${P}; true`),
45
+ ],
46
+ [2, 2, 2],
47
+ );
48
+ });
49
+
50
+ test("moving a paper source away is a write to it: denied", () => {
51
+ assert.equal(guard(`mv ${P} /tmp/elsewhere.md`), 2);
52
+ });
53
+
54
+ test("a command cut off right after a backslash still names its target, quoted or not", () => {
55
+ // The backslash escapes nothing — there is no next character — and the target read so far
56
+ // is still a paper source.
57
+ assert.deepEqual(
58
+ [guard(`echo x ${GT} ${P}\\`), guard(`echo x ${GT} "${P}\\`)],
59
+ [2, 2],
60
+ );
61
+ });
@@ -71,8 +71,8 @@ const READ_CONFIG = `f="\${CLAUDE_PROJECT_DIR:-.}/${CONFIG_FILE}"; if [ -e "$f"
71
71
  * `e.path.under()`, and vigiles normalises the prefix itself (`normalizePrefix` →
72
72
  * `trimTrailingSeparators`), so `"docs/papers/"` and `"docs/papers"` are the same prefix to it.
73
73
  * A normalise here would be a line no test could kill, which is a line that documents a
74
- * defence that does not exist. Checked 2026-09-12 by mutation: removing it left the harness
75
- * green, which is the finding that produced this paragraph.
74
+ * defence that does not exist. Confirmed 2026-09-12: removing it left the harness green, which
75
+ * is the finding that produced this paragraph.
76
76
  *
77
77
  * ⚠️ A NUDGE FALLS SILENT WHERE THE GATE REFUSES, and the asymmetry is the decision, not an
78
78
  * inconsistency. `paper-edit-guard` denies on an unreadable declaration because a gate that
@@ -0,0 +1,38 @@
1
+ /**
2
+ * paper-skills-nudge on the two declarations the hooks harness does not carry: none at all (the
3
+ * default `papers/` root applies) and one that does not parse (silence — a nudge never blocks).
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import { join } from "node:path";
7
+ import { test } from "vitest";
8
+ import {
9
+ hookConsumer,
10
+ onEdit,
11
+ runShippedHook,
12
+ } from "../test/hook-consumer.mjs";
13
+ import { useTempDir } from "../test/support.mjs";
14
+
15
+ const root = useTempDir("paper-skills-nudge-");
16
+
17
+ test("no paperlint.json: an edit to papers/<p>/paper.md gets the checklist", () => {
18
+ const dir = hookConsumer(join(root, "default"));
19
+ const r = runShippedHook(
20
+ "paper-skills-nudge",
21
+ onEdit("papers/alpha/paper.md"),
22
+ dir,
23
+ );
24
+ assert.equal(r.exitCode, 0);
25
+ assert.notEqual(r.stdout, "");
26
+ });
27
+
28
+ test("a paperlint.json that does not parse: silent, exit 0", () => {
29
+ const dir = hookConsumer(join(root, "broken"), {
30
+ "paperlint.json": "{ nope",
31
+ });
32
+ const r = runShippedHook(
33
+ "paper-skills-nudge",
34
+ onEdit("papers/alpha/paper.md"),
35
+ dir,
36
+ );
37
+ assert.deepEqual([r.exitCode, r.stdout, r.stderr], [0, "", ""]);
38
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * paper-status-gates on the two declarations the hooks harness does not carry: none at all (the
3
+ * default `papers/` root applies) and one that does not parse (silence — this hook never blocks).
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import { join } from "node:path";
7
+ import { test } from "vitest";
8
+ import {
9
+ hookConsumer,
10
+ onEdit,
11
+ runShippedHook,
12
+ } from "../test/hook-consumer.mjs";
13
+ import { useTempDir } from "../test/support.mjs";
14
+
15
+ const root = useTempDir("paper-status-gates-");
16
+ const STATUS = "**Readiness verdict:** ✅ fixture verdict line\n";
17
+
18
+ test("no paperlint.json: an edit under papers/ runs the gates for that paper", () => {
19
+ const dir = hookConsumer(join(root, "default"), {
20
+ "papers/alpha/PIPELINE-STATUS.md": STATUS,
21
+ });
22
+ const r = runShippedHook(
23
+ "paper-status-gates",
24
+ onEdit("papers/alpha/paper.md"),
25
+ dir,
26
+ );
27
+ assert.equal(r.exitCode, 0);
28
+ assert.match(r.stderr, /fixture verdict line/);
29
+ });
30
+
31
+ test("a paperlint.json that does not parse: silent, exit 0", () => {
32
+ const dir = hookConsumer(join(root, "broken"), {
33
+ "paperlint.json": "{ nope",
34
+ "papers/alpha/PIPELINE-STATUS.md": STATUS,
35
+ });
36
+ const r = runShippedHook(
37
+ "paper-status-gates",
38
+ onEdit("papers/alpha/paper.md"),
39
+ dir,
40
+ );
41
+ assert.deepEqual([r.exitCode, r.stdout, r.stderr], [0, "", ""]);
42
+ });
@@ -107,6 +107,13 @@ const cases = [];
107
107
  cases.push(
108
108
  'exit 0 + empty stdout (the `stdio: "ignore"` shape) → throws, does not report a blank',
109
109
  );
110
+ // …and what the program DID say, on stderr, is carried into the refusal: it is the only clue.
111
+ const chatty = spawner({ status: 0, stdout: "", stderr: " warming up\n" });
112
+ assert.throws(
113
+ () => observeAgentCli({ spawnSync: chatty }),
114
+ /\(got "", stderr "warming up"\)/,
115
+ );
116
+ cases.push("…and the stderr it printed instead is quoted in the refusal");
110
117
  }
111
118
 
112
119
  // ── 5. THE CALL SHAPE, asserted directly. Case 4 catches a discarding probe only because the
@@ -0,0 +1,17 @@
1
+ // Types for lib/check.mjs — the shared harness assertion helper (see the module).
2
+
3
+ /** A failure detail: text, any value (rendered with `util.inspect`), or a thunk producing one. */
4
+ export type Detail = unknown;
5
+
6
+ export declare function renderDetail(detail: Detail): string;
7
+ export declare function failureMessage(label: string, detail: Detail): string;
8
+
9
+ export interface Check {
10
+ (label: string, cond: unknown, detail?: Detail): void;
11
+ /** How many times this checker has been called. */
12
+ readonly count: number;
13
+ }
14
+
15
+ export declare function createChecker(options?: {
16
+ log?: (line: string) => void;
17
+ }): Check;
package/lib/check.mjs ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The one assertion helper every `*.harness.mjs` uses.
3
+ *
4
+ * Until #52 each harness defined its own `check()`, and the copies had drifted: some printed a
5
+ * detail on failure and some dropped it, some joined it with " — " and some with a newline, and two
6
+ * passed the LABEL to vigiles' `recordCheck(n)`, which adds its argument to a number — so the
7
+ * recorded count became a string. One helper removes the drift instead of documenting it.
8
+ *
9
+ * What it guarantees:
10
+ * - a failure names the label AND the detail, on stderr and in the thrown assertion — the detail
11
+ * is what makes a red run diagnosable without re-running it;
12
+ * - `detail` may be a function, so an expensive rendering is paid only on failure;
13
+ * - every call is counted (`check.count`) and reported to the vigiles runner (`recordCheck`), so
14
+ * a harness's summary line and the runner's check count come from the same place.
15
+ */
16
+ import assert from "node:assert/strict";
17
+ import { inspect } from "node:util";
18
+ import { recordCheck } from "vigiles";
19
+
20
+ /** A failure detail as text: strings verbatim, other values through `util.inspect`, a thunk called. */
21
+ export function renderDetail(detail) {
22
+ const value = typeof detail === "function" ? detail() : detail;
23
+ if (value === undefined || value === null || value === "") return "";
24
+ return typeof value === "string" ? value : inspect(value, { depth: 6 });
25
+ }
26
+
27
+ /** The label, then the detail: on the same line when it is one line, below it when it is many. */
28
+ export function failureMessage(label, detail) {
29
+ const shown = renderDetail(detail);
30
+ if (!shown) return label;
31
+ return shown.includes("\n") ? `${label}\n${shown}` : `${label} — ${shown}`;
32
+ }
33
+
34
+ /**
35
+ * A fresh `check(label, cond, detail?)`. `check.count` is how many times it has been called.
36
+ * `log` is where a failure is printed — stderr unless this module's own test injects one.
37
+ */
38
+ export function createChecker({ log = console.error } = {}) {
39
+ let count = 0;
40
+ const check = (label, cond, detail) => {
41
+ count++;
42
+ recordCheck();
43
+ if (cond) return;
44
+ const message = failureMessage(label, detail);
45
+ log(`✗ ${message}`);
46
+ assert.fail(message);
47
+ };
48
+ Object.defineProperty(check, "count", { get: () => count });
49
+ return check;
50
+ }
@@ -0,0 +1,67 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "vitest";
3
+ import { createChecker, failureMessage, renderDetail } from "./check.mjs";
4
+
5
+ test("renderDetail: a string verbatim, nothing for empty, a value inspected, a thunk called", () => {
6
+ assert.deepEqual(
7
+ [
8
+ renderDetail("as is"),
9
+ renderDetail(""),
10
+ renderDetail(undefined),
11
+ renderDetail(null),
12
+ renderDetail({ a: [1, 2] }),
13
+ renderDetail(() => "lazy"),
14
+ renderDetail(() => undefined),
15
+ ],
16
+ ["as is", "", "", "", "{ a: [ 1, 2 ] }", "lazy", ""],
17
+ );
18
+ });
19
+
20
+ test("failureMessage: label alone, label — detail, or label on its own line above a multi-line detail", () => {
21
+ assert.deepEqual(
22
+ [
23
+ failureMessage("L", undefined),
24
+ failureMessage("L", "d"),
25
+ failureMessage("L", "one\ntwo"),
26
+ ],
27
+ ["L", "L — d", "L\none\ntwo"],
28
+ );
29
+ });
30
+
31
+ test("createChecker: counts every call, stays silent on a pass", () => {
32
+ const printed: string[] = [];
33
+ const check = createChecker({ log: (s) => printed.push(s) });
34
+ check("a", true);
35
+ check("b", 1 === 1, "unused detail");
36
+ assert.deepEqual({ count: check.count, printed }, { count: 2, printed: [] });
37
+ });
38
+
39
+ test("createChecker: a failure throws AND prints the label with its detail, and is counted", () => {
40
+ const printed: string[] = [];
41
+ const check = createChecker({ log: (s) => printed.push(s) });
42
+ // Guards: the detail reaches the reader — the drift this helper replaced dropped it.
43
+ assert.throws(() => check("the label", false, { got: 3 }), {
44
+ name: "AssertionError",
45
+ message: "the label — { got: 3 }",
46
+ });
47
+ assert.deepEqual(
48
+ { count: check.count, printed },
49
+ { count: 1, printed: ["✗ the label — { got: 3 }"] },
50
+ );
51
+ });
52
+
53
+ test("createChecker: a detail thunk is only evaluated on failure", () => {
54
+ let calls = 0;
55
+ const check = createChecker({ log: () => {} });
56
+ check("pass", true, () => {
57
+ calls++;
58
+ return "x";
59
+ });
60
+ assert.throws(() =>
61
+ check("fail", false, () => {
62
+ calls++;
63
+ return "x";
64
+ }),
65
+ );
66
+ assert.equal(calls, 1);
67
+ });