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
@@ -3,7 +3,7 @@
3
3
  # ── WHY ONE JOB ─────────────────────────────────────────────────────────────
4
4
  # Parallelism does not save billable minutes, it ADDS them: every job pays for its own
5
5
  # setup + checkout + install (~20-30 s). Splitting is worth it for signal isolation on a big
6
- # matrix; here the whole suite is 84 seconds, so a second job would cost more than it isolates.
6
+ # matrix; here the whole suite is ~96 seconds, so a second job would cost more than it isolates.
7
7
  #
8
8
  # ⛽ THIS REPOSITORY IS PUBLIC, so its minutes are FREE — they do not touch the account's
9
9
  # 3000/month quota (verified against the API 2026-09-12: `"private": false`). The header said
@@ -11,23 +11,24 @@
11
11
  # rather than guessed. Keep measuring them anyway: the bond exists to stop a HANG, and a hang
12
12
  # costs wall-clock and a red check whether or not anyone is billed.
13
13
  #
14
- # ── THE MEASUREMENT THE BONDS COME FROM (local, 2026-09-12) ─────────────────
15
- # npm test (12 harnesses) 11 s
16
- # npm run lint (eslint over fixtures) 1 s
17
- # rules-see-files.mjs 1 s
18
- # vigiles lint 3 s ← added 2026-09-12 with the first ten skills
19
- # run-mutations.mjs (12 batteries) 242 s ← 130 mutations, the expensive and load-bearing part
14
+ # ── THE MEASUREMENT THE BONDS COME FROM (local, 4 cores, 2026-09-26) ─────────
15
+ # npm run build 2 s
16
+ # npm test -- --no-skip (69 harnesses) 41 s ← vitest + every harness
17
+ # npm run lint 6 s
18
+ # npm run fmt:check 5 s
19
+ # vigiles lint . README.md 6 s
20
+ # rules-see-files.mjs 5 s
21
+ # layer-legacy-frozen.mjs 3 s
22
+ # install e2e --strict (npm + pnpm) 28 s
20
23
  # ───────────────────────────────────────────
21
- # total work ~255 s, plus checkout + install
24
+ # total work ~96 s, plus checkout + install + the claude binary
22
25
  #
23
- # 🔴 THE PREVIOUS NUMBERS ARE KEPT NOWHERE BUT THIS SENTENCE, ON PURPOSE: they said ~84 s total
24
- # with `timeout-minutes: 8`, i.e. a ~2.7× bond. The pipeline's checkers arrived on 2026-09-12 and
25
- # tripled every count — 4 harnesses became 12, 45 mutations became 130 — which would have left an
26
- # 8-minute bond at ~1.3× of a ~6-minute run. A bond that tight fails on a slow runner and reads
27
- # as a broken suite rather than as a bond. Re-measure when the counts move again; do not cite the
28
- # table above without re-running it.
26
+ # 🔴 THE PREVIOUS TABLE (2026-09-12) had one more row, `run-mutations.mjs`, at 242 s of ~255 s, and
27
+ # by 2026-09-23 it was 8m39s of a 10m02s job (#52). The batteries were removed in #52, which is
28
+ # what took the job from `timeout-minutes: 20` to 10. Re-measure when the counts move again; do not
29
+ # cite the table above without re-running it.
29
30
  #
30
- # Expect ~6 minutes warm. `timeout-minutes: 20` is ~3.3×, and `npm ci` carries its OWN bond
31
+ # Expect ~3 minutes warm. `timeout-minutes: 10` is ~3×, and `npm ci` carries its OWN bond
31
32
  # because a job-level bond cannot tell "the registry hung" from "the suite is slow" and so hands
32
33
  # a hang the whole job.
33
34
  name: ci
@@ -62,7 +63,7 @@ jobs:
62
63
  # there is no pull_request object, and a bare `.draft` would be missing and kill every check there.
63
64
  if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
64
65
  runs-on: ubuntu-latest
65
- timeout-minutes: 20
66
+ timeout-minutes: 10
66
67
  steps:
67
68
  - uses: actions/checkout@v7
68
69
 
@@ -88,14 +89,10 @@ jobs:
88
89
  # i.e., claimed a protection that does not exist. Protection against this case comes from `--no-skip` below.
89
90
  # 🔴 THE `claude` CLI IS A TEST DEPENDENCY HERE, and its absence was NOT harmless.
90
91
  # `plan-paper-timeline.effects.harness.mjs` spawns the real agent binary; without it the
91
- # harness SKIPS, the skip exits 0 through `vigiles test`, and the mutation driver interprets
92
- # that as "the mutation survived". Measured 2026-09-15 on run 34966606186: two survivors in CI,
93
- # zero locally, the difference being this binary and nothing about the code.
94
- #
95
- # The driver now REFUSES a battery whose harness skips (lib/mutation-driver.mjs), so the
96
- # wrong diagnosis is gone either way. This step is the other half: it restores the evidence
97
- # instead of settling for an honest "no evidence". No API key — the tier drives a scripted
98
- # stand-in, which is why it costs seconds and $0.
92
+ # harness SKIPS, and a skip is not evidence. (Measured 2026-09-15 on run 34966606186, when the
93
+ # since-removed mutation batteries read that skip as two "survivors" in CI and none locally.)
94
+ # This step restores the evidence instead of settling for an honest "no evidence". No API
95
+ # key — the tier drives a scripted stand-in, which is why it costs seconds and $0.
99
96
  # 🔴 DELIBERATELY UNPINNED, and since issue #7 that is finally honest. The tier exists to
100
97
  # notice upstream moving, so a pin would defeat it — but until the version was OBSERVED
101
98
  # rather than probed for, an unpinned install meant the assertions characterized whatever
@@ -110,14 +107,16 @@ jobs:
110
107
  # that failed to LOAD. In other words, one flag closes both doors at once.
111
108
  #
112
109
  # ⚠️ The step order was incorrect and made the install above nearly pointless: `npm test`
113
- # ran BEFORE it, so the effects tier in this job was always skipped, and the binary
114
- # reached only the mutation batteries. The comment about "its absence was NOT harmless"
110
+ # ran BEFORE it, so the effects tier in this job was always skipped. The comment about "its absence was NOT harmless"
115
111
  # described exactly this harm yet did not protect against it itself.
116
112
  #
117
113
  # ⚠️ And the flag belongs IN THE STEP, not in `package.json`: the macOS cell of build-e2e runs the same `npm test`
118
114
  # WITHOUT installing the binary, where skips are legitimate, and a global flag would falsely color it.
119
- - name: npm test
120
- run: npm test -- --no-skip
115
+ #
116
+ # `npm run coverage` is `npm test` under c8 with the thresholds in .c8rc.json: the same tests,
117
+ # one run, and a drop below the coverage floor fails this step by name.
118
+ - name: npm test, under coverage
119
+ run: npm run coverage -- --no-skip
121
120
 
122
121
  # The rules themselves, over this repository's own fixtures. Two of the four fixtures are
123
122
  # DEFECTIVE ON PURPOSE, so a healthy checkout reports warnings here and still exits 0 —
@@ -161,22 +160,11 @@ jobs:
161
160
  - name: every rule sees a file
162
161
  run: node scripts/rules-see-files.mjs
163
162
 
164
- # Hand-written mutation batteries are being removed (#52): the frozen list may only
165
- # shrink — no new battery, no new case in an existing one.
166
- - name: mutation batteries are frozen
167
- run: node scripts/mutation-batteries-frozen.mjs
168
-
169
163
  # The legacy exemptions from src/'s layer rules (#76) may only shrink: counted from ESLint's
170
164
  # own suppressedMessages, so a new `-- legacy …` directive anywhere fails here.
171
165
  - name: legacy layer exemptions are frozen
172
166
  run: node scripts/layer-legacy-frozen.mjs
173
167
 
174
- # The batteries that remain still run until #52 replaces or removes them. The runner
175
- # DISCOVERS batteries rather than listing them and errors on finding zero — see
176
- # scripts/run-mutations.mjs for why (the hand list it replaced had gone stale, silently).
177
- - name: mutation batteries
178
- run: node scripts/run-mutations.mjs
179
-
180
168
  # pnpm is INSTALLED here rather than hoped for. The runner image may or may not carry it,
181
169
  # and the npm/pnpm difference is the entire reason the install e2e exists — so leaving it
182
170
  # to the image means the half that matters most is covered by luck. `corepack` ships with
@@ -244,8 +232,7 @@ jobs:
244
232
  : "DIVERGE — this is the platform this job exists for");
245
233
  '
246
234
 
247
- # Issue #9: `npm test` was red ONLY on macOS. Only `npm test`, not the mutation batteries:
248
- # they would verify on macOS exactly what the gates job verifies on Linux.
235
+ # Issue #9: `npm test` was red ONLY on macOS.
249
236
  - name: npm test (macOS)
250
237
  if: runner.os == 'macOS'
251
238
  run: npm test
@@ -16,7 +16,7 @@
16
16
  #
17
17
  # 🔴 WHY WE MERGE ANY BUMP, INCLUDING MAJOR, not just patch/minor.
18
18
  # Semver is a PUBLISHER'S CLAIM about their release; `ci` is a MEASUREMENT on our corpus (full harness suite,
19
- # mutations, gates for "rules see their files" and "rules don't touch git"). A major bump that breaks us
19
+ # gates for "rules see their files" and "rules don't touch git"). A major bump that breaks us
20
20
  # reds the run and does not merge; a minor one that breaks us does the same.
21
21
  # A version-number filter adds a manual step where measurement already exists, and would miss
22
22
  # exactly the class for which version filters are typically used.
package/CLAUDE.md CHANGED
@@ -35,7 +35,6 @@ assumed. Two things that sound like limits and are not:
35
35
  | ----------------------- | -----------------------------------------------------------------------------------------: |
36
36
  | ESLint rules | **5** — `latex-language` · `tex-build` · `papers` · `review-findings-cause` · `doc-fields` |
37
37
  | harnesses | **57** |
38
- | mutation batteries | **34** |
39
38
  | skills | **24** |
40
39
  | hooks (runnable `.mjs`) | **5** |
41
40
  | repo-wide scripts | 5 |
@@ -75,8 +74,8 @@ nodes.
75
74
 
76
75
  **3. Every check needs BOTH halves, or it is not tested.** It must FIRE on a planted defect
77
76
  and stay QUIET on a clean fixture. A check that has only been seen quiet is
78
- indistinguishable from a dead one — silence is its success state. Prove the fire half with a
79
- mutation, and assert the patch actually landed before trusting a green run.
77
+ indistinguishable from a dead one — silence is its success state. Prove the fire half by
78
+ seeing the test go RED before the fix lands (§ Testing — how a test is written here).
80
79
 
81
80
  **4. `exit 0` with empty output is NOT "clean".** A rule whose glob matched no files reports
82
81
  exactly like a rule that passed. Any rule shipped here must be loud when its input set is
@@ -461,26 +460,8 @@ node scripts/rules-see-files.mjs # also part of npm run check
461
460
  It is per RULE, not per glob, and that distinction is the point: a rule can be enabled in one
462
461
  block whose glob is empty while a different block is busy, so "some glob matched something" is
463
462
  not evidence about the rule you care about. Both halves are tested
464
- (`scripts/rules-see-files.harness.mjs`) and both directions are mutated
465
- (`scripts/rules-see-files.mutations.mjs` — under-reporting and over-reporting must die on
466
- _different_ assertions, or only one half of the guard is really tested).
467
-
468
- ## Mutations — hand-written batteries are deprecated (#52)
469
-
470
- The `*.mutations.mjs` batteries (string replacements of source lines, run through
471
- `lib/mutation-driver.mjs`) are being removed. The idea stays — a test must be seen going red
472
- when the code breaks — but the vehicle is not this one.
473
-
474
- - **Do not create a new `*.mutations.mjs`**, and **do not add cases to an existing one.**
475
- - **Record what a test guards as a comment directly above its assertion** (`// Guards: …`).
476
- - The rule is enforced, not asked for: `scripts/mutation-batteries-frozen.mjs` (part of
477
- `npm run check` and CI) fails on a battery missing from `scripts/mutation-batteries.frozen.json`,
478
- on a listed battery with more or fewer cases than recorded, and on a listed file that is gone.
479
- The list may only shrink — delete a battery or a case, then delete or lower its entry.
480
- - The intended replacement is a real mutation-testing tool (StrykerJS) or nothing; that is
481
- decided in #52, not in a pull request that happens to touch a battery.
482
-
483
- The batteries that remain still run (`node scripts/run-mutations.mjs`) until #52 retires them.
463
+ (`scripts/rules-see-files.harness.mjs`), under-reporting and over-reporting each by an
464
+ assertion of its own.
484
465
 
485
466
  ## Cost
486
467
 
@@ -508,22 +489,26 @@ justified two hundred lines above precisely by these minutes being free.
508
489
 
509
490
  ```bash
510
491
  npm test # vitest over *.test.ts, then the vigiles harnesses
492
+ npm run coverage # the same run under c8; fails below the thresholds in .c8rc.json
511
493
  npx vitest run <file> # one unit test
512
494
  npx vigiles test <file> # one harness
513
495
  ```
514
496
 
515
497
  **Two kinds of test, told apart by what the file imports.** A `*.harness.*` file tests the agent
516
498
  surface and imports `runHook`, `runHarnessTest` or `runEval` from vigiles; everything else is a plain
517
- unit test, `*.test.ts`, run by vitest — and new tests are TypeScript. Older harnesses that
499
+ unit test run by vitest, beside the module it tests: `<module>.test.ts` for a `.ts` module, and
500
+ `<module>.test.mjs` for a `.mjs` one (TypeScript has no types to check a JS module against, and
501
+ `allowJs` was measured to break a dozen existing `@ts-expect-error` imports). Older harnesses that
518
502
  import none of the three are frozen in `scripts/harness-api.frozen.json`, which only shrinks;
519
503
  `scripts/harness-api.test.ts` parses every harness's imports and holds both halves (#77). vitest
520
504
  exits 1 when no file matches, and it transpiles without type-checking, so `npm run check` runs
521
505
  `tsc -p tsconfig.test.json` as its own gate.
522
506
 
523
- **Local = the fast gates on what you touched; the full `npm run check` = CI.** Locally run vitest on
524
- the touched test files, `npx tsc --noEmit`, `npx eslint <touched files>` and `npm run fmt:check`, then
525
- push and read CI by job name. The repo is public, so CI is free, and it also runs the TeX e2e that
526
- cannot run locally; a local full run takes 10-15 min, most of it the mutation batteries (#52).
507
+ **While working: the fast gates on what you touched. Before every push: `npm run check`.** While
508
+ editing, run vitest on the touched test files (`npx vitest run <file or folder>`), `npx tsc --noEmit`,
509
+ `npx eslint <touched files>` and `npm run fmt:check`. Before pushing, run the full `npm run check`
510
+ (about three minutes) and read its exit code: it includes the 100% coverage gate, which a
511
+ single-file run cannot judge. CI runs the same command plus the TeX e2e that cannot run locally.
527
512
 
528
513
  ⚠️ **Not `vigiles test .`** — the `.` is read as a FILE, the runner dies with
529
514
  `ERR_UNSUPPORTED_DIR_IMPORT`, and it still exits 0. See the measured table below.
@@ -534,34 +519,44 @@ Skills are tested **through vigiles** — a colocated `<skill>.harness.mjs` besi
534
519
  empty — issue #6.) Not through a bespoke script: a home-grown runner here once printed
535
520
  confident, byte-identical "clean" verdicts for three different skills that had never loaded.
536
521
 
537
- ### Every npm script takes an exclusive lock, and that is not ceremony
538
-
539
- `npm run *` in this repository goes through `scripts/exclusive.mjs`, which holds
540
- `.vigiles/exclusive.lock` for the duration. A second gate started while one is running does not
541
- queue and does not race — it **refuses**, names the holder, and exits 3.
542
-
543
- 🔴 **The reason is that the mutation batteries edit the working tree in place.** That strategy is
544
- deliberate (see `lib/mutation-driver.mjs` — copying the repo per mutation costs minutes instead
545
- of seconds), and its one cost is that any parallel reader sees a source file mid-mutation. The
546
- resulting failure is **false, non-deterministic, and blames the wrong file**: it reports a broken
547
- assertion, not a mutation, and it reads as "the suite is flaky". That has already cost a wrong
548
- conclusion here — two runs in a row produced _different_ error messages and the diagnosis "I broke
549
- round-diff" was incorrect.
550
-
551
- A prose instruction "don't run them at the same time" existed and did not work: prose does not
552
- execute, so it does not apply to the person in the other terminal, the agent, or the editor with
553
- tests on save. Measured live, with the batteries running:
554
-
555
- ```
556
- $ npm test
557
- 🔴 refused: this repository is busy with a run that EDITS FILES IN PLACE.
558
- held by: pid 6645, "node scripts/run-mutations.mjs", since 2026-09-17T05:22:28.757Z
559
- RC=3
560
- ```
561
-
562
- ⚠️ A lock left behind by a process that no longer exists is **taken over** with a message, not
563
- respected. Otherwise one interrupted run would block the repository forever, and the first cure
564
- anybody reaches for would be "delete the lock by hand" — i.e. switching the mechanism off.
522
+ ### How a test is written here
523
+
524
+ 1. **Red first.** A new test is seen FAILING before the change that makes it pass — run it on
525
+ the unfixed code, watch it go red at its own assertion, then fix. A test that has only ever
526
+ been green is indistinguishable from one that cannot fail. Never add a file that patches
527
+ source to prove a test can fail — seeing it red once, here, is that proof.
528
+ 2. **Assert the whole value.** Compare the entire returned value (`assert.deepEqual`,
529
+ `expect(x).toEqual(…)`), not a substring of it or one field. A substring assertion passes on
530
+ output that is wrong everywhere else — a defect found here over and over
531
+ (`"that is the"` matching two different messages, a bare word the usage block always prints).
532
+ A substring is right only when the value is prose whose wording is not the subject; then say
533
+ so in a comment.
534
+ 3. **Test what the code DOES, never what its source SAYS.** A test that reads a source file (a
535
+ `.mjs`, a `SKILL.md`, a config) and asserts it contains some text restates the file: rewording
536
+ turns it red while a real regression stays green. Assert behaviour instead: a return value, the
537
+ output and exit code of a run, what landed on disk, what a parser reports. Text is a legitimate
538
+ subject only when it IS the output (a message the program prints, a file it generates).
539
+ 4. **One assertion helper for harnesses: `lib/check.mjs`.** `const check = createChecker();` then
540
+ `check(label, cond, detail)`. A failure prints the label and the detail (a value is rendered
541
+ with `util.inspect`, a function is called only on failure), every call is counted
542
+ (`check.count`, for the summary line) and reported to vigiles. Do not define a local `check`.
543
+ 5. **Coverage is a gate.** `npm run check` and CI run `npm run coverage` — `npm test` under c8,
544
+ `--check-coverage` against `.c8rc.json`. c8 reads `NODE_V8_COVERAGE`, so a CLI a harness
545
+ spawns is measured too — unless the harness hands the child a fresh `env` without it. vitest
546
+ runs in the `threads` pool with native `import` (`vitest.config.ts` says why): under its
547
+ defaults c8 saw nothing vitest ran. The run preloads `test/coverage-src.mjs`, which answers
548
+ every import of `dist/*.js` with `src/*.ts`: otherwise a module the tests import directly AND
549
+ the spawned CLI loads compiled is measured twice, and its branches and functions stay red in
550
+ whichever copy did not run them. `npm test` without coverage still runs `dist/`.
551
+ 6. **No `c8 ignore`, and no guard deleted to reach 100%.** An effect a test cannot reach — a
552
+ race, a permission root is never denied, a broken install, a 270 MB download — is made
553
+ INJECTABLE (a `readdir`, an fs object, a manifest reader, a toolchain function) and the test
554
+ passes a fake. A precondition about an argument becomes the signature: the function takes the
555
+ value it needs, so a call without it cannot be written. A guard whose input can really occur
556
+ stays, with the test that reaches it. `scripts/coverage-config.test.ts` fails on any
557
+ coverage-ignore comment in a measured file (it reads comments off the parsed tree), and pins
558
+ `.c8rc.json`'s `exclude` list: a new exclusion fails there until it is added on purpose, with
559
+ its reason. Thresholds are 100 for lines, statements, functions and branches.
565
560
 
566
561
  ## `npm test` — `--min=1` stays, and here is what it is for
567
562
 
package/CONTRIBUTING.md CHANGED
@@ -17,13 +17,20 @@ The second run is the one people skip, and it is the one that matters. A rule th
17
17
  finds nothing passes the first kind of test by accident — from outside, "there is nothing wrong
18
18
  here" and "this check never ran" look exactly the same.
19
19
 
20
- Older rules also have a **battery** (`*.mutations.mjs`): it deletes one thing the rule depends on
21
- and then demands the harness go red, at the specific assertion that thing belongs to. **These
22
- hand-written batteries are deprecated and being removed (#52).** Do not write a new one and do
23
- not add cases to an existing one; instead, say what an assertion guards in a comment directly
24
- above it (`// Guards: …`). `npm run check` fails on a new or grown battery — the frozen list in
25
- `scripts/mutation-batteries.frozen.json` may only shrink. Whether a real mutation-testing tool
26
- replaces them is decided in #52.
20
+ Three rules for writing the test itself:
21
+
22
+ 1. **Red first.** Run a new test on the code BEFORE the fix and watch it fail at its own
23
+ assertion. A test that has only ever been green is indistinguishable from one that cannot fail.
24
+ 2. **Assert the whole value** — `assert.deepEqual` / `toEqual` on the entire result, not a
25
+ substring or one field. A substring passes on output that is wrong everywhere else. The
26
+ exception is prose whose wording is not the subject; say so in a comment.
27
+ 3. **Test what the code does, not what its source says.** A test that reads a source file and
28
+ asserts it contains some text restates the file: rewording turns it red, a real regression
29
+ stays green. Assert a return value, a run's output and exit code, or what landed on disk.
30
+
31
+ Say what an assertion guards in a comment directly above it (`// Guards: …`). A harness asserts
32
+ through the shared `lib/check.mjs` (`const check = createChecker()`), which prints the label and
33
+ the detail on failure and counts every call; a new harness does not define its own `check`.
27
34
 
28
35
  A harness runs in this tree, against this working copy. That is the wrong shape for a defect that
29
36
  only exists once somebody else has installed the package — a path written inside a skill, a file
@@ -31,9 +38,9 @@ that never made it into the tarball, a PDF whose content is wrong while the exit
31
38
  Those are covered by the end-to-end runs, and [`docs/e2e.md`](docs/e2e.md) says which question
32
39
  belongs to which tier, and **when a change owes a new e2e rather than a harness**.
33
40
 
34
- It caught a real one on the way in: `js-yaml` 5 stopped parsing an unquoted date as a `Date`.
35
- Every harness stayed green under both majors, and only the battery noticed that the rule's
36
- date coercion had become dead code.
41
+ An example of why asserting the value matters: `js-yaml` 5 stopped parsing an unquoted date as a
42
+ `Date`, every harness stayed green under both majors, and the rule's date coercion had quietly
43
+ become dead code — no test asserted the coerced value.
37
44
 
38
45
  ## Running the checks: one command
39
46
 
@@ -41,8 +48,8 @@ date coercion had become dead code.
41
48
  npm run check
42
49
  ```
43
50
 
44
- That is the whole instruction. It runs every gate in order — build, lint, every harness, every
45
- mutation battery, the install e2e under npm and pnpm, and a real `pdflatex` build — and ends
51
+ That is the whole instruction. It runs every gate in order — build, lint, every harness, the
52
+ install e2e under npm and pnpm, and a real `pdflatex` build — and ends
46
53
 
47
54
  by printing **which CI jobs it does not reproduce, and why**.
48
55
 
@@ -65,8 +72,8 @@ nobody covered — verified by adding a `windows` job and watching it fail.
65
72
  ## Layout
66
73
 
67
74
  ```
68
- eslint-rules/ the rules, each with its .harness.mjs beside it (older ones also a .mutations.mjs)
69
- lib/ shared readers — markdown, skill corpus, the mutation driver
75
+ eslint-rules/ the rules, each with its .harness.mjs beside it
76
+ lib/ shared readers — markdown, skill corpus
70
77
  hooks/ three hooks for vigiles — the code
71
78
  plugin/ the Claude Code plugin: wiring for those hooks, no code, no package.json
72
79
  skills/ 24 stage skills
@@ -102,13 +109,19 @@ The README links only what a user needs. These are for people changing the packa
102
109
 
103
110
  ```bash
104
111
  npm install
105
- npm test # every harness, the tests' type-check, every *.test.ts (Node >= 22.18)
106
- node scripts/run-mutations.mjs # run the remaining batteries (deprecated, #52 — do not add to them)
112
+ npm test # every *.test.ts, then every harness (Node >= 22.18)
113
+ npm run coverage # the same, under c8, failing below the thresholds in .c8rc.json
107
114
  ```
108
115
 
109
116
  None of these are needed to USE the tool — they are here because the gates are part of the
110
117
  argument, not decoration.
111
118
 
119
+ Coverage is 100% for lines, statements, functions and branches, and there is no `c8 ignore`:
120
+ code a test cannot reach directly — a race, a permission, a broken install, a real download — takes
121
+ the effect as a parameter (the ports in `src/ports`, an injected `readdir` or runner), and the test
122
+ passes a fake. `scripts/coverage-config.test.ts` fails on a coverage-ignore comment and on any
123
+ change to `.c8rc.json`'s exclude list that was not made there on purpose.
124
+
112
125
  ## Adding a venue
113
126
 
114
127
  A venue is a **preset**, a JSONC file in `skills/submit-paper/references/venues/`, validated by
@@ -166,8 +179,7 @@ Licensing differs too: that suite is CC BY-NC 4.0, this is MIT.
166
179
  - **scorecard** — `PIPELINE-STATUS.md`, the file above. One per paper.
167
180
  - **frozen** — a copy of the exact PDF or `.tex` that was sent, kept in `versions/`. Bytes, not a
168
181
  commit reference: squash and `gc` destroy commit references, and did.
169
- - **harness** — the test beside a rule. **battery** — a set of edits that try to break a harness
170
- (deprecated, being removed in #52).
182
+ - **harness** — the test beside a rule.
171
183
 
172
184
  ## Also in the box
173
185
 
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `bin/paperlint.mjs` — the shim's one decision of its own: with no `dist/` beside it (a clone
3
+ * before `npm run build`, a git dependency installed with `--ignore-scripts`) it refuses loudly,
4
+ * naming the missing file and both remedies, instead of an ERR_MODULE_NOT_FOUND from the loader.
5
+ *
6
+ * The missing build is staged with a preload that makes `existsSync` answer "no" for exactly
7
+ * `dist/cli.js` — the shim reads the real file, so the real shim is what is measured.
8
+ */
9
+ import assert from "node:assert/strict";
10
+ import { spawnSync } from "node:child_process";
11
+ import { fileURLToPath } from "node:url";
12
+ import { test } from "vitest";
13
+
14
+ const BIN = fileURLToPath(new URL("./paperlint.mjs", import.meta.url));
15
+ const DIST = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
16
+
17
+ const HIDE_DIST = [
18
+ 'import fs from "node:fs";',
19
+ 'import { syncBuiltinESMExports } from "node:module";',
20
+ "const real = fs.existsSync;",
21
+ 'fs.existsSync = (p) => (String(p).endsWith("/dist/cli.js") ? false : real(p));',
22
+ "syncBuiltinESMExports();",
23
+ ].join("\n");
24
+
25
+ test("no dist/: exit 2, the missing path, and both remedies — nothing from the loader", () => {
26
+ const r = spawnSync(
27
+ process.execPath,
28
+ [
29
+ "--import",
30
+ `data:text/javascript,${encodeURIComponent(HIDE_DIST)}`,
31
+ BIN,
32
+ "--version",
33
+ ],
34
+ { encoding: "utf8" },
35
+ );
36
+ assert.equal(r.status, 2);
37
+ assert.equal(r.stdout, "");
38
+ const [first, ...rest] = r.stderr.split("\n");
39
+ assert.equal(first, `paperlint: not built — ${DIST} missing.`);
40
+ assert.match(
41
+ rest.join("\n"),
42
+ /In a clone: `npm run build`\. For a git dependency: reinstall without `--ignore-scripts`\./,
43
+ );
44
+ assert.doesNotMatch(r.stderr, /ERR_MODULE_NOT_FOUND/);
45
+ });
@@ -1,3 +1,5 @@
1
1
  import type { Files } from "../../ports/files.ts";
2
+ /** An error's errno code; anything thrown that is not an Error has none. */
3
+ export declare const errorCode: (e: unknown) => unknown;
2
4
  export declare const nodeFiles: Files;
3
5
  //# sourceMappingURL=files.io.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"files.io.d.ts","sourceRoot":"","sources":["../../../src/adapters/node/files.io.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,sBAAsB,CAAC;AAKlD,eAAO,MAAM,SAAS,EAAE,KAsBvB,CAAC"}
1
+ {"version":3,"file":"files.io.d.ts","sourceRoot":"","sources":["../../../src/adapters/node/files.io.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,sBAAsB,CAAC;AAElD,4EAA4E;AAC5E,eAAO,MAAM,SAAS,GAAI,GAAG,OAAO,KAAG,OAC6B,CAAC;AAErE,eAAO,MAAM,SAAS,EAAE,KAsBvB,CAAC"}
@@ -1,7 +1,8 @@
1
1
  /** `Files` over `node:fs`. */
2
2
  import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
3
  import { dirname } from "node:path";
4
- const code = (e) => e instanceof Error ? e.code : undefined;
4
+ /** An error's errno code; anything thrown that is not an Error has none. */
5
+ export const errorCode = (e) => e instanceof Error ? e.code : undefined;
5
6
  export const nodeFiles = {
6
7
  isFile(p) {
7
8
  return statSync(p, { throwIfNoEntry: false })?.isFile() ?? false;
@@ -11,7 +12,7 @@ export const nodeFiles = {
11
12
  return readFileSync(p);
12
13
  }
13
14
  catch (e) {
14
- if (code(e) === "ENOENT")
15
+ if (errorCode(e) === "ENOENT")
15
16
  return null;
16
17
  throw e;
17
18
  }
@@ -1 +1 @@
1
- {"version":3,"file":"files.io.js","sourceRoot":"","sources":["../../../src/adapters/node/files.io.ts"],"names":[],"mappings":"AAAA,8BAA8B;AAC9B,OAAO,EACL,SAAS,EACT,YAAY,EACZ,UAAU,EACV,MAAM,EACN,QAAQ,EACR,aAAa,GACd,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAIpC,MAAM,IAAI,GAAG,CAAC,CAAU,EAAW,EAAE,CACnC,CAAC,YAAY,KAAK,CAAC,CAAC,CAAE,CAA2B,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;AAErE,MAAM,CAAC,MAAM,SAAS,GAAU;IAC9B,MAAM,CAAC,CAAe;QACpB,OAAO,QAAQ,CAAC,CAAC,EAAE,EAAE,cAAc,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,IAAI,KAAK,CAAC;IACnE,CAAC;IACD,SAAS,CAAC,CAAe;QACvB,IAAI,CAAC;YACH,OAAO,YAAY,CAAC,CAAC,CAAC,CAAC;QACzB,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,QAAQ;gBAAE,OAAO,IAAI,CAAC;YACtC,MAAM,CAAC,CAAC;QACV,CAAC;IACH,CAAC;IACD,WAAW,CAAC,CAAe,EAAE,KAAiB;QAC5C,MAAM,IAAI,GAAG,GAAG,CAAC,OAAO,CAAC;QACzB,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC3C,IAAI,CAAC;YACH,aAAa,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;YAC3B,UAAU,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;QACtB,CAAC;gBAAS,CAAC;YACT,MAAM,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QAChC,CAAC;IACH,CAAC;CACF,CAAC"}
1
+ {"version":3,"file":"files.io.js","sourceRoot":"","sources":["../../../src/adapters/node/files.io.ts"],"names":[],"mappings":"AAAA,8BAA8B;AAC9B,OAAO,EACL,SAAS,EACT,YAAY,EACZ,UAAU,EACV,MAAM,EACN,QAAQ,EACR,aAAa,GACd,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAIpC,4EAA4E;AAC5E,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,CAAU,EAAW,EAAE,CAC/C,CAAC,YAAY,KAAK,CAAC,CAAC,CAAE,CAA2B,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;AAErE,MAAM,CAAC,MAAM,SAAS,GAAU;IAC9B,MAAM,CAAC,CAAe;QACpB,OAAO,QAAQ,CAAC,CAAC,EAAE,EAAE,cAAc,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,IAAI,KAAK,CAAC;IACnE,CAAC;IACD,SAAS,CAAC,CAAe;QACvB,IAAI,CAAC;YACH,OAAO,YAAY,CAAC,CAAC,CAAC,CAAC;QACzB,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,IAAI,SAAS,CAAC,CAAC,CAAC,KAAK,QAAQ;gBAAE,OAAO,IAAI,CAAC;YAC3C,MAAM,CAAC,CAAC;QACV,CAAC;IACH,CAAC;IACD,WAAW,CAAC,CAAe,EAAE,KAAiB;QAC5C,MAAM,IAAI,GAAG,GAAG,CAAC,OAAO,CAAC;QACzB,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC3C,IAAI,CAAC;YACH,aAAa,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;YAC3B,UAAU,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;QACtB,CAAC;gBAAS,CAAC;YACT,MAAM,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QAChC,CAAC;IACH,CAAC;CACF,CAAC"}
@@ -25,11 +25,13 @@ export interface OperatorList {
25
25
  }
26
26
  /** `setFillRGBColor`'s argument: a `#rrggbb` string in current pdf.js, three 0-255 numbers in older ones. */
27
27
  export declare function hexOf(args: unknown): string;
28
+ /** One drawn character and the fill it was drawn in. */
29
+ interface Drawn {
30
+ readonly ch: string;
31
+ readonly fill: Fill;
32
+ }
28
33
  /** Every drawn character, in content order, with the fill it was drawn in. */
29
- export declare function drawnCharacters(ops: ColourOps, list: OperatorList): {
30
- chars: string[];
31
- fills: Fill[];
32
- };
34
+ export declare function drawnCharacters(ops: ColourOps, list: OperatorList): Drawn[];
33
35
  /** How far ahead the walk looks for an item it cannot find where it expects it. */
34
36
  export declare const LOOKAHEAD = 4000;
35
37
  /**
@@ -39,4 +41,13 @@ export declare const LOOKAHEAD = 4000;
39
41
  * a missed match can keep light text, never drop dark text.
40
42
  */
41
43
  export declare function fillsOf(ops: ColourOps, list: OperatorList, items: readonly string[]): Fill[];
44
+ /**
45
+ * Each item paired with its fill — `fillsOf` for items that are not bare strings, so a caller never
46
+ * matches the two lists up by index.
47
+ */
48
+ export declare function fillsFor<T>(ops: ColourOps, list: OperatorList, items: readonly T[], textOf: (item: T) => string): {
49
+ readonly item: T;
50
+ readonly fill: Fill;
51
+ }[];
52
+ export {};
42
53
  //# sourceMappingURL=fill.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"fill.d.ts","sourceRoot":"","sources":["../../../src/adapters/pdfjs/fill.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,6BAA6B,CAAC;AAExD,yFAAyF;AACzF,wBAAgB,SAAS,CAAC,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAGvD;AAED,0FAA0F;AAC1F,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAC;IACtC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;CACjC;AAED,qFAAqF;AACrF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,SAAS,EAAE,SAAS,OAAO,EAAE,CAAC;CACxC;AAKD,6GAA6G;AAC7G,wBAAgB,KAAK,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,CAQ3C;AAuCD,8EAA8E;AAC9E,wBAAgB,eAAe,CAC7B,GAAG,EAAE,SAAS,EACd,IAAI,EAAE,YAAY,GACjB;IAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,KAAK,EAAE,IAAI,EAAE,CAAA;CAAE,CAepC;AAED,mFAAmF;AACnF,eAAO,MAAM,SAAS,OAAO,CAAC;AAc9B;;;;;GAKG;AACH,wBAAgB,OAAO,CACrB,GAAG,EAAE,SAAS,EACd,IAAI,EAAE,YAAY,EAClB,KAAK,EAAE,SAAS,MAAM,EAAE,GACvB,IAAI,EAAE,CAWR"}
1
+ {"version":3,"file":"fill.d.ts","sourceRoot":"","sources":["../../../src/adapters/pdfjs/fill.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,6BAA6B,CAAC;AAExD,yFAAyF;AACzF,wBAAgB,SAAS,CAAC,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAGvD;AAED,0FAA0F;AAC1F,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAC;IACtC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;CACjC;AAED,qFAAqF;AACrF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,SAAS,EAAE,SAAS,OAAO,EAAE,CAAC;CACxC;AAKD,6GAA6G;AAC7G,wBAAgB,KAAK,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,CAQ3C;AAuCD,wDAAwD;AACxD,UAAU,KAAK;IACb,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;CACrB;AAED,8EAA8E;AAC9E,wBAAgB,eAAe,CAAC,GAAG,EAAE,SAAS,EAAE,IAAI,EAAE,YAAY,GAAG,KAAK,EAAE,CAW3E;AAED,mFAAmF;AACnF,eAAO,MAAM,SAAS,OAAO,CAAC;AAoB9B;;;;;GAKG;AACH,wBAAgB,OAAO,CACrB,GAAG,EAAE,SAAS,EACd,IAAI,EAAE,YAAY,EAClB,KAAK,EAAE,SAAS,MAAM,EAAE,GACvB,IAAI,EAAE,CAER;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EACxB,GAAG,EAAE,SAAS,EACd,IAAI,EAAE,YAAY,EAClB,KAAK,EAAE,SAAS,CAAC,EAAE,EACnB,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,MAAM,GAC1B;IAAE,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAA;CAAE,EAAE,CAY7C"}
@@ -42,8 +42,7 @@ function applyState(ops, fn, args, s) {
42
42
  /** Every drawn character, in content order, with the fill it was drawn in. */
43
43
  export function drawnCharacters(ops, list) {
44
44
  const s = { cur: { fill: { kind: "unknown" }, mode: 0 }, stack: [] };
45
- const chars = [];
46
- const fills = [];
45
+ const drawn = [];
47
46
  list.fnArray.forEach((fn, i) => {
48
47
  const args = list.argsArray[i];
49
48
  if (applyState(ops, fn, args, s))
@@ -51,22 +50,25 @@ export function drawnCharacters(ops, list) {
51
50
  if (fn !== ops.showText && fn !== ops.showSpacedText)
52
51
  return;
53
52
  const fill = s.cur.mode === 3 ? { kind: "invisible" } : s.cur.fill;
54
- for (const ch of squeeze(glyphText(args))) {
55
- chars.push(ch);
56
- fills.push(fill);
57
- }
53
+ for (const ch of squeeze(glyphText(args)))
54
+ drawn.push({ ch, fill });
58
55
  });
59
- return { chars, fills };
56
+ return drawn;
60
57
  }
61
58
  /** How far ahead the walk looks for an item it cannot find where it expects it. */
62
59
  export const LOOKAHEAD = 4000;
63
- /** The position at or after `from` where `want` starts in `chars`, or -1 within the look-ahead. */
64
- function findRun(chars, want, from) {
65
- const limit = Math.min(chars.length, from + LOOKAHEAD);
66
- for (let q = from; q < limit; q++)
67
- if (want.every((ch, k) => chars[q + k] === ch))
68
- return q;
69
- return -1;
60
+ /**
61
+ * Where `want` starts in `drawn`, at or after `from` within the look-ahead, and the fill it was
62
+ * drawn in — or null. The fill comes from the matched element itself, not a second lookup.
63
+ */
64
+ function findRun(drawn, want, from) {
65
+ const window = drawn.slice(from, from + LOOKAHEAD);
66
+ for (const [i, d] of window.entries()) {
67
+ const at = from + i;
68
+ if (want.every((ch, k) => drawn[at + k]?.ch === ch))
69
+ return { at, fill: d.fill };
70
+ }
71
+ return null;
70
72
  }
71
73
  /**
72
74
  * The fill of each text item. pdf.js's text content carries no colour, so the operator list is
@@ -75,17 +77,25 @@ function findRun(chars, want, from) {
75
77
  * a missed match can keep light text, never drop dark text.
76
78
  */
77
79
  export function fillsOf(ops, list, items) {
78
- const { chars, fills } = drawnCharacters(ops, list);
80
+ return fillsFor(ops, list, items, (text) => text).map((p) => p.fill);
81
+ }
82
+ /**
83
+ * Each item paired with its fill — `fillsOf` for items that are not bare strings, so a caller never
84
+ * matches the two lists up by index.
85
+ */
86
+ export function fillsFor(ops, list, items, textOf) {
87
+ const drawn = drawnCharacters(ops, list);
79
88
  let at = 0;
80
- return items.map((text) => {
89
+ const fillOf = (text) => {
81
90
  const want = [...squeeze(text)];
82
91
  if (want.length === 0)
83
92
  return { kind: "unknown" };
84
- const q = findRun(chars, want, at);
85
- if (q < 0)
93
+ const hit = findRun(drawn, want, at);
94
+ if (hit === null)
86
95
  return { kind: "unknown" };
87
- at = q + want.length;
88
- return fills[q] ?? { kind: "unknown" };
89
- });
96
+ at = hit.at + want.length;
97
+ return hit.fill;
98
+ };
99
+ return items.map((item) => ({ item, fill: fillOf(textOf(item)) }));
90
100
  }
91
101
  //# sourceMappingURL=fill.js.map