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
@@ -4,8 +4,18 @@
4
4
  * tally is tested on what the linter reports, not on source text.
5
5
  */
6
6
  import assert from "node:assert/strict";
7
+ import { spawnSync } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
7
9
  import { test } from "vitest";
8
- import { judge, tally, type Suppressed } from "./layer-legacy-frozen.mjs";
10
+ import {
11
+ checkFrozen,
12
+ FROZEN_FILE,
13
+ judge,
14
+ main,
15
+ tally,
16
+ type Suppressed,
17
+ } from "./layer-legacy-frozen.mjs";
18
+ import { useTempDir, writeTree } from "../test/support.mjs";
9
19
 
10
20
  const IO = "legacy I/O, moves behind a port in #76";
11
21
  const LAYER = "legacy layer, moves behind a port in #76";
@@ -113,3 +123,135 @@ test("a layer finding silenced without naming #76 is reported; other rules' supp
113
123
  /src\/build\.ts:4: boundaries\/dependencies is silenced with "temporary"/,
114
124
  );
115
125
  });
126
+
127
+ test("main: problems are listed and fail; a clean count passes", async () => {
128
+ const run = async (r: {
129
+ problems: string[];
130
+ counts: Record<string, Record<string, number>>;
131
+ }) => {
132
+ const out: string[] = [];
133
+ const code = await main({
134
+ check: async () => ({ ...r, frozen: {} }),
135
+ log: (s: string) => out.push(s),
136
+ err: (s: string) => out.push(`E ${s}`),
137
+ });
138
+ return { code, out };
139
+ };
140
+ assert.deepEqual(
141
+ [
142
+ await run({ problems: ["a.ts: grew", "b.ts: new"], counts: {} }),
143
+ await run({
144
+ problems: [],
145
+ counts: { "a.ts": { r: 2 }, "b.ts": { r: 1, s: 1 } },
146
+ }),
147
+ ],
148
+ [
149
+ {
150
+ code: 1,
151
+ out: [
152
+ "E 🔴 legacy layer exemptions are frozen and may only shrink (#76) — 2 problem(s):",
153
+ "E a.ts: grew",
154
+ "E b.ts: new",
155
+ ],
156
+ },
157
+ {
158
+ code: 0,
159
+ out: [
160
+ "✓ 2 legacy files, 4 frozen layer exemptions, none new, none grown (#76)",
161
+ ],
162
+ },
163
+ ],
164
+ );
165
+ });
166
+
167
+ // Lints the whole of src/ with the real config: ~3 s alone, 5.6 s measured under c8 with the
168
+ // suite running in parallel — past vitest's 5 s default, so the budget is stated here.
169
+ test(
170
+ "checkFrozen on this repository: nothing new, nothing grown — and on a tree with no src/, ESLint refuses loudly",
171
+ {
172
+ timeout: 30_000,
173
+ },
174
+ async () => {
175
+ const root = useTempDir("layer-frozen-");
176
+ writeTree(root, {
177
+ [FROZEN_FILE]: JSON.stringify({ files: {} }),
178
+ "eslint.config.mjs": "export default [];\n",
179
+ });
180
+ const here = await checkFrozen();
181
+ await assert.rejects(checkFrozen(root), {
182
+ message: "No files matching 'src/**/*.ts' were found.",
183
+ });
184
+ assert.deepEqual(here.problems, []);
185
+ assert.deepEqual(here.counts, here.frozen);
186
+ },
187
+ );
188
+
189
+ test("checkFrozen: a lint that returns no result reports that nothing was counted", async () => {
190
+ const root = useTempDir("layer-frozen-empty-");
191
+ writeTree(root, {
192
+ [FROZEN_FILE]: JSON.stringify({ files: { "src/a.ts": { x: 1 } } }),
193
+ });
194
+ assert.deepEqual(await checkFrozen(root, { lint: async () => [] }), {
195
+ problems: ["src/**/*.ts matched no file — nothing was counted."],
196
+ frozen: { "src/a.ts": { x: 1 } },
197
+ counts: {},
198
+ });
199
+ });
200
+
201
+ test("tally: optional parts of ESLint's result shape are read as absent", () => {
202
+ const t = tally([
203
+ { filePath: "src/a.ts" },
204
+ {
205
+ filePath: "src/b.ts",
206
+ suppressedMessages: [
207
+ { ruleId: null, line: 1, suppressions: [{ justification: IO }] },
208
+ { ruleId: "boundaries/dependencies", line: 2 },
209
+ { ruleId: "boundaries/dependencies", line: 3, suppressions: [{}] },
210
+ ],
211
+ },
212
+ ]);
213
+ assert.deepEqual(t.counts, {});
214
+ assert.deepEqual(t.unexplained, [
215
+ 'src/b.ts:2: boundaries/dependencies is silenced with "" — a layer exemption must say "legacy I/O|layer, moves behind a port in #76", or the site must be fixed.',
216
+ 'src/b.ts:3: boundaries/dependencies is silenced with "" — a layer exemption must say "legacy I/O|layer, moves behind a port in #76", or the site must be fixed.',
217
+ ]);
218
+ });
219
+
220
+ test("checkFrozen: a frozen file with no `files` freezes nothing", async () => {
221
+ const root = useTempDir("layer-frozen-nofiles-");
222
+ writeTree(root, { [FROZEN_FILE]: "{}\n" });
223
+ const r = await checkFrozen(root, {
224
+ lint: async () => [
225
+ { filePath: `${root}/src/a.ts`, suppressedMessages: [] },
226
+ ],
227
+ });
228
+ assert.deepEqual(r, { problems: [], frozen: {}, counts: {} });
229
+ });
230
+
231
+ test("run as a program: the gate's verdict is the exit code", () => {
232
+ // ESLint is replaced by one whose lint answers nothing, so the real script runs end to end in
233
+ // well under a second and takes its refusal path.
234
+ const fake = encodeURIComponent(
235
+ "export class ESLint { async lintFiles() { return []; } }",
236
+ );
237
+ const hook = encodeURIComponent(
238
+ `export async function resolve(s, c, next) { return s === "eslint" ? { url: "data:text/javascript,${fake}", shortCircuit: true } : next(s, c); }`,
239
+ );
240
+ const preload = `data:text/javascript,${encodeURIComponent(
241
+ `import { register } from "node:module"; register("data:text/javascript,${hook}");`,
242
+ )}`;
243
+ const r = spawnSync(
244
+ process.execPath,
245
+ [
246
+ "--import",
247
+ preload,
248
+ fileURLToPath(new URL("./layer-legacy-frozen.mjs", import.meta.url)),
249
+ ],
250
+ { encoding: "utf8" },
251
+ );
252
+ assert.equal(r.status, 1, r.stderr);
253
+ assert.match(
254
+ r.stderr,
255
+ /1 problem\(s\):\n {3}src\/\*\*\/\*\.ts matched no file — nothing was counted\./,
256
+ );
257
+ });
@@ -12,20 +12,16 @@
12
12
  * III. QUIET on sources that merely TALK about the banned module — a comment and a string
13
13
  * literal. This is the half a grep cannot have, and the reason the check parses.
14
14
  */
15
- import assert from "node:assert/strict";
16
15
  import { dirname, join } from "node:path";
17
16
  import { fileURLToPath } from "node:url";
17
+ import { createChecker } from "../lib/check.mjs";
18
18
 
19
19
  const HERE = dirname(fileURLToPath(import.meta.url));
20
20
  const { rulesAreContentOnly, processImports } = await import(
21
21
  join(HERE, "rules-are-content-only.mjs")
22
22
  );
23
23
 
24
- let n = 0;
25
- const check = (label, ok) => {
26
- n++;
27
- assert.equal(ok, true, label);
28
- };
24
+ const check = createChecker();
29
25
 
30
26
  // ── I. quiet on the real corpus, and it actually looked ───────────────────────
31
27
  {
@@ -38,10 +34,6 @@ const check = (label, ok) => {
38
34
  `quiet on the real corpus (${findings.length} findings)`,
39
35
  findings.length === 0,
40
36
  );
41
- check(
42
- "mutation files are not scanned as rules",
43
- !checked.some((f) => f.endsWith(".mutations.mjs")),
44
- );
45
37
  }
46
38
 
47
39
  // ── II. fires on every form the defect has taken ────────────────────────────
@@ -110,4 +102,4 @@ for (const [label, src] of [
110
102
  check(`quiet on ${label}`, processImports(src).length === 0);
111
103
  }
112
104
 
113
- console.log(`rules-are-content-only.harness: ${n} assertions passed`);
105
+ console.log(`rules-are-content-only.harness: ${check.count} assertions passed`);
@@ -28,7 +28,7 @@
28
28
  * Tested by: `scripts/rules-are-content-only.harness.mjs` — quiet on the real corpus, firing on
29
29
  * a planted rule.
30
30
  */
31
- import { readdirSync, readFileSync } from "node:fs";
31
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
32
32
  import { join } from "node:path";
33
33
  import { parse } from "espree";
34
34
  import { isMain } from "../skills/paper-pipeline/scripts/consumer.mjs";
@@ -59,11 +59,8 @@ const SPAWNERS = new Set([
59
59
  const isGit = (v) =>
60
60
  typeof v === "string" && (v === "git" || v.startsWith("git "));
61
61
 
62
- /** Rule sources only: a mutation file plants defects on purpose, a harness asserts about them. */
63
- const isRuleSource = (f) =>
64
- f.endsWith(".mjs") &&
65
- !f.endsWith(".mutations.mjs") &&
66
- !f.endsWith(".harness.mjs");
62
+ /** Rule sources only: a harness plants defects on purpose and asserts about them. */
63
+ const isRuleSource = (f) => f.endsWith(".mjs") && !f.endsWith(".harness.mjs");
67
64
 
68
65
  /** @param {string} src @returns {string[]} the git invocations this source makes */
69
66
  export function processImports(src) {
@@ -98,7 +95,10 @@ export function rulesAreContentOnly({ cwd }) {
98
95
  const dir = join(cwd, "eslint-rules");
99
96
  const checked = [];
100
97
  const findings = [];
101
- for (const f of readdirSync(dir).filter(isRuleSource)) {
98
+ // A repository with no `eslint-rules/` has no rule sources: `checked` stays empty and the gate
99
+ // says so by name. `readdirSync` would throw ENOENT instead — a stack trace, not the refusal.
100
+ const names = existsSync(dir) ? readdirSync(dir) : [];
101
+ for (const f of names.filter(isRuleSource)) {
102
102
  const hits = processImports(readFileSync(join(dir, f), "utf8"));
103
103
  // 🔴 The counter increments WITH the verdict, not before it: a `checked` that keeps
104
104
  // counting while the verdict stops being reached is the failure recorded in the consumer's
@@ -109,30 +109,40 @@ export function rulesAreContentOnly({ cwd }) {
109
109
  return { checked, findings };
110
110
  }
111
111
 
112
- if (isMain(import.meta.url)) {
113
- // 🔴 `process.cwd()`, NOT the package root. A consumer installs this package and runs the
114
- // script from its own repository, where the rules that matter are ITS `eslint-rules/` — the
115
- // package's own are already checked by the package's own gate. Anchored to the package root
116
- // the consumer's gate would re-check the same eight files and report a confident zero about
117
- // rules it never opened: a counter that counts what it ignores.
118
- const cwd = process.cwd();
112
+ /**
113
+ * The gate over `cwd`'s `eslint-rules/`: exit 1 on no rule sources (a silent zero would read as
114
+ * clean) or on any rule asking git; 0 otherwise.
115
+ *
116
+ * 🔴 `cwd` is `process.cwd()`, NOT the package root. A consumer installs this package and runs the
117
+ * script from its own repository, where the rules that matter are ITS `eslint-rules/` — the
118
+ * package's own are already checked by the package's own gate. Anchored to the package root the
119
+ * consumer's gate would re-check the same eight files and report a confident zero about rules it
120
+ * never opened: a counter that counts what it ignores.
121
+ */
122
+ export function main({
123
+ cwd = process.cwd(),
124
+ log = console.log,
125
+ err = console.error,
126
+ } = {}) {
119
127
  const { checked, findings } = rulesAreContentOnly({ cwd });
120
128
  if (checked.length === 0) {
121
- console.error(
129
+ err(
122
130
  `${join(cwd, "eslint-rules")}: no rule sources. Either this is not a repository with ` +
123
131
  `ESLint rules, or the directory moved — a silent zero here would read as a clean run.`,
124
132
  );
125
- process.exit(1);
133
+ return 1;
126
134
  }
127
135
  for (const { file, specifier } of findings) {
128
- console.error(
136
+ err(
129
137
  `${file}: imports \`${specifier}\`. A rule may not ask git: history is rewritten by ` +
130
138
  `routine maintenance and CI clones one commit deep, so the fact does not survive where ` +
131
139
  `the rule has to hold. Put the fact on disk and check it by bytes.`,
132
140
  );
133
141
  }
134
- console.log(
142
+ log(
135
143
  `rules-are-content-only: ${checked.length} rule sources under ${cwd}, ${findings.length} findings`,
136
144
  );
137
- process.exit(findings.length === 0 ? 0 : 1);
145
+ return findings.length === 0 ? 0 : 1;
138
146
  }
147
+
148
+ if (isMain(import.meta.url)) process.exit(main());
@@ -0,0 +1,61 @@
1
+ /**
2
+ * rules-are-content-only's gate over a consumer's `eslint-rules/`: temp trees with planted rules.
3
+ */
4
+ import assert from "node:assert/strict";
5
+ import { fileURLToPath } from "node:url";
6
+ import { spawnSync } from "node:child_process";
7
+ import { test } from "vitest";
8
+ import { useTempDir, writeTree } from "../test/support.mjs";
9
+ import { main as contentMain } from "./rules-are-content-only.mjs";
10
+
11
+ const capture = () => {
12
+ const out = [];
13
+ const io = { log: (s) => out.push(s), err: (s) => out.push(`E ${s}`) };
14
+ return { out, io };
15
+ };
16
+
17
+ const root = useTempDir("content-only-");
18
+
19
+ test("rules-are-content-only: no rules is a failure, a rule asking git is a finding, clean rules pass", () => {
20
+ writeTree(root, {
21
+ "empty/.keep": "",
22
+ "dirty/eslint-rules/asks-git.mjs":
23
+ 'import { execFileSync } from "node:child_process";\nexecFileSync("git", ["log"]);\n',
24
+ "dirty/eslint-rules/clean.mjs": "export default {};\n",
25
+ "clean/eslint-rules/clean.mjs": "export default {};\n",
26
+ });
27
+ const runs = ["empty", "dirty", "clean"].map((d) => {
28
+ const { out, io } = capture();
29
+ return [
30
+ contentMain({ cwd: `${root}/${d}`, ...io }),
31
+ out.map((l) => l.replace(root, "<root>").split(". ")[0]),
32
+ ];
33
+ });
34
+ assert.deepEqual(runs, [
35
+ [1, ["E <root>/empty/eslint-rules: no rule sources"]],
36
+ [
37
+ 1,
38
+ [
39
+ "E asks-git.mjs: imports `git`",
40
+ "rules-are-content-only: 2 rule sources under <root>/dirty, 1 findings",
41
+ ],
42
+ ],
43
+ [
44
+ 0,
45
+ ["rules-are-content-only: 1 rule sources under <root>/clean, 0 findings"],
46
+ ],
47
+ ]);
48
+ });
49
+
50
+ test("run as a program over this repository: every rule source is content-only, exit 0", () => {
51
+ const r = spawnSync(
52
+ process.execPath,
53
+ [fileURLToPath(new URL("./rules-are-content-only.mjs", import.meta.url))],
54
+ { cwd: fileURLToPath(new URL("..", import.meta.url)), encoding: "utf8" },
55
+ );
56
+ assert.equal(r.status, 0, r.stderr);
57
+ assert.match(
58
+ r.stdout,
59
+ /^rules-are-content-only: \d+ rule sources under .*, 0 findings\n$/,
60
+ );
61
+ });
@@ -79,21 +79,23 @@ export async function rulesSeeFiles({ cwd }) {
79
79
  // a symlink the two are not equal and the CLI silently does not run — the process exits 0 having
80
80
  // done nothing. The consumer reaches these scripts through exactly such a symlink. Observed 14.09
81
81
  // on run 34784079821: `extract-pdf-facts.mjs --strict` returned RC=0 and created no facts file.
82
- if (isMain(import.meta.url)) {
83
- const { rules, linted, blind } = await rulesSeeFiles({ cwd: process.cwd() });
82
+ /** The gate: every declared rule listed with the files it saw; exit 1 when one saw none. */
83
+ export async function main({ cwd = process.cwd(), log = console.log } = {}) {
84
+ const { rules, linted, blind } = await rulesSeeFiles({ cwd });
84
85
  for (const { rule, files } of rules)
85
- console.log(`${String(files).padStart(4)} ${rule}`);
86
- console.log(`\n${rules.length} rule(s) declared, ${linted} file(s) linted.`);
86
+ log(`${String(files).padStart(4)} ${rule}`);
87
+ log(`\n${rules.length} rule(s) declared, ${linted} file(s) linted.`);
87
88
  if (blind.length) {
88
- console.log(
89
+ log(
89
90
  `\n✗ ${blind.length} rule(s) saw NO file and were never invoked:\n` +
90
91
  blind.map((r) => ` ${r}`).join("\n") +
91
92
  "\n\n A rule that is never invoked reports exactly what a rule that passed reports.\n" +
92
93
  " Fix the glob it is declared on, or delete the declaration — do not leave it green.",
93
94
  );
94
- process.exit(1);
95
+ return 1;
95
96
  }
96
- console.log(
97
- "✓ every declared rule was enabled for at least one file on disk.",
98
- );
97
+ log("✓ every declared rule was enabled for at least one file on disk.");
98
+ return 0;
99
99
  }
100
+
101
+ if (isMain(import.meta.url)) process.exit(await main());
@@ -0,0 +1,71 @@
1
+ /**
2
+ * rules-see-files' gate: a temp repository whose config declares a rule on a glob matching nothing.
3
+ */
4
+ import assert from "node:assert/strict";
5
+ import { spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { test } from "vitest";
8
+ import { useTempDir, writeTree } from "../test/support.mjs";
9
+ import { main as seeMain } from "./rules-see-files.mjs";
10
+
11
+ const capture = () => {
12
+ const out = [];
13
+ const io = { log: (s) => out.push(s), err: (s) => out.push(`E ${s}`) };
14
+ return { out, io };
15
+ };
16
+
17
+ const root = useTempDir("rules-see-files-");
18
+
19
+ test("rules-see-files: a rule declared on a glob that matches nothing is named and fails", async () => {
20
+ writeTree(root, {
21
+ "see/eslint.config.mjs":
22
+ 'export default [{ files: ["**/*.js"], rules: { "no-debugger": "error" } }, { files: ["**/*.nothing"], rules: { "no-undef": "error", "eqeqeq": "off" } }];\n',
23
+ "see/a.js": "export const a = 1;\n",
24
+ "see/package.json": '{ "type": "module" }\n',
25
+ });
26
+ const { out, io } = capture();
27
+ const code = await seeMain({ cwd: `${root}/see`, ...io });
28
+ assert.deepEqual(
29
+ { code, out },
30
+ {
31
+ code: 1,
32
+ out: [
33
+ " 1 no-debugger",
34
+ " 0 no-undef",
35
+ "\n2 rule(s) declared, 2 file(s) linted.",
36
+ "\n✗ 1 rule(s) saw NO file and were never invoked:\n no-undef\n\n" +
37
+ " A rule that is never invoked reports exactly what a rule that passed reports.\n" +
38
+ " Fix the glob it is declared on, or delete the declaration — do not leave it green.",
39
+ ],
40
+ },
41
+ );
42
+ });
43
+
44
+ test("rules-see-files: every rule seeing a file passes — asked with a trailing slash, and as a program", async () => {
45
+ writeTree(root, {
46
+ "ok/eslint.config.mjs":
47
+ 'export default [{ files: ["**/*.js"], rules: { "no-debugger": ["error"] } }];\n',
48
+ "ok/a.js": "export const a = 1;\n",
49
+ "ok/package.json": '{ "type": "module" }\n',
50
+ });
51
+ const { out, io } = capture();
52
+ const code = await seeMain({ cwd: `${root}/ok/`, ...io });
53
+ assert.deepEqual(
54
+ { code, out },
55
+ {
56
+ code: 0,
57
+ out: [
58
+ " 1 no-debugger",
59
+ "\n1 rule(s) declared, 2 file(s) linted.",
60
+ "✓ every declared rule was enabled for at least one file on disk.",
61
+ ],
62
+ },
63
+ );
64
+ const run = spawnSync(
65
+ process.execPath,
66
+ [fileURLToPath(new URL("./rules-see-files.mjs", import.meta.url))],
67
+ { cwd: `${root}/ok`, encoding: "utf8" },
68
+ );
69
+ assert.equal(run.status, 0, run.stderr);
70
+ assert.match(run.stdout, /✓ every declared rule was enabled/);
71
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `npm test`: the unit tests (vitest), then the harnesses (`vigiles test`). BOTH always run.
3
+ *
4
+ * Until #52 this was `vitest run && vigiles test --min=1`. Under `npm run coverage` a single
5
+ * failing vitest test then skipped every harness, and what the run printed last was c8's summary:
6
+ * lines, branches and functions each some six points below the floor. The one failing test sat
7
+ * above that, looking like a consequence rather than the cause. Running both suites keeps the
8
+ * coverage report whole, so a failing test is reported as itself and the coverage figures still
9
+ * mean what they say.
10
+ *
11
+ * Arguments are the harness runner's (`npm test -- --no-skip`), as they were before.
12
+ */
13
+ import { spawnSync } from "node:child_process";
14
+ import { isMain } from "../skills/paper-pipeline/scripts/consumer.mjs";
15
+
16
+ /** The two suites, in order: what a failure is named as, and the command. */
17
+ export const SUITES = [
18
+ { name: "vitest", command: ["vitest", "run"] },
19
+ { name: "harnesses", command: ["vigiles", "test", "--min=1"] },
20
+ ];
21
+
22
+ /**
23
+ * Run every suite, whatever the one before it returned, and name the ones that failed.
24
+ * @returns 0 when every suite passed; otherwise the first failing suite's exit code (1 for a signal).
25
+ */
26
+ export function runSuites(
27
+ args,
28
+ { spawn = spawnSync, err = console.error, suites = SUITES } = {},
29
+ ) {
30
+ const failed = suites.flatMap(({ name, command }) => {
31
+ const [file, ...rest] = command;
32
+ const r = spawn(file, [...rest, ...(name === "harnesses" ? args : [])], {
33
+ stdio: "inherit",
34
+ });
35
+ return r.status === 0 ? [] : [{ name, code: r.status ?? 1 }];
36
+ });
37
+ for (const f of failed)
38
+ err(
39
+ `✗ ${f.name} failed (exit ${String(f.code)}) — its own report is above`,
40
+ );
41
+ return failed.length === 0 ? 0 : failed[0].code;
42
+ }
43
+
44
+ if (isMain(import.meta.url)) process.exit(runSuites(process.argv.slice(2)));
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `npm test`'s runner: both suites run whatever the first returned, each failure is named, and
3
+ * the harness arguments reach the harness runner only.
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import { test } from "vitest";
7
+ import { runSuites, SUITES } from "./test.mjs";
8
+
9
+ const fake = (status) => {
10
+ const calls = [];
11
+ return {
12
+ calls,
13
+ spawn: (file, args) => {
14
+ calls.push([file, ...args]);
15
+ return { status: status[file] };
16
+ },
17
+ };
18
+ };
19
+
20
+ test("a failing vitest does not skip the harnesses, and is named as the failure", () => {
21
+ const { calls, spawn } = fake({ vitest: 1, vigiles: 0 });
22
+ const said = [];
23
+ assert.equal(
24
+ runSuites(["--no-skip"], { spawn, err: (s) => said.push(s) }),
25
+ 1,
26
+ );
27
+ assert.deepEqual(calls, [
28
+ ["vitest", "run"],
29
+ ["vigiles", "test", "--min=1", "--no-skip"],
30
+ ]);
31
+ assert.deepEqual(said, [
32
+ "✗ vitest failed (exit 1) — its own report is above",
33
+ ]);
34
+ });
35
+
36
+ test("both passing is 0 and silent; a harness killed by a signal is exit 1, named", () => {
37
+ const ok = fake({ vitest: 0, vigiles: 0 });
38
+ assert.equal(runSuites([], { spawn: ok.spawn, err: assert.fail }), 0);
39
+ const killed = fake({ vitest: 0, vigiles: null });
40
+ const said = [];
41
+ assert.equal(
42
+ runSuites([], { spawn: killed.spawn, err: (s) => said.push(s) }),
43
+ 1,
44
+ );
45
+ assert.deepEqual(said, [
46
+ "✗ harnesses failed (exit 1) — its own report is above",
47
+ ]);
48
+ assert.deepEqual(
49
+ SUITES.map((s) => s.name),
50
+ ["vitest", "harnesses"],
51
+ );
52
+ });
@@ -0,0 +1,57 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`--flags-only: an over-long caption flags and exits 1 1`] = `
4
+ "✍️ prose-lint — paper.md — 2 finding(s):
5
+ figures/fig1.tex: caption sentence of 46 words (warn >40) — "Big. word0 word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12 word13 word14 word15 wor…"
6
+ figures/fig1.tex: caption is 106 words (warn >100) — a caption this long is a section under a picture
7
+ run \`node .claude/skills/grade-paper-writing/prose-lint.mjs <file>\` for the sentences
8
+ "
9
+ `;
10
+
11
+ exports[`--headings lists every heading from level 2 to 4, in order 1`] = `
12
+ "
13
+ === paper.md — every heading, in order ===
14
+ 1. Introduction
15
+ 2. Method
16
+
17
+ Read these as a stranger who will read nothing else. Each should name a thing:
18
+ a linter, a configuration, a repository, a rule — not "answers", "kinds", "moves".
19
+
20
+ (thresholds and their sources are in THRESHOLDS at the top of this file)
21
+ "
22
+ `;
23
+
24
+ exports[`a .tex file is refused with exit 2, and nothing is measured 1`] = `
25
+ "prose-lint reads markdown, not LaTeX: <root>/paper.tex
26
+ There is no .tex stripping here — the word count would include the preamble and the .bib,
27
+ and every metric (they are all fractions of the word count) would be understated by roughly half.
28
+ What to cover prose in .tex with — papers/research/2026-08-26-proza-v-latex-zanyatost.md
29
+ "
30
+ `;
31
+
32
+ exports[`the full report over markdown and rendered text 1`] = `
33
+ "
34
+ === paper.md — 82 words, 9 sentences ===
35
+ sentence lengths [7,10,8,6,19,6,8,8,6] mean 8.7 sd 3.9 CV 0.45
36
+ FLAG all metadiscourse/1000w 195.1 (Hyland RA baseline 64.9, warn >90)
37
+ nominalisations/1000w 12.2
38
+ FLAG "rather than"/instead-of/10,000w 243.9 (warn >25; not epanorthosis, no published baseline)
39
+ FLAG sentences ending on a cross-ref/citation/hedge (wasted stress position): 3
40
+ > …Clearly the first result matters (Section 3).
41
+ > …Prior work agrees [1, 2].
42
+ > … full suite of papers, which suggests the method might hold generally.
43
+ ok sentences whose main claim is a negation: 22%
44
+ topic positions (whose story is this?): "in this paper we present" | "it might possibly help, but" | "clearly the first result matters" | "prior work agrees [1, 2]." | "the speedup was 40% on" | "- the first item is" | "- the second item is" | "we believe that rather than" | "instead of prose, the numbers"
45
+
46
+ === page.txt — 13 words, 3 sentences ===
47
+ sentence lengths [3,6,4] mean 4.3 sd 1.2 CV 0.29
48
+ ok all metadiscourse/1000w 0 (Hyland RA baseline 64.9, warn >90)
49
+ nominalisations/1000w 0
50
+ ok "rather than"/instead-of/10,000w 0 (warn >25; not epanorthosis, no published baseline)
51
+ ok sentences ending on a cross-ref/citation/hedge (wasted stress position): 0
52
+ ok sentences whose main claim is a negation: 0%
53
+ topic positions (whose story is this?): "a rendered page." | "it was hyphenated across a" | "next page text here."
54
+
55
+ (thresholds and their sources are in THRESHOLDS at the top of this file)
56
+ "
57
+ `;