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
@@ -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
@@ -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
+ });
package/lib/markdown.mjs CHANGED
@@ -49,6 +49,9 @@ try {
49
49
  */
50
50
  export const MD_AVAILABLE = () => md !== null;
51
51
 
52
+ /** markdown-it's block tokens for `text`, or null when it did not resolve. A test passes its own. */
53
+ const defaultParse = () => (md === null ? null : (text) => md.parse(text, {}));
54
+
52
55
  /**
53
56
  * The frontmatter is BLANKED with spaces, not cut out: the offsets must stay the
54
57
  * same, because the checks cut the document by offset. Blanking is necessary — inside
@@ -118,15 +121,17 @@ function lineStarts(text) {
118
121
  * under without a blank line would become a level-2 heading per CommonMark. The former
119
122
  * regexps knew only ATX, so this preserves the contract rather than narrowing it.
120
123
  */
121
- export function headings(text) {
122
- if (md === null) return [];
123
- const toks = md.parse(blankFrontmatter(text), {});
124
+ export function headings(text, { parse = defaultParse() } = {}) {
125
+ if (parse === null) return [];
126
+ const toks = parse(blankFrontmatter(text));
124
127
  const starts = lineStarts(text);
125
128
  const out = [];
126
129
  for (let i = 0; i < toks.length; i++) {
127
130
  const t = toks[i];
128
131
  if (t.type !== "heading_open" || !t.map) continue;
129
132
  if (!t.markup || !t.markup.startsWith("#")) continue; // setext — see the caveat above
133
+ // markdown-it follows `heading_open` with its `inline` token and maps blocks to lines of the
134
+ // parsed text; the fallbacks are for a token stream that does not (another parser version).
130
135
  const inline = toks[i + 1];
131
136
  out.push({
132
137
  depth: Number(t.tag.slice(1)),
@@ -138,64 +143,6 @@ export function headings(text) {
138
143
  return out;
139
144
  }
140
145
 
141
- /** Offsets of the `---` thematic breaks (in this knowledge base — a prose separator). */
142
- export function thematicBreaks(text) {
143
- if (md === null) return [];
144
- const starts = lineStarts(text);
145
- return md
146
- .parse(blankFrontmatter(text), {})
147
- .filter((t) => t.type === "hr" && t.map)
148
- .map((t) => starts[t.map[0]] ?? 0);
149
- }
150
-
151
- /**
152
- * A section's body: from the line AFTER the heading matching `match`, up to the next
153
- * heading of THE SAME OR A LOWER level. `{ heading, body }` or `null`.
154
- *
155
- * `match` — a regex over the heading text OR a predicate over `{depth,text,line,offset}`.
156
- *
157
- * Level-or-lower, not "exactly `##`": a `###` subsection inside the section does not
158
- * end it — that is exactly how the former expressions with the `(?=\n## )` lookahead
159
- * behaved. A regex cannot know this distinction in principle: it has no notion of level.
160
- *
161
- * `depth` — the CEILING on the level of the heading itself. Without it an `### Abstract`
162
- * quoted in an appendix becomes the abstract — and the abstract is judged with a
163
- * threshold of ZERO.
164
- *
165
- * `stopAtRule` additionally cuts the section off at the first `---`. That is NOT markdown
166
- * structure but a paper convention, and it is preserved deliberately: the conclusion has
167
- * a `---` before the appendices, and without that boundary a section with no following
168
- * `##` would reach to the end of the file.
169
- *
170
- * 🔴 A MERGED VERSION. On 2026-08-11 this function appeared SIMULTANEOUSLY in two places:
171
- * here (for `kb-lint`) and in `paper-lint.mjs` (richer — with the level ceiling, the
172
- * heading in the result and `stopAtRule`). Both were written within one hour by two
173
- * parallel passes, neither saw the other. Merged into the rich version: the weak one was
174
- * a subset, and keeping both would have meant diverging in behaviour at the very next edit.
175
- */
176
- export function sectionBody(
177
- text,
178
- match,
179
- { depth = 6, stopAtRule = false } = {},
180
- ) {
181
- const test = typeof match === "function" ? match : (h) => match.test(h.text);
182
- const hs = headings(text);
183
- const i = hs.findIndex((h) => h.depth <= depth && test(h));
184
- if (i === -1) return null;
185
- const h = hs[i];
186
- const next = hs.slice(i + 1).find((n) => n.depth <= h.depth);
187
- let end = next ? next.offset : text.length;
188
- if (stopAtRule) {
189
- const hr = thematicBreaks(text).find((o) => o > h.offset && o < end);
190
- if (hr !== undefined) end = hr;
191
- }
192
- // The body starts on the line AFTER the heading: the heading itself is not part of the
193
- // section, just as the former expressions' group did not capture it.
194
- const nl = text.indexOf("\n", h.offset);
195
- const start = nl === -1 || nl + 1 > end ? end : nl + 1;
196
- return { heading: h, body: text.slice(start, end) };
197
- }
198
-
199
146
  /**
200
147
  * Slicing the document into chunks "a heading + everything up to the next heading FROM
201
148
  * THE SAME RANGE of levels". → `[{ heading, raw, body }]`.
@@ -302,15 +249,15 @@ export function stripFences(text, { blank = false } = {}) {
302
249
  * indistinguishable from "the paper has no sections". A confident zero has already been
303
250
  * read as a finding four times in this repo; here it is inexpressible.
304
251
  */
305
- export function requireMarkdown() {
252
+ export function requireMarkdown({ from = import.meta.url } = {}) {
306
253
  if (MD_AVAILABLE()) return;
307
- // 🔴 THE CURE DEPENDS ON THE DELIVERY CHANNEL, AND NAMING THE WRONG ONE WASTES THE ERROR.
308
- // Measured 2026-09-19: `npm pack` strips `package-lock.json` unconditionally, and a plugin copy
309
- // gets `npm ci` only when a lockfile is present in the fetched root — so a plugin installed from
310
- // npm has NO `node_modules`, with no log entry anywhere. Telling that user to run `npm i at the
311
- // repo root` points at a repository they do not have. The channel is readable from this file's
312
- // own location, so the message can name it instead of guessing.
313
- const fromPluginCache = import.meta.url.includes("/.claude/plugins/");
254
+ // 🔴 THE CURE DEPENDS ON WHERE THIS COPY RUNS FROM. Measured 2026-09-19: a Claude Code plugin
255
+ // copy is installed without dependencies (`npm pack` strips `package-lock.json`, and the plugin
256
+ // cache runs `npm ci` only with a lockfile). The plugin channel was removed in #82, but a copy
257
+ // installed before then still runs from there, and "npm i at the repo root" would point its user
258
+ // at a repository they do not have. The location is readable from this file's own URL (`from`,
259
+ // a parameter only so a test can name a plugin-cache location without copying the file there).
260
+ const fromPluginCache = from.includes("/.claude/plugins/");
314
261
  console.error(
315
262
  "markdown-it does not resolve — this script parses markup with a parser and without it " +
316
263
  "would produce a confident zero instead of an error.\n" +