paperlint 3.1.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/.github/workflows/ci.yml +28 -41
  2. package/.github/workflows/dependabot-automerge.yml +1 -1
  3. package/CLAUDE.md +21 -105
  4. package/CONTRIBUTING.md +23 -25
  5. package/README.md +11 -7
  6. package/bin/paperlint.test.mjs +45 -0
  7. package/dist/adapters/node/files.io.d.ts +2 -0
  8. package/dist/adapters/node/files.io.d.ts.map +1 -1
  9. package/dist/adapters/node/files.io.js +3 -2
  10. package/dist/adapters/node/files.io.js.map +1 -1
  11. package/dist/adapters/pdfjs/fill.d.ts +15 -4
  12. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  13. package/dist/adapters/pdfjs/fill.js +31 -21
  14. package/dist/adapters/pdfjs/fill.js.map +1 -1
  15. package/dist/adapters/references/clock.io.d.ts +5 -0
  16. package/dist/adapters/references/clock.io.d.ts.map +1 -0
  17. package/dist/adapters/references/clock.io.js +7 -0
  18. package/dist/adapters/references/clock.io.js.map +1 -0
  19. package/dist/adapters/references/index.d.ts +24 -2
  20. package/dist/adapters/references/index.d.ts.map +1 -1
  21. package/dist/adapters/references/index.js +131 -12
  22. package/dist/adapters/references/index.js.map +1 -1
  23. package/dist/adapters/references/pool.d.ts +8 -0
  24. package/dist/adapters/references/pool.d.ts.map +1 -0
  25. package/dist/adapters/references/pool.js +19 -0
  26. package/dist/adapters/references/pool.js.map +1 -0
  27. package/dist/build.d.ts +14 -2
  28. package/dist/build.d.ts.map +1 -1
  29. package/dist/build.js +17 -11
  30. package/dist/build.js.map +1 -1
  31. package/dist/cli.d.ts +27 -1
  32. package/dist/cli.d.ts.map +1 -1
  33. package/dist/cli.js +71 -55
  34. package/dist/cli.js.map +1 -1
  35. package/dist/doctor.d.ts +27 -1
  36. package/dist/doctor.d.ts.map +1 -1
  37. package/dist/doctor.js +4 -3
  38. package/dist/doctor.js.map +1 -1
  39. package/dist/domain/lookup-cache.d.ts +75 -0
  40. package/dist/domain/lookup-cache.d.ts.map +1 -0
  41. package/dist/domain/lookup-cache.js +137 -0
  42. package/dist/domain/lookup-cache.js.map +1 -0
  43. package/dist/domain/result.d.ts +0 -2
  44. package/dist/domain/result.d.ts.map +1 -1
  45. package/dist/domain/result.js +0 -4
  46. package/dist/domain/result.js.map +1 -1
  47. package/dist/hooks-settings.d.ts +8 -5
  48. package/dist/hooks-settings.d.ts.map +1 -1
  49. package/dist/hooks-settings.js +32 -32
  50. package/dist/hooks-settings.js.map +1 -1
  51. package/dist/init.d.ts +8 -0
  52. package/dist/init.d.ts.map +1 -1
  53. package/dist/init.js +3 -3
  54. package/dist/init.js.map +1 -1
  55. package/dist/latex-log.d.ts +0 -2
  56. package/dist/latex-log.d.ts.map +1 -1
  57. package/dist/latex-log.js +7 -13
  58. package/dist/latex-log.js.map +1 -1
  59. package/dist/link-skills.d.ts +15 -2
  60. package/dist/link-skills.d.ts.map +1 -1
  61. package/dist/link-skills.js +36 -20
  62. package/dist/link-skills.js.map +1 -1
  63. package/dist/paper-files.d.ts.map +1 -1
  64. package/dist/paper-files.js +6 -4
  65. package/dist/paper-files.js.map +1 -1
  66. package/dist/pdf-facts.d.ts +11 -0
  67. package/dist/pdf-facts.d.ts.map +1 -1
  68. package/dist/pdf-facts.js +16 -18
  69. package/dist/pdf-facts.js.map +1 -1
  70. package/dist/pdf-geometry.js +1 -1
  71. package/dist/pdf-geometry.js.map +1 -1
  72. package/dist/ports/check-references.d.ts +12 -2
  73. package/dist/ports/check-references.d.ts.map +1 -1
  74. package/dist/ports/check-references.js +0 -5
  75. package/dist/ports/check-references.js.map +1 -1
  76. package/dist/presets.d.ts +2 -0
  77. package/dist/presets.d.ts.map +1 -1
  78. package/dist/presets.js +3 -1
  79. package/dist/presets.js.map +1 -1
  80. package/dist/references.d.ts +11 -3
  81. package/dist/references.d.ts.map +1 -1
  82. package/dist/references.js +63 -15
  83. package/dist/references.js.map +1 -1
  84. package/dist/tex-requirements.d.ts +8 -0
  85. package/dist/tex-requirements.d.ts.map +1 -1
  86. package/dist/tex-requirements.js +6 -5
  87. package/dist/tex-requirements.js.map +1 -1
  88. package/dist/toolchain.d.ts +3 -2
  89. package/dist/toolchain.d.ts.map +1 -1
  90. package/dist/toolchain.js +4 -5
  91. package/dist/toolchain.js.map +1 -1
  92. package/dist/venue-rules.d.ts.map +1 -1
  93. package/dist/venue-rules.js +11 -9
  94. package/dist/venue-rules.js.map +1 -1
  95. package/docs/prior-art/README.md +11 -10
  96. package/docs/prior-art/citation-checkers.md +174 -0
  97. package/docs/prior-art/test-tooling.md +7 -30
  98. package/docs/references.md +126 -0
  99. package/docs/testing.md +118 -0
  100. package/docs/toolchain.md +6 -4
  101. package/eslint-rules/install-path-literals.harness.mjs +17 -1
  102. package/eslint-rules/latex-language.harness.mjs +3 -3
  103. package/eslint-rules/latex-language.mjs +12 -9
  104. package/eslint-rules/latex-language.test.mjs +200 -0
  105. package/eslint-rules/paper-research-question.harness.mjs +73 -7
  106. package/eslint-rules/paper-stages.harness.mjs +9 -14
  107. package/eslint-rules/paper-stages.test.mjs +135 -0
  108. package/eslint-rules/paper-typography.mjs +9 -2
  109. package/eslint-rules/paper-typography.test.mjs +146 -1
  110. package/eslint-rules/pdf-last-page-balance.harness.mjs +63 -7
  111. package/eslint-rules/review-frontmatter.mjs +2 -1
  112. package/eslint-rules/review-frontmatter.test.mjs +37 -1
  113. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  114. package/eslint-rules/tex-build.mjs +13 -13
  115. package/eslint.config.mjs +6 -2
  116. package/hooks/hooks.harness.mjs +6 -11
  117. package/hooks/paper-edit-guard.hook.mjs +2 -2
  118. package/hooks/paper-edit-guard.test.mjs +61 -0
  119. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  120. package/hooks/paper-skills-nudge.test.mjs +38 -0
  121. package/hooks/paper-status-gates.test.mjs +42 -0
  122. package/lib/agent-cli-version.harness.mjs +7 -0
  123. package/lib/check.d.mts +17 -0
  124. package/lib/check.mjs +50 -0
  125. package/lib/check.test.ts +67 -0
  126. package/lib/markdown.mjs +16 -69
  127. package/lib/markdown.test.mjs +246 -0
  128. package/lib/paper-config.harness.mjs +28 -9
  129. package/lib/paper-config.mjs +7 -2
  130. package/lib/skill-checks.mjs +18 -46
  131. package/lib/skill-checks.test.mjs +297 -0
  132. package/lib/skill-corpus.mjs +11 -13
  133. package/lib/skill-corpus.test.mjs +54 -0
  134. package/lib/skill-trigger-cases.harness.mjs +57 -4
  135. package/package.json +6 -5
  136. package/scripts/check.harness.mjs +4 -10
  137. package/scripts/check.mjs +45 -42
  138. package/scripts/check.test.mjs +104 -0
  139. package/scripts/coverage-config.test.ts +406 -0
  140. package/scripts/eslint-report-guard.test.mjs +48 -0
  141. package/scripts/harness-api.frozen.json +0 -2
  142. package/scripts/layer-legacy-frozen.d.mts +9 -1
  143. package/scripts/layer-legacy-frozen.mjs +28 -11
  144. package/scripts/layer-legacy-frozen.test.ts +143 -1
  145. package/scripts/rules-are-content-only.harness.mjs +3 -11
  146. package/scripts/rules-are-content-only.mjs +29 -19
  147. package/scripts/rules-are-content-only.test.mjs +61 -0
  148. package/scripts/rules-see-files.mjs +11 -9
  149. package/scripts/rules-see-files.test.mjs +71 -0
  150. package/scripts/test.mjs +44 -0
  151. package/scripts/test.test.mjs +52 -0
  152. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  153. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  154. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  155. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  156. package/skills/paper-pipeline/references/artifact-checklist.md +7 -7
  157. package/skills/paper-pipeline/scripts/README.md +13 -132
  158. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  159. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  160. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  161. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  162. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  163. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  164. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  165. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  166. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  167. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  168. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  169. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  170. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  171. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  172. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
  173. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  174. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  175. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  176. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  177. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  178. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  179. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  180. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  181. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  182. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  183. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  184. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  185. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  186. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  187. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  188. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  189. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  190. package/skills/render-paper/checkers-requirements.txt +30 -19
  191. package/skills/render-paper/ensure-checkers.sh +20 -4
  192. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  193. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  194. package/skills/tighten-paper/structure.test.mjs +116 -0
  195. package/skills/verify-citations/SKILL.md +30 -14
  196. package/skills/verify-citations/SKILL.md.spec.ts +29 -13
  197. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +253 -0
  198. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  199. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  200. package/skills/verify-citations/scripts/bib-authors.mjs +6 -6
  201. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  202. package/skills/verify-citations/scripts/verify-cites.mjs +360 -215
  203. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +588 -0
  204. package/skills/verify-citations/scripts/verify-cites.test.mjs +151 -5
  205. package/src/adapters/banal/failure.test.ts +70 -1
  206. package/src/adapters/banal/index.test.ts +16 -0
  207. package/src/adapters/banal/output.test.ts +5 -0
  208. package/src/adapters/curl/download.test.ts +43 -1
  209. package/src/adapters/memory/index.test.ts +25 -0
  210. package/src/adapters/node/files.io.ts +3 -2
  211. package/src/adapters/node/files.test.ts +13 -1
  212. package/src/adapters/node/process.test.ts +38 -0
  213. package/src/adapters/pdfjs/fill.test.ts +34 -0
  214. package/src/adapters/pdfjs/fill.ts +44 -25
  215. package/src/adapters/references/clock.io.ts +8 -0
  216. package/src/adapters/references/index.test.ts +560 -0
  217. package/src/adapters/references/index.ts +203 -19
  218. package/src/adapters/references/pool.test.ts +31 -0
  219. package/src/adapters/references/pool.ts +24 -0
  220. package/src/build-engine.harness.mjs +3 -7
  221. package/src/build-engine.test.ts +137 -0
  222. package/src/build.harness.mjs +5 -9
  223. package/src/build.test.ts +362 -0
  224. package/src/build.ts +23 -12
  225. package/src/cli.harness.mjs +10 -17
  226. package/src/cli.test.ts +584 -0
  227. package/src/cli.ts +112 -66
  228. package/src/doctor.harness.mjs +3 -8
  229. package/src/doctor.test.ts +64 -0
  230. package/src/doctor.ts +12 -4
  231. package/src/domain/lookup-cache.test.ts +178 -0
  232. package/src/domain/lookup-cache.ts +202 -0
  233. package/src/domain/result.test.ts +4 -0
  234. package/src/domain/result.ts +0 -5
  235. package/src/engine.harness.mjs +27 -7
  236. package/src/facts-file.test.ts +110 -0
  237. package/src/hooks-settings.harness.mjs +43 -13
  238. package/src/hooks-settings.ts +41 -35
  239. package/src/init.test.ts +308 -5
  240. package/src/init.ts +3 -3
  241. package/src/latex-log.harness.mjs +32 -7
  242. package/src/latex-log.ts +6 -13
  243. package/src/latex-loop.harness.mjs +8 -7
  244. package/src/link-skills.harness.mjs +3 -8
  245. package/src/link-skills.test.ts +105 -0
  246. package/src/link-skills.ts +75 -21
  247. package/src/new-paper.harness.mjs +3 -7
  248. package/src/new-paper.test.ts +31 -1
  249. package/src/paper-files.test.ts +12 -0
  250. package/src/paper-files.ts +9 -5
  251. package/src/paper-settings.test.ts +20 -0
  252. package/src/pdf-facts.harness.mjs +3 -6
  253. package/src/pdf-facts.test.ts +144 -0
  254. package/src/pdf-facts.ts +17 -19
  255. package/src/pdf-geometry.harness.mjs +3 -7
  256. package/src/pdf-geometry.ts +1 -1
  257. package/src/ports/check-references.ts +16 -2
  258. package/src/presets.test.ts +2 -0
  259. package/src/presets.ts +7 -1
  260. package/src/references.test.ts +257 -10
  261. package/src/references.ts +93 -17
  262. package/src/rules-config.harness.mjs +10 -8
  263. package/src/structure.harness.mjs +3 -7
  264. package/src/tex-requirements.harness.mjs +34 -7
  265. package/src/tex-requirements.ts +12 -6
  266. package/src/toolchain.harness.mjs +3 -7
  267. package/src/toolchain.test.ts +280 -0
  268. package/src/toolchain.ts +8 -7
  269. package/src/venue-rules.test.ts +60 -0
  270. package/src/venue-rules.ts +13 -11
  271. package/action.mutations.mjs +0 -162
  272. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  273. package/eslint-rules/latex-language.mutations.mjs +0 -196
  274. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  275. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  276. package/eslint-rules/papers.mutations.mjs +0 -186
  277. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  278. package/eslint-rules/tex-build.mutations.mjs +0 -258
  279. package/hooks/hooks.mutations.mjs +0 -337
  280. package/lib/agent-cli-version.mutations.mjs +0 -109
  281. package/lib/mutation-driver.harness.mjs +0 -227
  282. package/lib/mutation-driver.mjs +0 -397
  283. package/lib/mutation-driver.mutations.mjs +0 -68
  284. package/lib/paper-config.mutations.mjs +0 -143
  285. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  286. package/scripts/check.mutations.mjs +0 -110
  287. package/scripts/exclusive.mjs +0 -138
  288. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  289. package/scripts/mutation-batteries-frozen.mjs +0 -238
  290. package/scripts/mutation-batteries.frozen.json +0 -108
  291. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  292. package/scripts/rules-see-files.mutations.mjs +0 -131
  293. package/scripts/run-mutations.mjs +0 -100
  294. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  295. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  296. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  297. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  298. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  299. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  300. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  301. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  302. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  303. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  304. package/skills/skill-contract.mutations.mjs +0 -179
  305. package/src/build.mutations.mjs +0 -196
  306. package/src/cli.mutations.mjs +0 -414
  307. package/src/doctor.mutations.mjs +0 -175
  308. package/src/hooks-settings.mutations.mjs +0 -99
  309. package/src/link-skills.mutations.mjs +0 -140
  310. package/src/new-paper.mutations.mjs +0 -65
  311. package/src/structure.mutations.mjs +0 -83
@@ -0,0 +1,406 @@
1
+ /**
2
+ * The exclusions in `.c8rc.json` are CLAIMS about the excluded files, and each claim is checked
3
+ * here — otherwise an exclusion is the quiet way to make coverage go up.
4
+ *
5
+ * - `typeOnly`: files with no runtime code. c8's `all` counts every line of a file no test
6
+ * loaded, and a types-only module compiles to nothing, so it can never be "covered". The
7
+ * claim is checked on the syntax tree: a file listed here that grows a function or a constant
8
+ * fails this test and must leave the list.
9
+ * - `evalOnly`: support for the PAID `*.eval.mjs` evaluations, which `npm test` never runs. The
10
+ * claim is that nothing but an eval (or another eval-only module) imports them.
11
+ *
12
+ * The floor itself is pinned: lines, statements, functions and branches are all 100, `npm run
13
+ * coverage` checks them, and CI runs that script — lowering any of the three links fails here.
14
+ *
15
+ * Two more gates close the ways around a 100% threshold (owner's rule for #52):
16
+ * - no measured source carries a coverage-ignore comment (`c8`/`v8`/`istanbul`/`node:coverage
17
+ * ignore`) — an effect a test cannot reach is made injectable instead;
18
+ * - the exclude list is PINNED: a new pattern fails here until this file names it, with its
19
+ * reason, on purpose.
20
+ */
21
+ import assert from "node:assert/strict";
22
+ import { spawnSync } from "node:child_process";
23
+ import { globSync, readFileSync } from "node:fs";
24
+ import { dirname, join, matchesGlob, resolve } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ // @ts-expect-error — js-yaml ships no types, and the package does not depend on @types/js-yaml
27
+ import yaml from "js-yaml";
28
+ import ts from "typescript";
29
+ import { test } from "vitest";
30
+
31
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
32
+ const CONFIG = JSON.parse(readFileSync(join(ROOT, ".c8rc.json"), "utf8")) as {
33
+ lines: number;
34
+ statements: number;
35
+ functions: number;
36
+ branches: number;
37
+ excludeAfterRemap: boolean;
38
+ include: string[];
39
+ exclude: string[];
40
+ extension: string[];
41
+ typeOnly: string[];
42
+ evalOnly: string[];
43
+ };
44
+
45
+ const expand = (patterns: readonly string[]): string[] =>
46
+ patterns.flatMap((p) => globSync(p, { cwd: ROOT })).sort();
47
+
48
+ /** Statements that emit JavaScript — anything but interfaces, type aliases and type-only imports/exports. */
49
+ export function runtimeStatements(fileName: string, source: string): string[] {
50
+ const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest);
51
+ return sf.statements
52
+ .filter(
53
+ (s) =>
54
+ !ts.isInterfaceDeclaration(s) &&
55
+ !ts.isTypeAliasDeclaration(s) &&
56
+ !(ts.isImportDeclaration(s) && s.importClause?.isTypeOnly) &&
57
+ !(ts.isExportDeclaration(s) && s.isTypeOnly),
58
+ )
59
+ .map((s) => ts.SyntaxKind[s.kind]);
60
+ }
61
+
62
+ /** Every relative module specifier a source imports, statically or with `import("…")`. */
63
+ export function importedPaths(fileName: string, source: string): string[] {
64
+ const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest);
65
+ const found: string[] = [];
66
+ const visit = (node: ts.Node): void => {
67
+ const spec = ts.isImportDeclaration(node)
68
+ ? node.moduleSpecifier
69
+ : ts.isCallExpression(node) &&
70
+ node.expression.kind === ts.SyntaxKind.ImportKeyword
71
+ ? node.arguments[0]
72
+ : undefined;
73
+ if (spec && ts.isStringLiteral(spec) && spec.text.startsWith("."))
74
+ found.push(resolve(dirname(fileName), spec.text));
75
+ ts.forEachChild(node, visit);
76
+ };
77
+ visit(sf);
78
+ return found;
79
+ }
80
+
81
+ test("the named exclusion lists are part of c8's exclude, and match files", () => {
82
+ for (const list of [CONFIG.typeOnly, CONFIG.evalOnly]) {
83
+ assert.deepEqual(
84
+ list.filter((p) => !CONFIG.exclude.includes(p)),
85
+ [],
86
+ );
87
+ assert.ok(expand(list).length >= list.length);
88
+ }
89
+ });
90
+
91
+ test("runtimeStatements tells a types-only module from one with code", () => {
92
+ assert.deepEqual(
93
+ [
94
+ runtimeStatements(
95
+ "a.ts",
96
+ 'import type { X } from "./x";\nexport type Y = X;\ninterface Z {}\n',
97
+ ),
98
+ runtimeStatements("b.ts", "export type Y = 1;\nexport const y = 1;\n"),
99
+ ],
100
+ [[], ["FirstStatement"]],
101
+ );
102
+ });
103
+
104
+ test("every typeOnly exclusion has no runtime code", () => {
105
+ const offenders = expand(CONFIG.typeOnly)
106
+ .map(
107
+ (f) =>
108
+ [f, runtimeStatements(f, readFileSync(join(ROOT, f), "utf8"))] as const,
109
+ )
110
+ .filter(([, rt]) => rt.length > 0);
111
+ assert.deepEqual(offenders, []);
112
+ });
113
+
114
+ test("every evalOnly exclusion is imported only by an eval or another evalOnly module", () => {
115
+ const evalOnly = new Set(expand(CONFIG.evalOnly).map((f) => join(ROOT, f)));
116
+ const sources = globSync("**/*.{mjs,ts}", {
117
+ cwd: ROOT,
118
+ exclude: (p) => p === "node_modules" || p === "dist" || p === "coverage",
119
+ });
120
+ const importers = sources.flatMap((f) => {
121
+ const abs = join(ROOT, f);
122
+ return importedPaths(abs, readFileSync(abs, "utf8"))
123
+ .filter((target) => evalOnly.has(target))
124
+ .map(() => f);
125
+ });
126
+ const outsiders = importers.filter(
127
+ (f) => !f.endsWith(".eval.mjs") && !evalOnly.has(join(ROOT, f)),
128
+ );
129
+ assert.deepEqual(
130
+ { outsiders, anyImporter: importers.length > 0 },
131
+ {
132
+ outsiders: [],
133
+ anyImporter: true,
134
+ },
135
+ );
136
+ });
137
+
138
+ /** Every tracked file c8 could load as code — the extensions it is configured to measure. */
139
+ function trackedSources(): string[] {
140
+ const r = spawnSync("git", ["ls-files", "-z"], {
141
+ cwd: ROOT,
142
+ encoding: "utf8",
143
+ });
144
+ assert.equal(r.status, 0, r.stderr);
145
+ return r.stdout
146
+ .split("\0")
147
+ .filter((f) => CONFIG.extension.some((x) => f.endsWith(x)))
148
+ .sort();
149
+ }
150
+ const isIncluded = (f: string): boolean =>
151
+ CONFIG.include.some((p) => matchesGlob(f, p));
152
+ const isExcluded = (f: string): boolean =>
153
+ CONFIG.exclude.some((p) => matchesGlob(f, p));
154
+ const isMeasured = (f: string): boolean => isIncluded(f) && !isExcluded(f);
155
+
156
+ /** The files c8 measures: `include` globs, the configured extensions, minus every `exclude`. */
157
+ function measuredFiles(): string[] {
158
+ return [...new Set(CONFIG.include.flatMap((p) => globSync(p, { cwd: ROOT })))]
159
+ .filter((f) => CONFIG.extension.some((x) => f.endsWith(x)))
160
+ .filter((f) => !CONFIG.exclude.some((x) => matchesGlob(f, x)))
161
+ .sort();
162
+ }
163
+
164
+ /**
165
+ * The text of every comment in a JS/TS source, read off the parsed tree — not by grep, which
166
+ * cannot tell a comment from a string or a regex. Every comment is trivia around a token — leading
167
+ * the token after it, or trailing the one before it on the same line — so the comment ranges
168
+ * around every token (end-of-file included) are all of them.
169
+ */
170
+ export function comments(fileName: string, source: string): string[] {
171
+ const sf = ts.createSourceFile(
172
+ fileName,
173
+ source,
174
+ ts.ScriptTarget.Latest,
175
+ true,
176
+ fileName.endsWith(".ts") ? ts.ScriptKind.TS : ts.ScriptKind.JS,
177
+ );
178
+ const found = new Map<number, string>();
179
+ const visit = (node: ts.Node): void => {
180
+ if (ts.isJSDoc(node)) return; // its text is the comment already collected before its node
181
+ const kids = node.getChildren(sf);
182
+ if (kids.length > 0) return kids.forEach(visit);
183
+ for (const r of [
184
+ ...(ts.getLeadingCommentRanges(source, node.pos) ?? []),
185
+ ...(ts.getTrailingCommentRanges(source, node.end) ?? []),
186
+ ])
187
+ found.set(r.pos, source.slice(r.pos, r.end));
188
+ };
189
+ visit(sf);
190
+ return [...found.entries()].sort(([a], [b]) => a - b).map(([, text]) => text);
191
+ }
192
+
193
+ const IGNORE = /\b(?:c8|v8|istanbul|node:coverage)\s+ignore\b/;
194
+
195
+ test("comments() finds comments and only comments — before a closing brace, in a template, at the end", () => {
196
+ assert.deepEqual(
197
+ comments(
198
+ "a.ts",
199
+ [
200
+ 'const s = "/* c8 ignore next */";',
201
+ "const r = /x\\/y/; // v8 ignore",
202
+ "const t = `${1 /* in a template */}`;",
203
+ "try {",
204
+ " f();",
205
+ " /* c8 ignore start */",
206
+ "} catch {}",
207
+ "/* at the end */",
208
+ ].join("\n"),
209
+ ),
210
+ [
211
+ "// v8 ignore",
212
+ "/* in a template */",
213
+ "/* c8 ignore start */",
214
+ "/* at the end */",
215
+ ],
216
+ );
217
+ });
218
+
219
+ test("no measured source carries a coverage-ignore comment", () => {
220
+ const files = measuredFiles();
221
+ // The scan covers exactly what c8 measures: the same set, derived from the tracked files.
222
+ assert.deepEqual(files, trackedSources().filter(isMeasured));
223
+ assert.ok(files.length > 0);
224
+ const offenders = files.filter((f) =>
225
+ comments(f, readFileSync(join(ROOT, f), "utf8")).some((c) =>
226
+ IGNORE.test(c),
227
+ ),
228
+ );
229
+ assert.deepEqual(offenders, []);
230
+ });
231
+
232
+ test("the floor is 100 on all four measures, and the script that checks it cannot loosen it", () => {
233
+ assert.deepEqual(
234
+ [CONFIG.lines, CONFIG.statements, CONFIG.functions, CONFIG.branches],
235
+ [100, 100, 100, 100],
236
+ );
237
+ const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as {
238
+ scripts: Record<string, string>;
239
+ };
240
+ // Pinned whole: c8's command-line flags override .c8rc.json, so `--lines 50` or `--exclude "**"`
241
+ // added here would bypass every threshold and the pinned exclude list below.
242
+ assert.equal(
243
+ pkg.scripts["coverage"],
244
+ 'NODE_OPTIONS="--import=./test/coverage-src.mjs $NODE_OPTIONS" c8 --check-coverage npm test --',
245
+ );
246
+ });
247
+
248
+ /** The gates job and the one step of it that runs coverage, read from the parsed workflow. */
249
+ interface Step {
250
+ readonly run?: string;
251
+ readonly if?: unknown;
252
+ readonly "continue-on-error"?: unknown;
253
+ }
254
+ interface Job {
255
+ readonly if?: unknown;
256
+ readonly "continue-on-error"?: unknown;
257
+ readonly steps?: readonly Step[];
258
+ }
259
+ export function coverageSteps(
260
+ workflow: string,
261
+ ): { job: string; definition: Job; step: Step }[] {
262
+ const ci = yaml.load(workflow) as { jobs: Record<string, Job> };
263
+ return Object.entries(ci.jobs).flatMap(([job, definition]) =>
264
+ (definition.steps ?? [])
265
+ .filter((step) => (step.run ?? "").trim().startsWith("npm run coverage"))
266
+ .map((step) => ({ job, definition, step })),
267
+ );
268
+ }
269
+
270
+ const DRAFT_GATE =
271
+ "github.event_name != 'pull_request' || github.event.pull_request.draft == false";
272
+
273
+ /** Why CI's coverage step would not fail the run on a coverage drop — empty when it would. */
274
+ export function coverageStepHoles(workflow: string): string[] {
275
+ const found = coverageSteps(workflow);
276
+ if (found.length !== 1)
277
+ return [
278
+ `expected one step running \`npm run coverage\`, found ${String(found.length)}`,
279
+ ];
280
+ const [{ job, definition, step }] = found as [(typeof found)[number]];
281
+ return [
282
+ ...(job === "gates" ? [] : [`the step is in job ${job}, not gates`]),
283
+ ...(definition.if === DRAFT_GATE
284
+ ? []
285
+ : [
286
+ `job ${job} runs if ${JSON.stringify(definition.if)}, not only the draft gate`,
287
+ ]),
288
+ ...("continue-on-error" in definition
289
+ ? [`job ${job} has continue-on-error`]
290
+ : []),
291
+ ...(step.if === undefined
292
+ ? []
293
+ : [`the step has if: ${JSON.stringify(step.if)}`]),
294
+ ...("continue-on-error" in step ? ["the step has continue-on-error"] : []),
295
+ ...(step.run?.trim() === "npm run coverage -- --no-skip"
296
+ ? []
297
+ : [
298
+ `the step runs ${JSON.stringify(step.run)}, not \`npm run coverage -- --no-skip\``,
299
+ ]),
300
+ ];
301
+ }
302
+
303
+ test("CI runs the coverage step in the gates job, unconditionally past the draft gate, and fails on it", () => {
304
+ const ci = readFileSync(join(ROOT, ".github", "workflows", "ci.yml"), "utf8");
305
+ assert.deepEqual(coverageStepHoles(ci), []);
306
+ });
307
+
308
+ test("coverageStepHoles names every way to keep CI green over a coverage drop", () => {
309
+ const wf = (step: string, jobIf = DRAFT_GATE, job = "gates") =>
310
+ [
311
+ "jobs:",
312
+ ` ${job}:`,
313
+ ` if: ${JSON.stringify(jobIf)}`,
314
+ " steps:",
315
+ " - name: coverage",
316
+ ` run: npm run coverage -- --no-skip${step}`,
317
+ ].join("\n");
318
+ assert.deepEqual(
319
+ [
320
+ coverageStepHoles(wf("")),
321
+ coverageStepHoles(wf("\n continue-on-error: true")),
322
+ coverageStepHoles(wf("\n if: false")),
323
+ coverageStepHoles(wf("", "false")),
324
+ coverageStepHoles(wf("", DRAFT_GATE, "other")),
325
+ coverageStepHoles("jobs:\n gates:\n steps: []\n"),
326
+ ],
327
+ [
328
+ [],
329
+ ["the step has continue-on-error"],
330
+ ["the step has if: false"],
331
+ ['job gates runs if "false", not only the draft gate'],
332
+ ["the step is in job other, not gates"],
333
+ ["expected one step running `npm run coverage`, found 0"],
334
+ ],
335
+ );
336
+ });
337
+
338
+ test("dist/ is excluded BEFORE source maps: a direct load is not a second copy of src/", () => {
339
+ // With `true`, a dist/ file remapped through its source map lands under src/ and escapes the
340
+ // `dist/**` exclude — measured: the extract-pdf-facts shim, spawned without NODE_OPTIONS, added a
341
+ // column-shifted duplicate of every function in the src/ modules it imported.
342
+ assert.equal(CONFIG.excludeAfterRemap, false);
343
+ });
344
+
345
+ test("the exclude list is exactly the justified set", () => {
346
+ assert.deepEqual(CONFIG.exclude, [
347
+ // tests themselves
348
+ "**/*.harness.mjs",
349
+ "**/*.test.*",
350
+ // PAID LLM evaluations, never run by `npm test`
351
+ "**/*.eval.mjs",
352
+ // skill sources vigiles compiles into SKILL.md
353
+ "**/*.spec.ts",
354
+ // type declarations
355
+ "**/*.d.ts",
356
+ "**/*.d.mts",
357
+ // inputs the tests read
358
+ "**/fixtures/**",
359
+ // build output (the coverage run resolves it to src/ — test/coverage-src.mjs)
360
+ "dist/**",
361
+ // test support
362
+ "test/**",
363
+ // not sources: dependencies, the report itself
364
+ "node_modules/**",
365
+ "coverage/**",
366
+ // typeOnly — checked above to hold no runtime code
367
+ ...CONFIG.typeOnly,
368
+ // evalOnly — checked above to be imported only by evals
369
+ ...CONFIG.evalOnly,
370
+ // dated research records kept as evidence for the skills' prose and the prior-art notes
371
+ "skills/**/repro/**",
372
+ "skills/**/references/**",
373
+ "docs/prior-art/repro/**",
374
+ // the test runner's own configuration, read by vitest, not code the package runs
375
+ "vitest.config.ts",
376
+ ]);
377
+ assert.deepEqual(CONFIG.include, [
378
+ "bin/**",
379
+ "src/**/*.ts",
380
+ "lib/**",
381
+ "eslint-rules/**",
382
+ "hooks/**",
383
+ "skills/**",
384
+ "scripts/**",
385
+ "eslint.config.mjs",
386
+ ]);
387
+ assert.deepEqual(CONFIG.typeOnly, [
388
+ "src/types.ts",
389
+ "src/ports/*.ts",
390
+ "src/domain/page-layout.ts",
391
+ "src/domain/paths.ts",
392
+ ]);
393
+ assert.deepEqual(CONFIG.evalOnly, [
394
+ "lib/skill-eval-kit.mjs",
395
+ "lib/skill-eval-fixture.mjs",
396
+ "lib/trigger-ledger.mjs",
397
+ ]);
398
+ });
399
+
400
+ test("every tracked source is either measured or excluded by a pattern named above", () => {
401
+ // A new directory outside `include` would otherwise be invisible to c8 and to this file alike.
402
+ assert.deepEqual(
403
+ trackedSources().filter((f) => !isIncluded(f) && !isExcluded(f)),
404
+ [],
405
+ );
406
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The report guard's listing: a file entry without `messages`, a warning, and a message with no
3
+ * rule (ESLint's own parse errors carry `ruleId: null`). The zero-file refusal and the pass-through
4
+ * are driven end to end by `action.harness.mjs`.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { test } from "vitest";
10
+ import { useTempDir } from "../test/support.mjs";
11
+ import { guard } from "./eslint-report-guard.mjs";
12
+
13
+ test("warnings and rule-less messages are listed; a file with no messages counts as linted", () => {
14
+ const report = join(useTempDir("report-guard-"), "report.json");
15
+ writeFileSync(
16
+ report,
17
+ JSON.stringify([
18
+ {
19
+ filePath: "a.md",
20
+ messages: [
21
+ {
22
+ severity: 1,
23
+ line: 2,
24
+ column: 3,
25
+ ruleId: "paper/x",
26
+ message: "soft",
27
+ },
28
+ {
29
+ severity: 2,
30
+ line: 1,
31
+ column: 1,
32
+ ruleId: null,
33
+ message: "Parsing error",
34
+ },
35
+ ],
36
+ },
37
+ { filePath: "b.md" },
38
+ ]),
39
+ );
40
+ assert.deepEqual(guard(report, 1), {
41
+ code: 1,
42
+ lines: [
43
+ "warning a.md:2:3 paper/x soft",
44
+ "error a.md:1:1 (no rule) Parsing error",
45
+ "\nlinted 2 file(s) · 2 finding(s) · 1 error(s) · 1 warning(s)",
46
+ ],
47
+ });
48
+ });
@@ -13,11 +13,9 @@
13
13
  "eslint-rules/tex-build.harness.mjs",
14
14
  "fixtures/real-markdown-paper/real-markdown-paper.harness.mjs",
15
15
  "lib/agent-cli-version.harness.mjs",
16
- "lib/mutation-driver.harness.mjs",
17
16
  "lib/paper-config.harness.mjs",
18
17
  "lib/skill-trigger-cases.harness.mjs",
19
18
  "scripts/check.harness.mjs",
20
- "scripts/mutation-batteries-frozen.harness.mjs",
21
19
  "scripts/rules-are-content-only.harness.mjs",
22
20
  "scripts/rules-see-files.harness.mjs",
23
21
  "skills/analyze-sibling-paper/analyze-sibling-paper.harness.mjs",
@@ -21,8 +21,16 @@ export declare function judge(o: {
21
21
  unexplained: readonly string[];
22
22
  frozen: Counts;
23
23
  }): string[];
24
- export declare function checkFrozen(root?: string): Promise<{
24
+ export declare function checkFrozen(
25
+ root?: string,
26
+ options?: { lint?: (cwd: string) => Promise<unknown[]> },
27
+ ): Promise<{
25
28
  problems: string[];
26
29
  frozen: Counts;
27
30
  counts: Counts;
28
31
  }>;
32
+ export declare function main(options?: {
33
+ check?: () => Promise<{ problems: string[]; frozen: Counts; counts: Counts }>;
34
+ log?: (line: string) => void;
35
+ err?: (line: string) => void;
36
+ }): Promise<number>;
@@ -12,7 +12,7 @@
12
12
  * `reportUnusedDisableDirectives: "error"` already makes a directive that suppresses nothing a lint
13
13
  * error, so a fixed site must drop its comment. What that cannot stop is a NEW directive: a new file,
14
14
  * or a new import in an old one, silenced the same way. This check freezes the per-file counts, in
15
- * the shape `mutation-batteries-frozen.mjs` gives the mutation batteries:
15
+ * a list that may only shrink:
16
16
  *
17
17
  * 1. a file carrying a legacy suppression that is not in the frozen list fails — a new exemption;
18
18
  * 2. a listed file with no legacy suppression left fails — the list must shrink with the code;
@@ -34,6 +34,7 @@
34
34
  *
35
35
  * Run: `node scripts/layer-legacy-frozen.mjs` (also part of `npm run check`)
36
36
  */
37
+ import { isMain } from "../skills/paper-pipeline/scripts/consumer.mjs";
37
38
  import { readFileSync } from "node:fs";
38
39
  import { dirname, join, relative, sep } from "node:path";
39
40
  import { fileURLToPath } from "node:url";
@@ -114,12 +115,16 @@ export function judge({ counts, unexplained, frozen }) {
114
115
  }
115
116
 
116
117
  /** Lint `src/` with the repository's configuration and run the whole check. */
117
- export async function checkFrozen(root = ROOT) {
118
+ export async function checkFrozen(
119
+ root = ROOT,
120
+ { lint = (cwd) => new ESLint({ cwd }).lintFiles(["src/**/*.ts"]) } = {},
121
+ ) {
118
122
  const data = JSON.parse(readFileSync(join(root, FROZEN_FILE), "utf8"));
119
123
  const frozen = data.files ?? {};
120
- const eslint = new ESLint({ cwd: root });
121
- const raw = await eslint.lintFiles(["src/**/*.ts"]);
122
- // Guards: a glob that matched nothing tallies nothing and reports every frozen file as fixed.
124
+ // ESLint itself THROWS when the glob matches nothing ("No files matching 'src/**/*.ts' were
125
+ // found") or only ignored files — measured. An empty result from any other linter (or a
126
+ // configuration that lets it through) must not tally nothing and report every frozen file fixed.
127
+ const raw = await lint(root);
123
128
  if (raw.length === 0)
124
129
  return {
125
130
  problems: ["src/**/*.ts matched no file — nothing was counted."],
@@ -134,19 +139,31 @@ export async function checkFrozen(root = ROOT) {
134
139
  return { problems: judge({ ...t, frozen }), frozen, counts: t.counts };
135
140
  }
136
141
 
137
- if (import.meta.url === `file://${process.argv[1]}`) {
138
- const { problems, counts } = await checkFrozen(ROOT);
142
+ /**
143
+ * The gate: exit 1 naming every problem, or 0 with the count of what stays frozen. `check` is
144
+ * injected so a test can hand it problems without planting suppressions in real source.
145
+ */
146
+ export async function main({
147
+ check = () => checkFrozen(ROOT),
148
+ log = console.log,
149
+ err = console.error,
150
+ } = {}) {
151
+ const { problems, counts } = await check();
139
152
  if (problems.length) {
140
- console.error(
153
+ err(
141
154
  `🔴 legacy layer exemptions are frozen and may only shrink (#76) — ${String(problems.length)} problem(s):`,
142
155
  );
143
- for (const p of problems) console.error(` ${p}`);
144
- process.exit(1);
156
+ for (const p of problems) err(` ${p}`);
157
+ return 1;
145
158
  }
146
159
  const n = Object.values(counts)
147
160
  .flatMap((r) => Object.values(r))
148
161
  .reduce((s, v) => s + v, 0);
149
- console.log(
162
+ log(
150
163
  `✓ ${String(Object.keys(counts).length)} legacy files, ${String(n)} frozen layer exemptions, none new, none grown (#76)`,
151
164
  );
165
+ return 0;
152
166
  }
167
+
168
+ // `isMain`, not a comparison with `file://${argv[1]}`: that is false through a symlink (consumer.mjs).
169
+ if (isMain(import.meta.url)) process.exit(await main());