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
@@ -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
  );
package/src/init.test.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  /**
2
- * `paperlint init` over a project that ALREADY declares its papers directory: the declaration is
3
- * the answer, and init reports it — not a directory it discovered elsewhere and then did not use.
2
+ * `paperlint init`: how the papers directory is decided and declared, the refusals over a
3
+ * `paperlint.json` it cannot use, and each section of its report, asserted whole.
4
4
  *
5
- * Seen migrating a consumer project: init printed "papers directory ✓ eslint-rules/fixtures — 4
6
- * candidates", then kept the declared papersDir. The line described a decision that was not made.
5
+ * The first block is the one this file was written for — a project that ALREADY declares its
6
+ * papers directory: the declaration is the answer, and init reports it, not a directory it
7
+ * discovered elsewhere and then did not use. Seen migrating a consumer project: init printed
8
+ * "papers directory ✓ eslint-rules/fixtures — 4 candidates", then kept the declared papersDir.
9
+ * The line described a decision that was not made.
7
10
  */
8
11
  import {
9
12
  existsSync,
@@ -17,7 +20,21 @@ import {
17
20
  import { tmpdir } from "node:os";
18
21
  import { join } from "node:path";
19
22
  import { afterEach, describe, expect, it } from "vitest";
20
- import { choosePapers, init } from "./init.ts";
23
+ import { FRESH_CLONE_NOTE } from "./hooks-settings.ts";
24
+ import {
25
+ UNPINNED_REF,
26
+ WORKFLOW_PATH,
27
+ choosePapers,
28
+ declarePapers,
29
+ init,
30
+ offerHooks,
31
+ offerTexLive,
32
+ reportHooks,
33
+ reportSkillLinks,
34
+ reportTexLive,
35
+ reportWorkflow,
36
+ } from "./init.ts";
37
+ import { runNode } from "../test/support.mjs";
21
38
 
22
39
  const dirs: string[] = [];
23
40
  afterEach(() => {
@@ -204,3 +221,289 @@ describe("paperlint init — TeX Live is offered with its cost, never installed
204
221
  expect(r.text).toMatch(/✓ paperlint's TeX Live is installed/);
205
222
  });
206
223
  });
224
+
225
+ /** A throwaway project from `{ path: contents }`. */
226
+ function tree(files: Record<string, string>): string {
227
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-init-")));
228
+ dirs.push(root);
229
+ for (const [p, text] of Object.entries(files)) {
230
+ mkdirSync(join(root, p, ".."), { recursive: true });
231
+ writeFileSync(join(root, p), text);
232
+ }
233
+ return root;
234
+ }
235
+
236
+ /** init with everything outside the papers step stubbed out; returns the code and both streams. */
237
+ async function initRun(
238
+ root: string,
239
+ opts: Parameters<typeof init>[1] = {},
240
+ ): Promise<{ code: number; out: string[]; err: string[] }> {
241
+ const out: string[] = [];
242
+ const err: string[] = [];
243
+ const code = await init(root, {
244
+ cwd: root,
245
+ log: (s: string) => out.push(s),
246
+ err: (s: string) => err.push(s),
247
+ interactive: false,
248
+ hooks: false,
249
+ run: (() => ({ status: 0 })) as never,
250
+ link: () => ({ ok: false, error: "not linked in this test" }),
251
+ ...opts,
252
+ });
253
+ return { code, out, err };
254
+ }
255
+
256
+ describe("paperlint init — a paperlint.json it cannot use stops it before anything is written", () => {
257
+ it("unparsable: exit 2, the reason, and nothing written", async () => {
258
+ const root = tree({ "paperlint.json": "{ nope" });
259
+ const r = await initRun(root);
260
+ expect(r.code).toBe(2);
261
+ expect(r.err[0]).toMatch(/^ {2}✗ paperlint\.json is not valid JSON: /);
262
+ expect(r.err.slice(1)).toEqual([
263
+ " nothing was written. The hooks read their papers directory from this file and",
264
+ " refuse every Bash command while it cannot be parsed — fix the JSON first.",
265
+ ]);
266
+ expect(readFileSync(join(root, "paperlint.json"), "utf8")).toBe("{ nope");
267
+ });
268
+
269
+ it("a papersDir that is not a string: exit 2, named", async () => {
270
+ const root = tree({ "paperlint.json": '{ "papersDir": 7 }' });
271
+ const r = await initRun(root);
272
+ expect([r.code, r.err]).toEqual([
273
+ 2,
274
+ [
275
+ " ✗ paperlint.json declares papersDir = 7 — it must be a directory path (a string)",
276
+ ],
277
+ ]);
278
+ });
279
+
280
+ it("a file without a trailing newline keeps having none", async () => {
281
+ const root = tree({
282
+ "paperlint.json": '{"x":1}',
283
+ "drafts/a/paper.md": "# a\n",
284
+ });
285
+ const decl = await declarePapers(root, () =>
286
+ choosePapers(root, { interactive: false }),
287
+ );
288
+ expect(decl.status).toBe("written");
289
+ expect(readFileSync(join(root, "paperlint.json"), "utf8")).toBe(
290
+ '{\n "x": 1,\n "papersDir": "drafts"\n}',
291
+ );
292
+ });
293
+ });
294
+
295
+ describe("paperlint init — how several candidate directories were decided, said", () => {
296
+ const several = () =>
297
+ tree({ "drafts/a/paper.md": "# a\n", "writing/b/paper.md": "# b\n" });
298
+ const section = (out: string[]) =>
299
+ out.slice(out.indexOf("papers directory") + 1, out.indexOf("settings") - 1);
300
+
301
+ it("picked by a human", async () => {
302
+ const r = await initRun(several(), {
303
+ interactive: true,
304
+ ask: async () => "2",
305
+ });
306
+ expect(section(r.out)).toEqual([
307
+ " ✓ writing — you picked it out of 2 candidates",
308
+ ]);
309
+ });
310
+
311
+ it("no terminal: the first, and the others named", async () => {
312
+ const r = await initRun(several());
313
+ expect(section(r.out)).toEqual([
314
+ " ✓ drafts — 2 candidates, stdin is not a terminal so nothing was asked",
315
+ ' the others: writing — set "papersDir" in paperlint.json if this is the wrong one',
316
+ ]);
317
+ });
318
+
319
+ it("an answer that picks nothing: the first, and said so", async () => {
320
+ const r = await initRun(several(), {
321
+ interactive: true,
322
+ ask: async () => "nine",
323
+ });
324
+ expect(section(r.out)[0]).toBe(
325
+ " ✓ drafts — 2 candidates, no answer was given, so the first one was taken",
326
+ );
327
+ });
328
+ });
329
+
330
+ const here = (p: string) => `<${p}>`;
331
+
332
+ describe("paperlint init — the workflow section, whole", () => {
333
+ it("the workflow: written unpinned, kept, declined, not asked", () => {
334
+ const ctx = { papersDir: "papers", why: "stdin is not a terminal" };
335
+ expect(reportWorkflow("written", ctx)).toEqual([
336
+ ` ✓ wrote ${WORKFLOW_PATH} — pin ${UNPINNED_REF} before pushing it`,
337
+ ]);
338
+ expect(reportWorkflow("written", { ...ctx, version: "1.2.3" })).toEqual([
339
+ ` ✓ wrote ${WORKFLOW_PATH}, pinned to v1.2.3`,
340
+ ]);
341
+ expect(reportWorkflow("kept", ctx)).toEqual([
342
+ ` ✓ ${WORKFLOW_PATH} is already there — kept, nothing overwritten`,
343
+ ]);
344
+ const step = [
345
+ " to run the same checks in CI, add this step to a workflow:",
346
+ ` - uses: zernie/paperlint@${UNPINNED_REF}`,
347
+ " with:",
348
+ " paths: papers",
349
+ ];
350
+ expect(reportWorkflow("declined", ctx)).toEqual([
351
+ " · declined — nothing written",
352
+ ...step,
353
+ ]);
354
+ expect(reportWorkflow("not-asked", ctx)).toEqual([
355
+ " · stdin is not a terminal, so nothing was asked. Default taken: NO file written.",
356
+ ...step,
357
+ ]);
358
+ });
359
+ });
360
+
361
+ describe("paperlint init — the hooks section, whole", () => {
362
+ it("the hooks: every outcome", () => {
363
+ const how = "HOW";
364
+ const r = (o: Parameters<typeof reportHooks>[0]) =>
365
+ reportHooks(o, { how, here });
366
+ expect(r({ status: "skipped" })).toEqual([
367
+ " · --no-hooks — nothing written",
368
+ ]);
369
+ expect(r({ status: "declined" })).toEqual([
370
+ " · declined — nothing written. `npx paperlint init` again wires them later",
371
+ ]);
372
+ expect(r({ status: "failed", reason: "no vigiles" })).toEqual([
373
+ " ✗ not wired — no vigiles",
374
+ " the hooks need vigiles to run at all; reinstall this package, then `npx paperlint init`",
375
+ ]);
376
+ expect(r({ status: "unparsable", path: "s.json", reason: "bad" })).toEqual([
377
+ " ✗ <s.json> does not parse — nothing written: bad",
378
+ ]);
379
+ expect(r({ status: "present", path: "s.json", names: ["a"] })).toEqual([
380
+ " ✓ already wired in <s.json> — nothing changed",
381
+ " HOW",
382
+ ` ${FRESH_CLONE_NOTE}`,
383
+ ]);
384
+ expect(
385
+ r({
386
+ status: "foreign",
387
+ path: "s.json",
388
+ names: ["a", "b"],
389
+ found: [{ name: "a", command: "node x.mjs" }],
390
+ missing: ["b"],
391
+ }),
392
+ ).toEqual([
393
+ " ✓ already wired under another spelling in <s.json> — nothing written, so nothing runs twice:",
394
+ " a: node x.mjs",
395
+ " ⚠ and NOT wired in any form: b — add them in that same form",
396
+ " to switch to the form init writes, delete those commands and run `npx paperlint init` again",
397
+ ]);
398
+ });
399
+ });
400
+
401
+ describe("paperlint init — the skills section, whole", () => {
402
+ it("the skills: nothing linked, and a mix with a skipped entry of unknown reason and a missing one", () => {
403
+ expect(reportSkillLinks({ ok: false, error: "no package" }, here)).toEqual([
404
+ "",
405
+ "skills (Claude Code finds project skills in .claude/skills/, not in node_modules)",
406
+ " ⚠ nothing linked — no package",
407
+ " install the package into this project (`npm i -D …`), then `npx paperlint init` again",
408
+ ]);
409
+ expect(
410
+ reportSkillLinks(
411
+ {
412
+ ok: true,
413
+ home: "h",
414
+ example: null,
415
+ links: [
416
+ { name: "a", status: "created" },
417
+ { name: "b", status: "foreign" },
418
+ { name: "c", status: "missing" },
419
+ ],
420
+ },
421
+ here,
422
+ ).slice(2),
423
+ ).toEqual([
424
+ " ⚠ 3 shipped: 1 linked now, 0 already linked, 1 skipped, 1 NOT linked",
425
+ " left untouched — the name is taken by something paperlint did not make:",
426
+ " b — occupied",
427
+ " those skills are NOT available in Claude Code until the entry is moved or removed",
428
+ ]);
429
+ });
430
+ });
431
+
432
+ describe("paperlint init — TeX Live and hooks failures, whole", () => {
433
+ it("TeX Live: a failed install says to run it again", async () => {
434
+ const t = await offerTexLive(
435
+ { installed: () => false, install: () => 1 },
436
+ { interactive: true, ask: async () => "yes" },
437
+ );
438
+ expect([t, reportTexLive(t, "")]).toEqual([
439
+ "failed",
440
+ [
441
+ " ✗ the install failed — see above. Run it again:",
442
+ " npx paperlint toolchain # ~270 MB, ~3 min, once",
443
+ ],
444
+ ]);
445
+ });
446
+
447
+ it("hooks: a merge that throws is a failed step, not a crash", async () => {
448
+ const root = tree({});
449
+ const outcome = await offerHooks(root, {
450
+ hooks: true,
451
+ interactive: false,
452
+ merge: () => {
453
+ throw new Error("merge exploded");
454
+ },
455
+ });
456
+ expect(outcome).toEqual({ status: "failed", reason: "merge exploded" });
457
+ });
458
+ });
459
+
460
+ describe("paperlint init — a first paper", () => {
461
+ it("Enter at the name prompt skips it: nothing is created", async () => {
462
+ const made: string[] = [];
463
+ const r = await initRun(tree({}), {
464
+ interactive: true,
465
+ ask: async () => "",
466
+ createPaper: async (_root, name) => (made.push(name), 0),
467
+ });
468
+ expect(made).toEqual([]);
469
+ expect(r.out[r.out.indexOf("first paper") + 1]).toBe(
470
+ " · none yet. `npx paperlint new <name>` or `--paper <name>` creates one",
471
+ );
472
+ });
473
+ });
474
+
475
+ describe("paperlint init — paths are shown relative to where it was run", () => {
476
+ it("a path that IS the working directory is shown whole, not as an empty string", async () => {
477
+ const root = tree({ "papers/a/paper.md": "# a\n" });
478
+ const home = join(root, ".claude", "skills");
479
+ mkdirSync(home, { recursive: true });
480
+ const r = await initRun(root, {
481
+ cwd: home,
482
+ link: () => ({
483
+ ok: true,
484
+ home,
485
+ example: "../../node_modules/paperlint/skills/x",
486
+ links: [{ name: "x", status: "present" }],
487
+ }),
488
+ });
489
+ expect(r.out).toContain(
490
+ ` ${join(home, "<name>")} → ../../node_modules/paperlint/skills/<name>`,
491
+ );
492
+ });
493
+ });
494
+
495
+ describe("askOnTerminal", () => {
496
+ it("reads one line from stdin, and the question goes to stdout", () => {
497
+ const probe = join(tree({}), "probe.mjs");
498
+ writeFileSync(
499
+ probe,
500
+ `const { askOnTerminal } = await import(${JSON.stringify(join(import.meta.dirname, "init.ts"))});\n` +
501
+ `console.log(JSON.stringify(await askOnTerminal("name? ")));\n`,
502
+ );
503
+ expect(runNode(probe, [], { input: "paper-one\n" })).toEqual({
504
+ status: 0,
505
+ stdout: 'name? "paper-one"\n',
506
+ stderr: "",
507
+ });
508
+ });
509
+ });
package/src/init.ts CHANGED
@@ -83,7 +83,7 @@ export type PapersHow =
83
83
  * declaration had already been written. So the install both succeeded and looked like a crash.
84
84
  * An unanswered question is an answer: take the default and say so.
85
85
  */
86
- async function askOrDefault(
86
+ export async function askOrDefault(
87
87
  ask: (q: string) => Promise<string>,
88
88
  question: string,
89
89
  ): Promise<string | null> {
@@ -372,7 +372,7 @@ export function nextSteps(
372
372
  ): string {
373
373
  return [
374
374
  ``,
375
- `next: npx paperlint new <name> # start a paper in ${papersDir}/ from the template`,
375
+ `next: npx paperlint new <name> --venue <preset> # start a paper in ${papersDir}/ from the template`,
376
376
  ...(toolchain
377
377
  ? [
378
378
  ` npx paperlint toolchain # TeX Live for building (${TOOLCHAIN_COST})`,
@@ -857,8 +857,8 @@ export async function init(
857
857
  // 🔴 THE NAMES AND THE COUNT HERE, THE CONSEQUENCES — IN THE doctor REPORT BELOW, AND THIS IS
858
858
  // NOT ABOUT SAVING LINES. The first version printed here the same "✗ program — what goes silent
859
859
  // without it" table that doctor prints twenty lines later. Not only did the reader see it
860
- // twice — the harness assert could not tell one from the other and left a mutation that cut the
861
- // remedy OUT of init green. One fact is printed by one author.
860
+ // twice — the harness assert could not tell one from the other and would have let a defect
861
+ // cut the remedy OUT of init green. One fact is printed by one author.
862
862
  log(
863
863
  ` ✗ ${String(missing.length)} of ${String(PROGRAMS.length)} missing: ` +
864
864
  missing.map((p) => p.bin).join(", "),
@@ -5,9 +5,9 @@
5
5
  * memory: the two error spellings `-file-line-error` produces, the missing-package form, and a
6
6
  * marker that TeX broke at column 79.
7
7
  */
8
- import assert from "node:assert/strict";
9
8
  import { dirname, join } from "node:path";
10
9
  import { fileURLToPath } from "node:url";
10
+ import { createChecker } from "../lib/check.mjs";
11
11
 
12
12
  const HERE = dirname(fileURLToPath(import.meta.url));
13
13
  const {
@@ -20,11 +20,7 @@ const {
20
20
  MAX_PRINT_LINE,
21
21
  } = await import(join(HERE, "latex-log.ts"));
22
22
 
23
- let n = 0;
24
- const check = (label, cond) => {
25
- assert.ok(cond, label);
26
- n++;
27
- };
23
+ const check = createChecker();
28
24
 
29
25
  // ── the 79-column wrap ──────────────────────────────────────────────────────────────────
30
26
  // A package warning long enough that "Rerun to get" crosses column 79. A real acmart log shows the
@@ -221,6 +217,35 @@ check(
221
217
  .citations.length === 1,
222
218
  );
223
219
 
220
+ check(
221
+ "an \\@input aux that is not there (the default reader finds nothing) is skipped",
222
+ JSON.stringify(auxBib("\\@input{gone.aux}\n\\citation{x}\n").citations) ===
223
+ JSON.stringify(["x"]),
224
+ );
225
+ check(
226
+ "a log whose LAST line is exactly 79 bytes keeps it (nothing follows to join it to)",
227
+ JSON.stringify(unwrapLog(`a\n${"x".repeat(79)}`)) ===
228
+ JSON.stringify(["a", "x".repeat(79)]),
229
+ );
230
+ check(
231
+ "an error path must hold a dot or a slash; a bare word before the line number is not one",
232
+ isErrorLine("paper.tex:4: Undefined control sequence.") &&
233
+ isErrorLine("chapters/intro:9: x") &&
234
+ !isErrorLine("paper:4: x") &&
235
+ !isErrorLine("two words.tex:4: x"),
236
+ );
237
+ {
238
+ const flood = [
239
+ "./paper.tex:1: Undefined control sequence.",
240
+ ...Array.from({ length: 20 }, (_, i) => `noise ${String(i)}`),
241
+ "l.1 \\foo",
242
+ ];
243
+ check(
244
+ "an excerpt stops at MAX_EXCERPT lines even before the l.NNN line arrives",
245
+ errorExcerpt(flood).length === 12 && errorExcerpt(flood)[11] === "noise 10",
246
+ );
247
+ }
248
+
224
249
  console.log(
225
- `✓ ${String(n)} assertions passed — latex-log: markers across the 79-column wrap, both error spellings, aux`,
250
+ `✓ ${String(check.count)} assertions passed — latex-log: markers across the 79-column wrap, both error spellings, aux`,
226
251
  );
package/src/latex-log.ts CHANGED
@@ -39,13 +39,6 @@ export type LogMarker =
39
39
  | "undefined-references"
40
40
  | "undefined-citations";
41
41
 
42
- /** Markers that mean "the output of this pass is not final yet". */
43
- export const RERUN_MARKERS: readonly LogMarker[] = [
44
- "rerun-requested",
45
- "labels-changed",
46
- "rerunfilecheck",
47
- ];
48
-
49
42
  /**
50
43
  * Rejoin the lines TeX broke at column 79. A line of exactly 79 bytes continues on the next one.
51
44
  *
@@ -100,9 +93,8 @@ export function logMarkers(lines: readonly string[]): LogMarker[] {
100
93
  * contain a dot or a slash, so an ordinary warning ending in a number and a colon cannot pass.
101
94
  */
102
95
  export function isErrorLine(line: string): boolean {
103
- if (line.startsWith("!")) return true;
104
- const m = /^([^\s:]+):(\d+): /.exec(line);
105
- return m !== null && /[./]/.test(m[1] ?? "");
96
+ // `<path>:<line>: `, where the path (no space, no colon) holds at least one dot or slash.
97
+ return line.startsWith("!") || /^[^\s:]*[./][^\s:]*:\d+: /.test(line);
106
98
  }
107
99
 
108
100
  /** The `l.NNN …` line TeX prints to show where in the source it stopped. */
@@ -121,12 +113,13 @@ export function errorExcerpt(lines: readonly string[]): string[] {
121
113
  const start = lines.findIndex(isErrorLine);
122
114
  if (start < 0) return [];
123
115
  const out: string[] = [];
124
- for (let i = start; i < lines.length && out.length < MAX_EXCERPT; i++) {
125
- const line = lines[i] ?? "";
116
+ const rest = lines.slice(start);
117
+ for (const [i, line] of rest.entries()) {
118
+ if (out.length >= MAX_EXCERPT) break;
126
119
  if (line.trim() === "") continue;
127
120
  out.push(line);
128
121
  if (isContextLine(line)) {
129
- const next = lines[i + 1];
122
+ const next = rest[i + 1];
130
123
  if (next !== undefined && next.trim() !== "") out.push(next);
131
124
  break;
132
125
  }
@@ -8,20 +8,16 @@
8
8
  * The decision is pure, so none of this needs TeX. The real-pdflatex half lives in
9
9
  * `test/e2e/build.mjs`.
10
10
  */
11
- import assert from "node:assert/strict";
12
11
  import { dirname, join } from "node:path";
13
12
  import { fileURLToPath } from "node:url";
13
+ import { createChecker } from "../lib/check.mjs";
14
14
 
15
15
  const HERE = dirname(fileURLToPath(import.meta.url));
16
16
  const { nextStep, summarize, changedFiles, MAX_PASSES } = await import(
17
17
  join(HERE, "latex-loop.ts")
18
18
  );
19
19
 
20
- let n = 0;
21
- const check = (label, cond) => {
22
- assert.ok(cond, label);
23
- n++;
24
- };
20
+ const check = createChecker();
25
21
 
26
22
  // ── builders ────────────────────────────────────────────────────────────────────────────
27
23
  const H = (aux, toc = null, out = null, bbl = null) => ({ aux, toc, out, bbl });
@@ -444,6 +440,11 @@ check(
444
440
  changedFiles(H(null), H("a1")).includes("aux"),
445
441
  );
446
442
 
443
+ check(
444
+ "a history of only a bibtex run that left every file as it was: nothing unsettled",
445
+ JSON.stringify(summarize([bibtex({ after: H("a1") })]).unsettled) === "[]",
446
+ );
447
+
447
448
  console.log(
448
- `✓ ${String(n)} assertions passed — latex-loop: nextStep over State, summarize over histories, and the two composed, including the ${MAX_PASSES}-pass cap`,
449
+ `✓ ${String(check.count)} assertions passed — latex-loop: nextStep over State, summarize over histories, and the two composed, including the ${MAX_PASSES}-pass cap`,
449
450
  );
@@ -13,9 +13,7 @@
13
13
  * consumer.mjs — the same constant the linker and the install e2e read.
14
14
  *
15
15
  * Run: node src/link-skills.harness.mjs
16
- * Killed by: src/link-skills.mutations.mjs
17
16
  */
18
- import assert from "node:assert/strict";
19
17
  import {
20
18
  existsSync,
21
19
  mkdirSync,
@@ -30,6 +28,7 @@ import {
30
28
  import { tmpdir } from "node:os";
31
29
  import { dirname, isAbsolute, join } from "node:path";
32
30
  import { fileURLToPath } from "node:url";
31
+ import { createChecker } from "../lib/check.mjs";
33
32
 
34
33
  const HERE = dirname(fileURLToPath(import.meta.url));
35
34
  const { linkSkills, locatePackage, shippedSkills } = await import(
@@ -39,11 +38,7 @@ const { SHIPPED_SKILLS_DIR: SHIPS, PACKAGE_NAME: PKG } = await import(
39
38
  join(HERE, "..", "skills", "paper-pipeline", "scripts", "consumer.mjs")
40
39
  );
41
40
 
42
- let n = 0;
43
- const check = (label, cond) => {
44
- n++;
45
- assert.ok(cond, label);
46
- };
41
+ const check = createChecker();
47
42
 
48
43
  const SKILLS = ["alpha", "beta", "gamma"];
49
44
  const work = realpathSync(
@@ -234,5 +229,5 @@ try {
234
229
  }
235
230
 
236
231
  console.log(
237
- `✓ ${n} assertions passed — link-skills: the skills are where Claude Code looks`,
232
+ `✓ ${check.count} assertions passed — link-skills: the skills are where Claude Code looks`,
238
233
  );