paperlint 3.0.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/.github/workflows/ci.yml +28 -41
  2. package/.github/workflows/dependabot-automerge.yml +1 -1
  3. package/CLAUDE.md +51 -56
  4. package/CONTRIBUTING.md +30 -18
  5. package/README.md +179 -180
  6. package/bin/paperlint.test.mjs +45 -0
  7. package/dist/adapters/node/files.io.d.ts +2 -0
  8. package/dist/adapters/node/files.io.d.ts.map +1 -1
  9. package/dist/adapters/node/files.io.js +3 -2
  10. package/dist/adapters/node/files.io.js.map +1 -1
  11. package/dist/adapters/pdfjs/fill.d.ts +15 -4
  12. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  13. package/dist/adapters/pdfjs/fill.js +31 -21
  14. package/dist/adapters/pdfjs/fill.js.map +1 -1
  15. package/dist/adapters/references/index.d.ts.map +1 -1
  16. package/dist/adapters/references/index.js +14 -5
  17. package/dist/adapters/references/index.js.map +1 -1
  18. package/dist/adapters/references/pool.d.ts +8 -0
  19. package/dist/adapters/references/pool.d.ts.map +1 -0
  20. package/dist/adapters/references/pool.js +19 -0
  21. package/dist/adapters/references/pool.js.map +1 -0
  22. package/dist/build-engine.d.ts.map +1 -1
  23. package/dist/build-engine.js +6 -2
  24. package/dist/build-engine.js.map +1 -1
  25. package/dist/build.d.ts +10 -2
  26. package/dist/build.d.ts.map +1 -1
  27. package/dist/build.js +29 -11
  28. package/dist/build.js.map +1 -1
  29. package/dist/cli.d.ts +93 -6
  30. package/dist/cli.d.ts.map +1 -1
  31. package/dist/cli.js +296 -80
  32. package/dist/cli.js.map +1 -1
  33. package/dist/doctor.d.ts +27 -1
  34. package/dist/doctor.d.ts.map +1 -1
  35. package/dist/doctor.js +4 -3
  36. package/dist/doctor.js.map +1 -1
  37. package/dist/domain/result.d.ts +0 -2
  38. package/dist/domain/result.d.ts.map +1 -1
  39. package/dist/domain/result.js +0 -4
  40. package/dist/domain/result.js.map +1 -1
  41. package/dist/engine.d.ts +9 -0
  42. package/dist/engine.d.ts.map +1 -1
  43. package/dist/engine.js +27 -0
  44. package/dist/engine.js.map +1 -1
  45. package/dist/hooks-settings.d.ts +8 -5
  46. package/dist/hooks-settings.d.ts.map +1 -1
  47. package/dist/hooks-settings.js +32 -32
  48. package/dist/hooks-settings.js.map +1 -1
  49. package/dist/init.d.ts +8 -0
  50. package/dist/init.d.ts.map +1 -1
  51. package/dist/init.js +4 -4
  52. package/dist/init.js.map +1 -1
  53. package/dist/latex-log.d.ts +0 -2
  54. package/dist/latex-log.d.ts.map +1 -1
  55. package/dist/latex-log.js +7 -13
  56. package/dist/latex-log.js.map +1 -1
  57. package/dist/link-skills.d.ts +15 -2
  58. package/dist/link-skills.d.ts.map +1 -1
  59. package/dist/link-skills.js +36 -20
  60. package/dist/link-skills.js.map +1 -1
  61. package/dist/new-paper.d.ts +8 -1
  62. package/dist/new-paper.d.ts.map +1 -1
  63. package/dist/new-paper.js +50 -15
  64. package/dist/new-paper.js.map +1 -1
  65. package/dist/paper-files.d.ts +30 -8
  66. package/dist/paper-files.d.ts.map +1 -1
  67. package/dist/paper-files.js +59 -30
  68. package/dist/paper-files.js.map +1 -1
  69. package/dist/pdf-facts.d.ts +11 -0
  70. package/dist/pdf-facts.d.ts.map +1 -1
  71. package/dist/pdf-facts.js +16 -18
  72. package/dist/pdf-facts.js.map +1 -1
  73. package/dist/pdf-geometry.js +1 -1
  74. package/dist/pdf-geometry.js.map +1 -1
  75. package/dist/presets.d.ts +2 -0
  76. package/dist/presets.d.ts.map +1 -1
  77. package/dist/presets.js +3 -1
  78. package/dist/presets.js.map +1 -1
  79. package/dist/references.js +2 -2
  80. package/dist/references.js.map +1 -1
  81. package/dist/structure.js +1 -1
  82. package/dist/structure.js.map +1 -1
  83. package/dist/tex-requirements.d.ts +8 -0
  84. package/dist/tex-requirements.d.ts.map +1 -1
  85. package/dist/tex-requirements.js +6 -5
  86. package/dist/tex-requirements.js.map +1 -1
  87. package/dist/toolchain.d.ts +8 -3
  88. package/dist/toolchain.d.ts.map +1 -1
  89. package/dist/toolchain.js +26 -10
  90. package/dist/toolchain.js.map +1 -1
  91. package/dist/types.d.ts +6 -0
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/venue-rules.d.ts.map +1 -1
  94. package/dist/venue-rules.js +12 -10
  95. package/dist/venue-rules.js.map +1 -1
  96. package/docs/configuration.md +48 -4
  97. package/docs/prior-art/README.md +1 -1
  98. package/docs/prior-art/test-tooling.md +7 -30
  99. package/docs/rules.md +29 -25
  100. package/docs/skills.md +80 -0
  101. package/eslint-rules/install-path-literals.harness.mjs +17 -1
  102. package/eslint-rules/latex-language.harness.mjs +3 -3
  103. package/eslint-rules/latex-language.mjs +12 -9
  104. package/eslint-rules/latex-language.test.mjs +200 -0
  105. package/eslint-rules/paper-research-question.harness.mjs +73 -7
  106. package/eslint-rules/paper-stages.harness.mjs +9 -14
  107. package/eslint-rules/paper-stages.test.mjs +135 -0
  108. package/eslint-rules/paper-typography.mjs +56 -9
  109. package/eslint-rules/paper-typography.test.mjs +186 -1
  110. package/eslint-rules/pdf-last-page-balance.harness.mjs +63 -7
  111. package/eslint-rules/review-frontmatter.mjs +2 -1
  112. package/eslint-rules/review-frontmatter.schema.json +1 -1
  113. package/eslint-rules/review-frontmatter.test.mjs +37 -1
  114. package/eslint-rules/temp-root-realpath.harness.mjs +49 -0
  115. package/eslint-rules/tex-build.mjs +13 -13
  116. package/eslint.config.mjs +6 -2
  117. package/fixtures/toolchain-mirror/catalog.txt +1 -0
  118. package/fixtures/toolchain-mirror/install-tl +4 -1
  119. package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
  120. package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
  121. package/hooks/hooks.harness.mjs +6 -11
  122. package/hooks/paper-edit-guard.hook.mjs +2 -2
  123. package/hooks/paper-edit-guard.test.mjs +61 -0
  124. package/hooks/paper-skills-nudge.hook.mjs +2 -2
  125. package/hooks/paper-skills-nudge.test.mjs +38 -0
  126. package/hooks/paper-status-gates.test.mjs +42 -0
  127. package/lib/agent-cli-version.harness.mjs +7 -0
  128. package/lib/check.d.mts +17 -0
  129. package/lib/check.mjs +50 -0
  130. package/lib/check.test.ts +67 -0
  131. package/lib/markdown.mjs +16 -69
  132. package/lib/markdown.test.mjs +246 -0
  133. package/lib/paper-config.harness.mjs +28 -9
  134. package/lib/paper-config.mjs +7 -2
  135. package/lib/skill-checks.mjs +18 -46
  136. package/lib/skill-checks.test.mjs +297 -0
  137. package/lib/skill-corpus.mjs +11 -13
  138. package/lib/skill-corpus.test.mjs +54 -0
  139. package/lib/skill-trigger-cases.harness.mjs +57 -4
  140. package/package.json +6 -5
  141. package/scripts/check.harness.mjs +4 -10
  142. package/scripts/check.mjs +45 -42
  143. package/scripts/check.test.mjs +104 -0
  144. package/scripts/coverage-config.test.ts +406 -0
  145. package/scripts/eslint-report-guard.test.mjs +48 -0
  146. package/scripts/harness-api.frozen.json +0 -2
  147. package/scripts/layer-legacy-frozen.d.mts +9 -1
  148. package/scripts/layer-legacy-frozen.mjs +28 -11
  149. package/scripts/layer-legacy-frozen.test.ts +143 -1
  150. package/scripts/rules-are-content-only.harness.mjs +3 -11
  151. package/scripts/rules-are-content-only.mjs +29 -19
  152. package/scripts/rules-are-content-only.test.mjs +61 -0
  153. package/scripts/rules-see-files.mjs +11 -9
  154. package/scripts/rules-see-files.test.mjs +71 -0
  155. package/scripts/test.mjs +44 -0
  156. package/scripts/test.test.mjs +52 -0
  157. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.mjs.snap +57 -0
  158. package/skills/grade-paper-writing/prose-lint.mjs +31 -12
  159. package/skills/grade-paper-writing/prose-lint.test.mjs +153 -0
  160. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +3 -3
  161. package/skills/paper-pipeline/scripts/README.md +13 -132
  162. package/skills/paper-pipeline/scripts/announce.test.mjs +102 -0
  163. package/skills/paper-pipeline/scripts/artifact-coverage.test.mjs +86 -0
  164. package/skills/paper-pipeline/scripts/consumer.d.mts +8 -1
  165. package/skills/paper-pipeline/scripts/consumer.harness.mjs +3 -3
  166. package/skills/paper-pipeline/scripts/consumer.mjs +13 -25
  167. package/skills/paper-pipeline/scripts/consumer.test.mjs +113 -0
  168. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +9 -4
  169. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +380 -0
  170. package/skills/paper-pipeline/scripts/fixtures/scorecard.mjs +71 -0
  171. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +4 -4
  172. package/skills/paper-pipeline/scripts/generated-code.mjs +3 -2
  173. package/skills/paper-pipeline/scripts/generated-code.test.mjs +62 -0
  174. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +302 -0
  175. package/skills/paper-pipeline/scripts/ledger.mjs +1 -1
  176. package/skills/paper-pipeline/scripts/{ledger.selftest.mjs → ledger.test.mjs} +44 -76
  177. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +474 -0
  178. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +2 -63
  179. package/skills/paper-pipeline/scripts/pipeline-check.mjs +37 -21
  180. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +2 -2
  181. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +73 -0
  182. package/skills/paper-pipeline/scripts/population-map.harness.mjs +4 -47
  183. package/skills/paper-pipeline/scripts/population-map.mjs +12 -2
  184. package/skills/paper-pipeline/scripts/population-map.test.mjs +154 -0
  185. package/skills/paper-pipeline/scripts/provenance.harness.mjs +26 -0
  186. package/skills/paper-pipeline/scripts/round-diff.harness.mjs +1 -2
  187. package/skills/paper-pipeline/scripts/round-diff.mjs +13 -6
  188. package/skills/paper-pipeline/scripts/round-diff.test.mjs +159 -0
  189. package/skills/paper-pipeline/scripts/run-mechanical.mjs +30 -6
  190. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +468 -0
  191. package/skills/paper-pipeline/scripts/status.mjs +2 -2
  192. package/skills/paper-pipeline/scripts/status.test.mjs +232 -0
  193. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +2 -2
  194. package/skills/render-paper/extract-pdf-facts.harness.mjs +56 -7
  195. package/skills/tighten-paper/__snapshots__/structure.test.mjs.snap +133 -0
  196. package/skills/tighten-paper/structure.test.mjs +116 -0
  197. package/skills/verify-citations/SKILL.md +5 -5
  198. package/skills/verify-citations/SKILL.md.spec.ts +4 -4
  199. package/skills/verify-citations/scripts/__snapshots__/verify-cites.net.test.mjs.snap +235 -0
  200. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +274 -0
  201. package/skills/verify-citations/scripts/bib-authors.cli.test.mjs +178 -0
  202. package/skills/verify-citations/scripts/bib-authors.mjs +2 -1
  203. package/skills/verify-citations/scripts/verify-cites.cli.test.mjs +160 -0
  204. package/skills/verify-citations/scripts/verify-cites.mjs +96 -96
  205. package/skills/verify-citations/scripts/verify-cites.net.test.mjs +287 -0
  206. package/skills/verify-citations/scripts/verify-cites.test.mjs +149 -5
  207. package/src/adapters/banal/failure.test.ts +70 -1
  208. package/src/adapters/banal/index.test.ts +16 -0
  209. package/src/adapters/banal/output.test.ts +5 -0
  210. package/src/adapters/curl/download.test.ts +43 -1
  211. package/src/adapters/memory/index.test.ts +25 -0
  212. package/src/adapters/node/files.io.ts +3 -2
  213. package/src/adapters/node/files.test.ts +13 -1
  214. package/src/adapters/node/process.test.ts +38 -0
  215. package/src/adapters/pdfjs/fill.test.ts +34 -0
  216. package/src/adapters/pdfjs/fill.ts +44 -25
  217. package/src/adapters/references/index.test.ts +228 -0
  218. package/src/adapters/references/index.ts +21 -6
  219. package/src/adapters/references/pool.test.ts +31 -0
  220. package/src/adapters/references/pool.ts +24 -0
  221. package/src/build-engine.harness.mjs +3 -7
  222. package/src/build-engine.test.ts +137 -0
  223. package/src/build-engine.ts +6 -1
  224. package/src/build.harness.mjs +51 -11
  225. package/src/build.test.ts +362 -0
  226. package/src/build.ts +40 -14
  227. package/src/cli.harness.mjs +12 -19
  228. package/src/cli.test.ts +584 -0
  229. package/src/cli.ts +409 -94
  230. package/src/doctor.harness.mjs +3 -8
  231. package/src/doctor.test.ts +64 -0
  232. package/src/doctor.ts +12 -4
  233. package/src/domain/result.test.ts +4 -0
  234. package/src/domain/result.ts +0 -5
  235. package/src/engine.harness.mjs +77 -7
  236. package/src/engine.ts +35 -0
  237. package/src/facts-file.test.ts +110 -0
  238. package/src/hooks-settings.harness.mjs +43 -13
  239. package/src/hooks-settings.ts +41 -35
  240. package/src/init.test.ts +308 -5
  241. package/src/init.ts +4 -4
  242. package/src/latex-log.harness.mjs +32 -7
  243. package/src/latex-log.ts +6 -13
  244. package/src/latex-loop.harness.mjs +8 -7
  245. package/src/link-skills.harness.mjs +3 -8
  246. package/src/link-skills.test.ts +105 -0
  247. package/src/link-skills.ts +75 -21
  248. package/src/linted-set.test.ts +118 -0
  249. package/src/new-paper.harness.mjs +5 -7
  250. package/src/new-paper.test.ts +203 -2
  251. package/src/new-paper.ts +84 -17
  252. package/src/paper-files.test.ts +122 -5
  253. package/src/paper-files.ts +99 -39
  254. package/src/paper-settings-commands.test.ts +41 -5
  255. package/src/paper-settings.test.ts +20 -0
  256. package/src/parse-args.test.ts +48 -0
  257. package/src/pdf-facts.harness.mjs +3 -6
  258. package/src/pdf-facts.test.ts +144 -0
  259. package/src/pdf-facts.ts +17 -19
  260. package/src/pdf-geometry.harness.mjs +3 -7
  261. package/src/pdf-geometry.ts +1 -1
  262. package/src/presets.test.ts +2 -0
  263. package/src/presets.ts +7 -1
  264. package/src/references.test.ts +97 -0
  265. package/src/references.ts +2 -2
  266. package/src/rules-config.harness.mjs +10 -8
  267. package/src/rules-off.test.ts +111 -0
  268. package/src/structure.harness.mjs +5 -9
  269. package/src/structure.ts +1 -1
  270. package/src/tex-requirements.harness.mjs +34 -7
  271. package/src/tex-requirements.ts +12 -6
  272. package/src/toolchain.harness.mjs +44 -13
  273. package/src/toolchain.test.ts +280 -0
  274. package/src/toolchain.ts +37 -12
  275. package/src/types.ts +6 -0
  276. package/src/venue-rules.test.ts +63 -0
  277. package/src/venue-rules.ts +14 -12
  278. package/templates/paper/paperlint.json +1 -1
  279. package/action.mutations.mjs +0 -162
  280. package/eslint-rules/install-path-literals.mutations.mjs +0 -62
  281. package/eslint-rules/latex-language.mutations.mjs +0 -196
  282. package/eslint-rules/paper-research-question.mutations.mjs +0 -127
  283. package/eslint-rules/paper-stages.mutations.mjs +0 -81
  284. package/eslint-rules/papers.mutations.mjs +0 -186
  285. package/eslint-rules/temp-root-realpath.mutations.mjs +0 -99
  286. package/eslint-rules/tex-build.mutations.mjs +0 -258
  287. package/hooks/hooks.mutations.mjs +0 -337
  288. package/lib/agent-cli-version.mutations.mjs +0 -109
  289. package/lib/mutation-driver.harness.mjs +0 -227
  290. package/lib/mutation-driver.mjs +0 -397
  291. package/lib/mutation-driver.mutations.mjs +0 -68
  292. package/lib/paper-config.mutations.mjs +0 -143
  293. package/lib/skill-trigger-cases.mutations.mjs +0 -65
  294. package/scripts/check.mutations.mjs +0 -110
  295. package/scripts/exclusive.mjs +0 -138
  296. package/scripts/mutation-batteries-frozen.harness.mjs +0 -204
  297. package/scripts/mutation-batteries-frozen.mjs +0 -238
  298. package/scripts/mutation-batteries.frozen.json +0 -108
  299. package/scripts/rules-are-content-only.mutations.mjs +0 -81
  300. package/scripts/rules-see-files.mutations.mjs +0 -131
  301. package/scripts/run-mutations.mjs +0 -100
  302. package/skills/paper-pipeline/scripts/artifact-coverage.mutations.mjs +0 -218
  303. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +0 -190
  304. package/skills/paper-pipeline/scripts/extract-ref-facts.mutations.mjs +0 -54
  305. package/skills/paper-pipeline/scripts/generated-code.mutations.mjs +0 -254
  306. package/skills/paper-pipeline/scripts/pipeline-check.mutations.mjs +0 -54
  307. package/skills/paper-pipeline/scripts/population-map.mutations.mjs +0 -65
  308. package/skills/paper-pipeline/scripts/population-map.selftest.mjs +0 -122
  309. package/skills/paper-pipeline/scripts/provenance.mutations.mjs +0 -59
  310. package/skills/paper-pipeline/scripts/round-diff.mutations.mjs +0 -276
  311. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +0 -65
  312. package/skills/skill-contract.mutations.mjs +0 -179
  313. package/src/build.mutations.mjs +0 -196
  314. package/src/cli.mutations.mjs +0 -414
  315. package/src/doctor.mutations.mjs +0 -175
  316. package/src/hooks-settings.mutations.mjs +0 -99
  317. package/src/link-skills.mutations.mjs +0 -140
  318. package/src/new-paper.mutations.mjs +0 -79
  319. package/src/structure.mutations.mjs +0 -83
package/src/new-paper.ts CHANGED
@@ -98,12 +98,85 @@ function scorecardAndSource(dir: string, format: PaperFormat): string[] {
98
98
  return hasSource ? [STATUS_FILE] : [STATUS_FILE, SOURCE_FILE[format]];
99
99
  }
100
100
 
101
- // Documented in README.md#starting-a-paper — update it when this changes.
101
+ /** The venue `paperlint new --venue` chose: what goes into the new paper's `paperlint.json`. */
102
+ export interface VenueSetting {
103
+ /** `paperlint:<name>`, or a path relative to the paper's `paperlint.json`. */
104
+ readonly extends: string;
105
+ readonly kind: string | null;
106
+ }
107
+
108
+ /**
109
+ * The template's `paperlint.json` with the chosen venue written in. The template's `$comment`
110
+ * explains how to choose one, so it goes once one is chosen. A template that is not plain JSON (a
111
+ * project's own, with comments) cannot be edited safely and is refused by name.
112
+ */
113
+ function withVenue(
114
+ text: string,
115
+ src: string,
116
+ venue: VenueSetting,
117
+ ): { ok: true; text: string } | { ok: false; reason: string } {
118
+ let obj: Record<string, unknown>;
119
+ try {
120
+ obj = JSON.parse(text) as Record<string, unknown>;
121
+ } catch {
122
+ return {
123
+ ok: false,
124
+ reason: `--venue cannot be written into ${src}: it is not plain JSON — set "extends" by hand`,
125
+ };
126
+ }
127
+ const out: Record<string, unknown> = { ...obj, extends: venue.extends };
128
+ delete out["$comment"];
129
+ if (venue.kind !== null) out["kind"] = venue.kind;
130
+ return { ok: true, text: `${JSON.stringify(out, null, 2)}\n` };
131
+ }
132
+
133
+ /** A file `newPaper` will write: its text, and which template it came from. */
134
+ interface Planned {
135
+ readonly file: string;
136
+ readonly text: string;
137
+ readonly from: "project" | "package";
138
+ }
139
+
140
+ /** One file's text from its template — the project's override first, the package's second. */
141
+ function fromTemplate(
142
+ file: string,
143
+ {
144
+ papersRoot,
145
+ packageTemplates,
146
+ name,
147
+ venue,
148
+ }: {
149
+ papersRoot: string;
150
+ packageTemplates: string;
151
+ name: string;
152
+ venue: VenueSetting | null;
153
+ },
154
+ ): { ok: true; value: Planned } | { ok: false; reason: string } {
155
+ const project = join(papersRoot, OVERRIDE_DIR, file);
156
+ const [src, from] = existsSync(project)
157
+ ? [project, "project" as const]
158
+ : [join(packageTemplates, file), "package" as const];
159
+ // A missing package template is a broken install, not a reason to write an empty file.
160
+ if (!existsSync(src))
161
+ return { ok: false, reason: `no template for ${file}: ${src} is missing` };
162
+ const text = readFileSync(src, "utf8").split("{{name}}").join(name);
163
+ if (file !== CONFIG_FILE || venue === null)
164
+ return { ok: true, value: { file, text, from } };
165
+ const edited = withVenue(text, src, venue);
166
+ return edited.ok
167
+ ? { ok: true, value: { file, text: edited.text, from } }
168
+ : edited;
169
+ }
170
+
171
+ // Documented in README.md#getting-started — update it when this changes.
102
172
  export function newPaper(
103
173
  papersRoot: string,
104
174
  name: string,
105
175
  format: PaperFormat,
106
- { packageTemplates = PACKAGE_TEMPLATES }: { packageTemplates?: string } = {},
176
+ {
177
+ packageTemplates = PACKAGE_TEMPLATES,
178
+ venue = null,
179
+ }: { packageTemplates?: string; venue?: VenueSetting | null } = {},
107
180
  ): NewPaperResult {
108
181
  const problem = nameProblem(name);
109
182
  if (problem) return { ok: false, reason: problem };
@@ -113,26 +186,20 @@ export function newPaper(
113
186
  return { ok: false, reason: `${dir} exists and is not a directory` };
114
187
 
115
188
  const files: FileOutcome[] = [];
116
- const toWrite: { file: string; text: string; from: "project" | "package" }[] =
117
- [];
189
+ const toWrite: Planned[] = [];
118
190
  for (const file of wantedFiles(dir, format)) {
119
191
  if (existsSync(join(dir, file))) {
120
192
  files.push({ file, status: "kept" });
121
193
  continue;
122
194
  }
123
- const project = join(papersRoot, OVERRIDE_DIR, file);
124
- const fromPackage = join(packageTemplates, file);
125
- const [src, from] = existsSync(project)
126
- ? [project, "project" as const]
127
- : [fromPackage, "package" as const];
128
- // A missing package template is a broken install, not a reason to write an empty file.
129
- if (!existsSync(src))
130
- return {
131
- ok: false,
132
- reason: `no template for ${file}: ${src} is missing`,
133
- };
134
- const text = readFileSync(src, "utf8").split("{{name}}").join(name);
135
- toWrite.push({ file, text, from });
195
+ const planned = fromTemplate(file, {
196
+ papersRoot,
197
+ packageTemplates,
198
+ name,
199
+ venue,
200
+ });
201
+ if (!planned.ok) return planned;
202
+ toWrite.push(planned.value);
136
203
  }
137
204
  // Read every template before writing any file, so a missing one leaves no half-made folder.
138
205
  mkdirSync(dir, { recursive: true });
@@ -23,6 +23,7 @@ import { buildConfig, run } from "./cli.ts";
23
23
  import {
24
24
  narrowToOwners,
25
25
  ownedPatterns,
26
+ ownedScopes,
26
27
  ruleOwners,
27
28
  scopeToOwned,
28
29
  } from "./paper-files.ts";
@@ -160,6 +161,73 @@ describe("`rules` blocks reach only the files each rule is written for", () => {
160
161
  });
161
162
  });
162
163
 
164
+ describe("🔴 a generated block never reaches a file paperlint does not lint (#101)", () => {
165
+ // A venue preset with `rules` (agenticdev turns on pdf/last-page-balance) became a block over
166
+ // every paper file. `pdf` is registered for every file, so the block was not narrowed, and the
167
+ // siblings index — which the sibling-card block excludes — matched a block with no language:
168
+ // ESLint parsed it as JavaScript ("Parsing error: Assigning to rvalue").
169
+ const SIBLINGS = {
170
+ "papers/a/siblings/README.md": "# Siblings\n\n- [x](smith2025.md)\n",
171
+ "papers/a/siblings/smith2025.md": "---\nread: full\n---\n# Smith 2025\n",
172
+ };
173
+
174
+ it.each([
175
+ [
176
+ "a venue preset's rules",
177
+ {
178
+ "papers/a/paperlint.json": JSON.stringify({
179
+ extends: "paperlint:agenticdev",
180
+ }),
181
+ },
182
+ ],
183
+ [
184
+ // `papers/**` alone would not show it: ESLint does not lint a file on a pattern ending in
185
+ // `/**`, it only applies such a block to files something else claims.
186
+ "a root block over every markdown file",
187
+ {
188
+ "paperlint.json": JSON.stringify({
189
+ rules: [
190
+ {
191
+ files: ["papers/**/*.md", "papers/**/*.tex"],
192
+ rules: { "pdf/last-page-balance": "warn" },
193
+ },
194
+ ],
195
+ }),
196
+ },
197
+ ],
198
+ [
199
+ "a paper's own { id: severity }",
200
+ {
201
+ "papers/a/paperlint.json": JSON.stringify({
202
+ rules: { "pdf/last-page-balance": "warn" },
203
+ }),
204
+ },
205
+ ],
206
+ ])(
207
+ "%s: the siblings index is not parsed, the cards still are",
208
+ async (_, settings) => {
209
+ const root = project({ ...SIBLINGS, ...settings });
210
+ const r = await lint(root);
211
+ const results = JSON.parse(r.out) as {
212
+ filePath: string;
213
+ messages: { ruleId: string | null; message: string }[];
214
+ }[];
215
+ // Guards: the class — no file reaches ESLint's default JavaScript parser.
216
+ expect(
217
+ results.flatMap((x) => x.messages).filter((m) => m.ruleId === null),
218
+ ).toEqual([]);
219
+ expect(linted(r.out)).not.toContain(
220
+ join(root, "papers/a/siblings/README.md"),
221
+ );
222
+ // Guards: the other half — narrowing did not drop the files the block is for.
223
+ expect(linted(r.out)).toContain(
224
+ join(root, "papers/a/siblings/smith2025.md"),
225
+ );
226
+ expect(linted(r.out)).toContain(join(root, "papers/a/paper.tex"));
227
+ },
228
+ );
229
+ });
230
+
163
231
  describe("`rules` blocks — where the rule lands", () => {
164
232
  it("on its own files and on no other", async () => {
165
233
  const root = project();
@@ -206,11 +274,60 @@ describe("paper-files — the pieces", () => {
206
274
  expect(ownedPatterns(own)).toEqual(["**/a.md", "**/b.md", "**/c.md"]);
207
275
  });
208
276
 
209
- it("a rule's owner is where its plugin is registered; a global plugin owns every file", () => {
277
+ it("a rule's owner is where its plugin is registered; a global plugin owns every OWNED file", () => {
210
278
  const owners = ruleOwners(own);
211
- expect(owners.get("g/any")).toBeNull();
212
- expect(owners.get("p/one")).toEqual(["**/a.md"]);
213
- expect(owners.get("p/two")).toEqual(["**/b.md", "**/c.md"]);
279
+ expect(owners.get("g/any")).toEqual([
280
+ { files: ["**/a.md", "**/b.md", "**/c.md"], ignores: [] },
281
+ ]);
282
+ expect(owners.get("p/one")).toEqual([{ files: ["**/a.md"], ignores: [] }]);
283
+ expect(owners.get("p/two")).toEqual([
284
+ { files: ["**/b.md", "**/c.md"], ignores: [] },
285
+ ]);
286
+ });
287
+
288
+ it("🔴 a scope keeps its block's exceptions, and a global plugin inherits them (#101)", () => {
289
+ const withIndex = [
290
+ ...own,
291
+ {
292
+ files: ["**/s/*.md"],
293
+ ignores: ["**/s/README.md"],
294
+ plugins: { s: { rules: { card: {} } } },
295
+ },
296
+ ];
297
+ expect(ownedScopes(withIndex)).toEqual([
298
+ { files: ["**/a.md", "**/b.md", "**/c.md"], ignores: [] },
299
+ { files: ["**/s/*.md"], ignores: ["**/s/README.md"] },
300
+ ]);
301
+ // Guards: the defect — the plugin registered for every file used to keep any glob, and so
302
+ // reached the index the card block excludes.
303
+ expect(ruleOwners(withIndex).get("g/any")).toEqual(ownedScopes(withIndex));
304
+ expect(
305
+ narrowToOwners(
306
+ {
307
+ basePath: "/x",
308
+ files: ["papers/**/*.md"],
309
+ rules: { "g/any": "warn" },
310
+ },
311
+ ruleOwners(withIndex),
312
+ ).at(-1),
313
+ ).toEqual({
314
+ basePath: "/x",
315
+ files: [["papers/**/*.md", "**/s/*.md"]],
316
+ ignores: ["**/s/README.md"],
317
+ rules: { "g/any": "warn" },
318
+ });
319
+ });
320
+ });
321
+
322
+ describe("paper-files — blocks that carry no rules", () => {
323
+ it("a plugin with only a language registers no rule; a block with no files owns no pattern of its own", () => {
324
+ const own = [
325
+ { plugins: { tex: { languages: {} } } },
326
+ { ignores: ["**/x.md"], plugins: { tex: { languages: {} } } },
327
+ { files: ["**/a.md"], plugins: { p: { rules: { one: {} } } } },
328
+ ];
329
+ expect([...ruleOwners(own).keys()]).toEqual(["p/one"]);
330
+ expect(ownedPatterns(own)).toEqual(["**/a.md"]);
214
331
  });
215
332
  });
216
333
 
@@ -249,7 +366,7 @@ describe("paper-files — narrowing a block", () => {
249
366
  },
250
367
  {
251
368
  basePath: "/x",
252
- files: ["papers/**"],
369
+ files: ["**/a.md", "**/b.md", "**/c.md"].map((o) => ["papers/**", o]),
253
370
  ignores: ["papers/old/**"],
254
371
  rules: { "g/any": "off" },
255
372
  },
@@ -24,6 +24,14 @@
24
24
  * per owner and its `files` ANDed with the owner's (ESLint's nested-array form), so a rule can
25
25
  * only land where its plugin is registered.
26
26
  *
27
+ * Both read ONE definition of "the files paperlint lints": `ownedScopes`, each own block's `files`
28
+ * together with its `ignores`. The globs alone are not that definition. The sibling-card block
29
+ * claims every markdown file under `siblings/` EXCEPT the index, and a rule whose plugin is
30
+ * registered for every file (`pdf`) used to keep the consumer block's `files` as they were — so a
31
+ * venue preset's rules reached `siblings/README.md`, a block with no `language` matched it, and
32
+ * ESLint parsed the markdown as JavaScript (#101). Now every generated block narrows to scopes,
33
+ * exceptions included, and a plugin registered everywhere owns exactly what paperlint lints.
34
+ *
27
35
  * ESLint's semantics that this leans on, each measured against ESLint 10 before writing it:
28
36
  * - a global-ignore pattern that matches a DIRECTORY hides everything inside it, so directories
29
37
  * must be un-ignored (`!` + a pattern ending in a slash) before owned files can be;
@@ -37,11 +45,21 @@ import type { RuleBlock, RuleEntry } from "./rules-config.ts";
37
45
  /** What this module reads of a config block. */
38
46
  interface Block {
39
47
  readonly files?: readonly unknown[];
48
+ readonly ignores?: readonly unknown[];
40
49
  readonly plugins?: Readonly<
41
50
  Record<string, { readonly rules?: Readonly<Record<string, unknown>> }>
42
51
  >;
43
52
  }
44
53
 
54
+ /**
55
+ * A set of files paperlint lints: an own block's globs and the exceptions that block makes. A file is
56
+ * in it when it matches one of `files` and none of `ignores`.
57
+ */
58
+ export interface OwnedScope {
59
+ readonly files: readonly string[];
60
+ readonly ignores: readonly string[];
61
+ }
62
+
45
63
  /** A `files` entry: one glob, or several that must ALL match. */
46
64
  export type FilesEntry = string | readonly string[];
47
65
 
@@ -53,12 +71,49 @@ export interface ScopedBlock {
53
71
  readonly rules: Readonly<Record<string, RuleEntry>>;
54
72
  }
55
73
 
56
- const globsOf = (b: Block): string[] =>
57
- (b.files ?? []).filter((f): f is string => typeof f === "string");
74
+ /** A block's `files`, the string globs only. */
75
+ const globsOf = (files: readonly unknown[]): string[] =>
76
+ files.filter((f): f is string => typeof f === "string");
77
+
78
+ const stringsOf = (xs: readonly unknown[] | undefined): string[] =>
79
+ (xs ?? []).filter((x): x is string => typeof x === "string");
80
+
81
+ /**
82
+ * Scopes with the same exceptions, merged into one: their globs united, once, in order. Keeps a
83
+ * generated block per distinct set of exceptions rather than one per own block.
84
+ */
85
+ const merged = (scopes: readonly OwnedScope[]): OwnedScope[] => {
86
+ const byIgnores = new Map<string, OwnedScope>();
87
+ for (const s of scopes) {
88
+ const key = JSON.stringify(s.ignores);
89
+ const had = byIgnores.get(key);
90
+ byIgnores.set(key, {
91
+ files: [...new Set([...(had?.files ?? []), ...s.files])],
92
+ ignores: s.ignores,
93
+ });
94
+ }
95
+ return [...byIgnores.values()];
96
+ };
97
+
98
+ /**
99
+ * THE FILES PAPERLINT LINTS: every own block that names `files`, with its `ignores`. A block without
100
+ * `files` (a global ignore, or the block registering `pdf` for every file) claims nothing.
101
+ */
102
+ export function ownedScopes(own: readonly unknown[]): OwnedScope[] {
103
+ return merged(
104
+ own
105
+ .map((raw) => raw as Block)
106
+ .flatMap((b) =>
107
+ b.files === undefined
108
+ ? []
109
+ : [{ files: globsOf(b.files), ignores: stringsOf(b.ignores) }],
110
+ ),
111
+ );
112
+ }
58
113
 
59
114
  /** Every glob paperlint's own blocks lint, once, in config order. */
60
115
  export function ownedPatterns(own: readonly unknown[]): string[] {
61
- return [...new Set(own.flatMap((b) => globsOf(b as Block)))];
116
+ return [...new Set(ownedScopes(own).flatMap((s) => s.files))];
62
117
  }
63
118
 
64
119
  /** ESLint's default ignores, re-applied after directories are un-ignored. */
@@ -84,40 +139,37 @@ const ruleIdsOf = (b: Block): string[] =>
84
139
  Object.keys(plugin.rules ?? {}).map((rule) => `${name}/${rule}`),
85
140
  );
86
141
 
87
- /** An owner seen so far, widened by one more block's globs; `null` (every file) absorbs all. */
88
- const widen = (
89
- had: readonly string[] | null | undefined,
90
- files: readonly string[] | null,
91
- ): readonly string[] | null =>
92
- had === null || files === null
93
- ? null
94
- : [...new Set([...(had ?? []), ...files])];
95
-
96
142
  /**
97
- * Rule id → the globs of the blocks that register its plugin with that rule. `null`: the plugin
98
- * is registered for every file (a block without `files`), so the rule resolves anywhere.
143
+ * Rule id → the scopes of the blocks that register its plugin with that rule. A plugin registered
144
+ * for every file (a block without `files`) owns every scope — everything paperlint lints, and not
145
+ * one file more.
99
146
  */
100
147
  export function ruleOwners(
101
148
  own: readonly unknown[],
102
- ): Map<string, readonly string[] | null> {
103
- const owners = new Map<string, readonly string[] | null>();
149
+ ): Map<string, readonly OwnedScope[]> {
150
+ const all = ownedScopes(own);
151
+ const found = new Map<string, OwnedScope[]>();
104
152
  for (const raw of own) {
105
153
  const b = raw as Block;
106
- const files = b.files ? globsOf(b) : null;
107
- for (const id of ruleIdsOf(b)) owners.set(id, widen(owners.get(id), files));
154
+ const scopes =
155
+ b.files === undefined
156
+ ? all
157
+ : [{ files: globsOf(b.files), ignores: stringsOf(b.ignores) }];
158
+ for (const id of ruleIdsOf(b))
159
+ found.set(id, [...(found.get(id) ?? []), ...scopes]);
108
160
  }
109
- return owners;
161
+ return new Map([...found].map(([id, scopes]) => [id, merged(scopes)]));
110
162
  }
111
163
 
112
164
  /**
113
- * One consumer block → one block per owner, each carrying only that owner's rules, its `files`
114
- * ANDed with the owner's globs. A rule no block of this config registers (the LaTeX block is
115
- * absent when its parser did not load) gets no block: it has no file to reach in this run, and
116
- * naming it to ESLint would only crash.
165
+ * One consumer block → one block per owner scope, each carrying only that owner's rules, its `files`
166
+ * ANDed with the scope's globs and its `ignores` joined by the scope's exceptions. A rule no block
167
+ * of this config registers (the LaTeX block is absent when its parser did not load) gets no block:
168
+ * it has no file to reach in this run, and naming it to ESLint would only crash.
117
169
  */
118
170
  export function narrowToOwners(
119
171
  block: RuleBlock,
120
- owners: ReadonlyMap<string, readonly string[] | null>,
172
+ owners: ReadonlyMap<string, readonly OwnedScope[]>,
121
173
  ): ScopedBlock[] {
122
174
  const groups = new Map<string, Record<string, RuleEntry>>();
123
175
  for (const [id, entry] of Object.entries(block.rules)) {
@@ -126,19 +178,27 @@ export function narrowToOwners(
126
178
  const key = JSON.stringify(owner);
127
179
  groups.set(key, { ...groups.get(key), [id]: entry });
128
180
  }
129
- return [...groups].map(([key, rules]) => {
130
- const owner = JSON.parse(key) as string[] | null;
131
- const files: readonly FilesEntry[] | undefined =
132
- owner === null
133
- ? block.files
134
- : block.files
135
- ? block.files.flatMap((f) => owner.map((o) => [f, o]))
136
- : owner;
137
- return {
138
- basePath: block.basePath,
139
- ...(files ? { files } : {}),
140
- ...(block.ignores ? { ignores: block.ignores } : {}),
141
- rules,
142
- };
143
- });
181
+ return [...groups].flatMap(([key, rules]) =>
182
+ (JSON.parse(key) as OwnedScope[]).map((scope) =>
183
+ within(block, scope, rules),
184
+ ),
185
+ );
186
+ }
187
+
188
+ /** `block`'s rules over the files both it and `scope` claim, minus both sets of exceptions. */
189
+ function within(
190
+ block: RuleBlock,
191
+ scope: OwnedScope,
192
+ rules: Readonly<Record<string, RuleEntry>>,
193
+ ): ScopedBlock {
194
+ const files: readonly FilesEntry[] = block.files
195
+ ? block.files.flatMap((f) => scope.files.map((o) => [f, o]))
196
+ : scope.files;
197
+ const ignores = [...(block.ignores ?? []), ...scope.ignores];
198
+ return {
199
+ basePath: block.basePath,
200
+ files,
201
+ ...(ignores.length ? { ignores } : {}),
202
+ rules,
203
+ };
144
204
  }
@@ -42,10 +42,11 @@ function project(files: Record<string, string> = {}): string {
42
42
 
43
43
  async function lint(
44
44
  root: string,
45
+ args: string[] = [],
45
46
  ): Promise<{ code: number; out: string; err: string }> {
46
47
  const out: string[] = [];
47
48
  const err: string[] = [];
48
- const code = await run(["lint", "--json"], {
49
+ const code = await run(["lint", "--json", ...args], {
49
50
  cwd: root,
50
51
  log: (s: string) => out.push(s),
51
52
  err: (s: string) => err.push(s),
@@ -58,6 +59,43 @@ const rulesIn = (stdout: string, paper: string): string[] =>
58
59
  .filter((r) => r.filePath.endsWith(join(paper, "paper.tex")))
59
60
  .flatMap((r) => r.messages.map((m) => m.ruleId));
60
61
 
62
+ describe("🔴 an optional rule that reaches no paper — judged against the PROJECT, not the run (#103)", () => {
63
+ const THIRD = {
64
+ "papers/c/paper.tex":
65
+ "\\documentclass{article}\n\\begin{document}x\\end{document}\n",
66
+ "papers/c/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
67
+ };
68
+ const rootRules = (files: string[]) => ({
69
+ "paperlint.json": JSON.stringify({
70
+ rules: [{ files, rules: { "pdf/last-page-balance": "error" } }],
71
+ }),
72
+ });
73
+
74
+ it("linting paper c alone passes when the block reaches papers a and b", async () => {
75
+ const root = project({
76
+ ...THIRD,
77
+ ...rootRules(["papers/a/**", "papers/b/**"]),
78
+ });
79
+ const r = await lint(root, ["papers/c"]);
80
+ // Guards: the defect — the guard counted only the paper.tex files this run linted.
81
+ expect(r.err).not.toMatch(/no linted paper\.tex gets it/);
82
+ expect(r.code).toBe(0);
83
+ expect(rulesIn(r.out, "c")).not.toContain("pdf/last-page-balance");
84
+ });
85
+
86
+ it("…and a glob that reaches no paper of the project still fails, from a subset too", async () => {
87
+ const root = project({ ...THIRD, ...rootRules(["papers/typo/**"]) });
88
+ for (const args of [["papers/c"], []]) {
89
+ const r = await lint(root, args);
90
+ // Guards: the other half — widening the judged set did not turn the guard off.
91
+ expect(r.err).toMatch(
92
+ /pdf\/last-page-balance is turned on in "rules", but no paper\.tex/,
93
+ );
94
+ expect(r.code).toBe(1);
95
+ }
96
+ });
97
+ });
98
+
61
99
  describe("paperlint lint — `rules` in a paper's paperlint.json", () => {
62
100
  it("turns an optional rule on for that paper alone", async () => {
63
101
  const root = project({
@@ -177,11 +215,9 @@ describe("paperlint lint — the venue preset's rules", () => {
177
215
  expect(cfgOf.ok && cfgOf.value).toEqual({
178
216
  preset: [
179
217
  {
218
+ // Scoped to the paper by `basePath` alone: which of its files the rule reaches is decided
219
+ // once, by narrowing to paperlint's owned scopes (#101), not by a copy of their globs.
180
220
  basePath: join(root, "papers/a"),
181
- files: expect.arrayContaining([
182
- "**/paper.tex",
183
- "**/PIPELINE-STATUS.md",
184
- ]),
185
221
  rules: { "pdf/last-page-balance": ["error", { tolerancePt: 120 }] },
186
222
  },
187
223
  ],
@@ -118,6 +118,26 @@ describe("readPaperSettings", () => {
118
118
  });
119
119
  });
120
120
 
121
+ describe("readPaperSettings — a root paperlint.json that cannot be used", () => {
122
+ const ROOT = "/work";
123
+ const withFiles = (f: Record<string, string>) =>
124
+ memoryFiles({ [`${ROOT}/package.json`]: "{}", ...f });
125
+
126
+ it("a root file that does not parse, or names a field of the wrong type, is the paper's problem too", () => {
127
+ const broken = (root: string) =>
128
+ readPaperSettings(withFiles({ [`${ROOT}/paperlint.json`]: root }), PAPER);
129
+ const notJson = broken("{ nope");
130
+ expect(!notJson.ok && notJson.error.kind).toBe("broken");
131
+ expect(broken('{"kind":3}')).toEqual({
132
+ ok: false,
133
+ error: {
134
+ kind: "broken",
135
+ why: expect.stringMatching(/^the root paperlint\.json: .*kind/),
136
+ },
137
+ });
138
+ });
139
+ });
140
+
121
141
  describe("readPaperSettings — the root paperlint.json's defaults, the paper's file over them", () => {
122
142
  const ROOT = "/work";
123
143
  const withFiles = (f: Record<string, string>) =>
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `--flag=value` and unknown flags — the command line the CI action actually writes.
3
+ *
4
+ * action.yml runs `npx paperlint lint $PATHS --max-warnings="$MAXWARN" --json`. The parser knew
5
+ * only `--max-warnings <n>`, so `--max-warnings=-1` fell through to the list of PATHS: ESLint was
6
+ * handed a file named `--max-warnings=-1`, found nothing for it, and the run reported "nothing was
7
+ * linted under papers, --max-warnings=-1". Any flag the parser did not know became a path the same
8
+ * way, silently.
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+ import { parseArgs, run } from "./cli.ts";
12
+
13
+ describe("parseArgs — `--flag=value` is the same as `--flag value`", () => {
14
+ it("🔴 the action's own command line: `--max-warnings=-1` is a threshold, not a path", () => {
15
+ const a = parseArgs(["lint", "papers", "--max-warnings=-1", "--json"]);
16
+ // Guards: the defect — the flag landed in `paths`.
17
+ expect(a.paths).toEqual(["papers"]);
18
+ expect(a.maxWarnings).toBe(-1);
19
+ });
20
+
21
+ it.each([
22
+ ["--config=o.json", "config", "o.json"],
23
+ ["--venue=agenticdev", "venue", "agenticdev"],
24
+ ["--kind=short", "kind", "short"],
25
+ ["--format=md", "format", "md"],
26
+ ["--paper=p", "paper", "p"],
27
+ ] as const)("%s", (flag, key, value) => {
28
+ const a = parseArgs(["new", "x", flag]);
29
+ expect(a[key]).toBe(value);
30
+ expect(a.paths).toEqual(["x"]);
31
+ });
32
+
33
+ it("an empty value after `=` is a missing value, refused like `--config` with none", () => {
34
+ expect(parseArgs(["lint", "--config="]).missingValue).toBe("--config");
35
+ });
36
+ });
37
+
38
+ describe("an unknown flag is refused, not read as a path", () => {
39
+ it("exits 2 and names the flag", async () => {
40
+ const err: string[] = [];
41
+ const code = await run(["lint", "--frobnicate"], {
42
+ log: () => {},
43
+ err: (s: string) => err.push(s),
44
+ });
45
+ expect(code).toBe(2);
46
+ expect(err.join("\n")).toMatch(/unknown flag `--frobnicate`/);
47
+ });
48
+ });
@@ -11,17 +11,14 @@ import { writeFileSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
11
11
  import { tmpdir } from "node:os";
12
12
  import { dirname, join, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
+ import { createChecker } from "../lib/check.mjs";
14
15
 
15
16
  const HERE = dirname(fileURLToPath(import.meta.url));
16
17
  const FIX = resolve(HERE, "..", "fixtures", "pdf-facts");
17
18
  const { readPdf } = await import(join(HERE, "pdf-facts.ts"));
18
19
  const { classifyLastPage } = await import(join(HERE, "pdf-geometry.ts"));
19
20
 
20
- let n = 0;
21
- const check = (label, cond, detail = "") => {
22
- assert.ok(cond, detail ? `${label} — ${detail}` : label);
23
- n++;
24
- };
21
+ const check = createChecker();
25
22
 
26
23
  const read = async (name) => {
27
24
  const r = await readPdf(join(FIX, name));
@@ -184,5 +181,5 @@ try {
184
181
  }
185
182
 
186
183
  console.log(
187
- `✓ ${String(n)} assertions passed — pdf-facts: pdf.js on real PDFs agrees with poppler, and says what it cannot read`,
184
+ `✓ ${String(check.count)} assertions passed — pdf-facts: pdf.js on real PDFs agrees with poppler, and says what it cannot read`,
188
185
  );