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
@@ -60,13 +60,18 @@ export type Located =
60
60
  *
61
61
  * @returns `dir` — the real directory; `spelled` — the same directory as the project names it.
62
62
  */
63
- export function locatePackage(project: string): Located {
63
+ export function locatePackage(
64
+ project: string,
65
+ {
66
+ resolveManifest = (from: string): string =>
67
+ createRequire(
68
+ pathToFileURL(join(from, "__paperlint_locate__.js")).href,
69
+ ).resolve(`${PACKAGE_NAME}/package.json`),
70
+ }: { resolveManifest?: (project: string) => string } = {},
71
+ ): Located {
64
72
  let manifest: string;
65
73
  try {
66
- const req = createRequire(
67
- pathToFileURL(join(project, "__paperlint_locate__.js")).href,
68
- );
69
- manifest = req.resolve(`${PACKAGE_NAME}/package.json`);
74
+ manifest = resolveManifest(project);
70
75
  } catch (e) {
71
76
  return {
72
77
  error: `${(e as NodeJS.ErrnoException).code ?? "error"}: ${(e as Error).message.split("\n")[0]}`,
@@ -126,10 +131,28 @@ export type LinkReport =
126
131
  }
127
132
  | { readonly ok: false; readonly error: string };
128
133
 
134
+ /** The filesystem calls linking makes — the disk by default; a test stages a race or a refusal. */
135
+ export interface LinkFs {
136
+ readonly lstatSync: (p: string) => {
137
+ isSymbolicLink(): boolean;
138
+ isDirectory(): boolean;
139
+ };
140
+ readonly realpathSync: (p: string) => string;
141
+ readonly readlinkSync: (p: string) => string;
142
+ readonly symlinkSync: (target: string, path: string, type: "dir") => void;
143
+ }
144
+ const nodeLinkFs: LinkFs = {
145
+ lstatSync,
146
+ realpathSync,
147
+ readlinkSync,
148
+ symlinkSync,
149
+ };
150
+
129
151
  /** What occupies `entry`, judged against the directory it should lead to. */
130
152
  function inspect(
131
153
  entry: string,
132
154
  want: string,
155
+ { lstatSync, realpathSync, readlinkSync }: LinkFs,
133
156
  ): { status: "present" | "missing" | "foreign"; reason?: string } {
134
157
  let st;
135
158
  try {
@@ -163,15 +186,22 @@ function linkOne(
163
186
  target,
164
187
  want,
165
188
  write,
166
- }: { entry: string; target: string; want: string; write: boolean },
189
+ fs,
190
+ }: {
191
+ entry: string;
192
+ target: string;
193
+ want: string;
194
+ write: boolean;
195
+ fs: LinkFs;
196
+ },
167
197
  ): SkillLink {
168
- const seen = inspect(entry, want);
198
+ const seen = inspect(entry, want, fs);
169
199
  if (!write || seen.status !== "missing")
170
200
  return seen.reason
171
201
  ? { name, status: seen.status, reason: seen.reason }
172
202
  : { name, status: seen.status };
173
203
  try {
174
- symlinkSync(target, entry, "dir");
204
+ fs.symlinkSync(target, entry, "dir");
175
205
  return { name, status: "created" };
176
206
  } catch (e) {
177
207
  return {
@@ -182,6 +212,16 @@ function linkOne(
182
212
  }
183
213
  }
184
214
 
215
+ /** Create the skills home; the reason it could not be, or null. */
216
+ function makeHome(home: string): string | null {
217
+ try {
218
+ mkdirSync(home, { recursive: true });
219
+ return null;
220
+ } catch (e) {
221
+ return `cannot create ${home}: ${(e as Error).message}`;
222
+ }
223
+ }
224
+
185
225
  /**
186
226
  * Link every shipped skill into `<project>/.claude/skills/`, or — with `write: false` — only
187
227
  * report which ones are missing (that is what `paperlint doctor` asks).
@@ -191,7 +231,12 @@ export function linkSkills(
191
231
  {
192
232
  write = true,
193
233
  locate = locatePackage,
194
- }: { write?: boolean; locate?: (p: string) => Located } = {},
234
+ fs = nodeLinkFs,
235
+ }: {
236
+ write?: boolean;
237
+ locate?: (p: string) => Located;
238
+ fs?: LinkFs;
239
+ } = {},
195
240
  ): LinkReport {
196
241
  const pkg = locate(project);
197
242
  if ("error" in pkg)
@@ -205,20 +250,28 @@ export function linkSkills(
205
250
  const spelledSkills = join(pkg.spelled, relative(pkg.dir, shipped.skillsDir));
206
251
 
207
252
  const home = join(project, SKILLS_HOME);
208
- if (write && shipped.names.length > 0) {
209
- try {
210
- mkdirSync(home, { recursive: true });
211
- } catch (e) {
212
- return {
213
- ok: false,
214
- error: `cannot create ${home}: ${(e as Error).message}`,
215
- };
216
- }
217
- }
253
+ const made = write && shipped.names.length > 0 ? makeHome(home) : null;
254
+ if (made !== null) return { ok: false, error: made };
255
+ return {
256
+ ok: true,
257
+ home,
258
+ ...linkAll(shipped, { spelledSkills, home, write, fs }),
259
+ };
260
+ }
261
+
262
+ /** Every shipped skill's link, and the first one's target as the report's example. */
263
+ function linkAll(
264
+ shipped: { readonly skillsDir: string; readonly names: readonly string[] },
265
+ {
266
+ spelledSkills,
267
+ home,
268
+ write,
269
+ fs,
270
+ }: { spelledSkills: string; home: string; write: boolean; fs: LinkFs },
271
+ ): { example: string | null; links: SkillLink[] } {
218
272
  // A relative target resolves against the directory that PHYSICALLY holds the link, so it is
219
273
  // computed from the real one — `.claude` may itself be a symlink.
220
274
  const physicalHome = existsSync(home) ? realpathSync(home) : resolve(home);
221
-
222
275
  let example: string | null = null;
223
276
  const links = shipped.names.map((name): SkillLink => {
224
277
  const target = relative(physicalHome, join(spelledSkills, name));
@@ -228,7 +281,8 @@ export function linkSkills(
228
281
  target,
229
282
  want: realpathSync(join(shipped.skillsDir, name)),
230
283
  write,
284
+ fs,
231
285
  });
232
286
  });
233
- return { ok: true, home, example, links };
287
+ return { example, links };
234
288
  }
@@ -16,7 +16,6 @@
16
16
  * ⚠️ Assertions at the TOP LEVEL: `vigiles test` imports the file and counts "did not throw"
17
17
  * as a pass.
18
18
  */
19
- import assert from "node:assert/strict";
20
19
  import {
21
20
  existsSync,
22
21
  mkdirSync,
@@ -32,6 +31,7 @@ import { dirname, join } from "node:path";
32
31
  import { fileURLToPath } from "node:url";
33
32
  import { load } from "js-yaml";
34
33
  import { frontmatterBlock } from "../lib/markdown.mjs";
34
+ import { createChecker } from "../lib/check.mjs";
35
35
 
36
36
  const HERE = dirname(fileURLToPath(import.meta.url));
37
37
  const ROOT = dirname(HERE);
@@ -46,11 +46,7 @@ const {
46
46
  } = await import(join(HERE, "new-paper.ts"));
47
47
  const { run } = await import(join(HERE, "cli.ts"));
48
48
 
49
- let n = 0;
50
- const check = (label, cond) => {
51
- n++;
52
- assert.ok(cond, label);
53
- };
49
+ const check = createChecker();
54
50
 
55
51
  const work = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-new-")));
56
52
  const read = (...p) => readFileSync(join(...p), "utf8");
@@ -217,5 +213,5 @@ try {
217
213
  }
218
214
 
219
215
  console.log(
220
- `✓ ${String(n)} assertions passed — paperlint new scaffolds what paperlint lint accepts`,
216
+ `✓ ${String(check.count)} assertions passed — paperlint new scaffolds what paperlint lint accepts`,
221
217
  );
@@ -15,7 +15,7 @@ import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { afterEach, describe, expect, it } from "vitest";
17
17
  import { existsSync } from "node:fs";
18
- import { newPaper, OVERRIDE_DIR } from "./new-paper.ts";
18
+ import { newPaper, OVERRIDE_DIR, reportNewPaper } from "./new-paper.ts";
19
19
  import { parsePaperSettings } from "./paper-settings.ts";
20
20
  import { chooseVenue, run } from "./cli.ts";
21
21
  import { shippedPresets } from "./presets.ts";
@@ -302,3 +302,33 @@ describe("chooseVenue — on a terminal", () => {
302
302
  expect(t.asked).toEqual([]);
303
303
  });
304
304
  });
305
+
306
+ describe("a project's own template", () => {
307
+ it("🔴 a template paperlint.json with comments refuses --venue by name, and nothing is made", () => {
308
+ const papers = join(tmp(), "papers");
309
+ mkdirSync(join(papers, OVERRIDE_DIR), { recursive: true });
310
+ const src = join(papers, OVERRIDE_DIR, "paperlint.json");
311
+ writeFileSync(src, '{\n // our house venue\n "extends": null\n}\n');
312
+ const r = newPaper(papers, "p", "tex", {
313
+ venue: { extends: "paperlint:aisec", kind: null },
314
+ });
315
+ expect(r).toEqual({
316
+ ok: false,
317
+ reason: `--venue cannot be written into ${src}: it is not plain JSON — set "extends" by hand`,
318
+ });
319
+ expect(existsSync(join(papers, "p"))).toBe(false);
320
+ });
321
+
322
+ it("the report names where each created file came from", () => {
323
+ const papers = join(tmp(), "papers");
324
+ mkdirSync(join(papers, OVERRIDE_DIR), { recursive: true });
325
+ writeFileSync(join(papers, OVERRIDE_DIR, "paperlint.json"), "{}\n");
326
+ const lines = reportNewPaper(newPaper(papers, "p", "tex"), (p) => p);
327
+ expect(lines).toContain(
328
+ " + paperlint.json (from the project's .template/ template)",
329
+ );
330
+ expect(
331
+ lines.filter((l) => l.endsWith("(from the package template)")).length,
332
+ ).toBeGreaterThan(0);
333
+ });
334
+ });
@@ -319,6 +319,18 @@ describe("paper-files — the pieces", () => {
319
319
  });
320
320
  });
321
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"]);
331
+ });
332
+ });
333
+
322
334
  describe("paper-files — narrowing a block", () => {
323
335
  const own = [
324
336
  { plugins: { g: { rules: { any: {} } } } },
@@ -71,8 +71,9 @@ export interface ScopedBlock {
71
71
  readonly rules: Readonly<Record<string, RuleEntry>>;
72
72
  }
73
73
 
74
- const globsOf = (b: Block): string[] =>
75
- (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");
76
77
 
77
78
  const stringsOf = (xs: readonly unknown[] | undefined): string[] =>
78
79
  (xs ?? []).filter((x): x is string => typeof x === "string");
@@ -102,8 +103,11 @@ export function ownedScopes(own: readonly unknown[]): OwnedScope[] {
102
103
  return merged(
103
104
  own
104
105
  .map((raw) => raw as Block)
105
- .filter((b) => b.files !== undefined)
106
- .map((b) => ({ files: globsOf(b), ignores: stringsOf(b.ignores) })),
106
+ .flatMap((b) =>
107
+ b.files === undefined
108
+ ? []
109
+ : [{ files: globsOf(b.files), ignores: stringsOf(b.ignores) }],
110
+ ),
107
111
  );
108
112
  }
109
113
 
@@ -150,7 +154,7 @@ export function ruleOwners(
150
154
  const scopes =
151
155
  b.files === undefined
152
156
  ? all
153
- : [{ files: globsOf(b), ignores: stringsOf(b.ignores) }];
157
+ : [{ files: globsOf(b.files), ignores: stringsOf(b.ignores) }];
154
158
  for (const id of ruleIdsOf(b))
155
159
  found.set(id, [...(found.get(id) ?? []), ...scopes]);
156
160
  }
@@ -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>) =>
@@ -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
  );
@@ -0,0 +1,144 @@
1
+ /**
2
+ * pdf-facts.ts on document shapes real test PDFs do not carry, through a fake pdf.js document
3
+ * (the real pdf.js library for its transforms and operator codes): a font object that is not one,
4
+ * a font without a name or metrics, an item whose font never resolved, zero-scale transforms, a
5
+ * document with no pages, text with no font, and a failure that is not an Error.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { getResolvedPDFJS } from "unpdf";
9
+ import { test } from "vitest";
10
+ import { factsOf, failureOf, rawFontOf } from "./pdf-facts.ts";
11
+
12
+ const lib = await getResolvedPDFJS();
13
+ const H = 792;
14
+
15
+ interface FakeItem {
16
+ str: string;
17
+ fontName: string;
18
+ transform: number[];
19
+ width: number;
20
+ height: number;
21
+ }
22
+ /** A page whose text items and loaded fonts are given; its viewport flips y like pdf.js's. */
23
+ function page(items: FakeItem[], fonts: Record<string, unknown>) {
24
+ const objs = new Map(Object.entries(fonts));
25
+ return {
26
+ getOperatorList: async () => ({ fnArray: [], argsArray: [] }),
27
+ getTextContent: async () => ({ items }),
28
+ // Iterable like pdf.js's `commonObjs`, with its `has`/`get`.
29
+ commonObjs: {
30
+ has: (id: string) => objs.has(id),
31
+ get: (id: string) => objs.get(id),
32
+ [Symbol.iterator]: () => objs[Symbol.iterator](),
33
+ },
34
+ getViewport: () => ({
35
+ width: 612,
36
+ height: H,
37
+ transform: [1, 0, 0, -1, 0, H],
38
+ convertToViewportPoint: (x: number, y: number) => [x, H - y],
39
+ }),
40
+ };
41
+ }
42
+ const doc = (pages: ReturnType<typeof page>[]) =>
43
+ ({
44
+ numPages: pages.length,
45
+ getPage: async (i: number) => pages[i - 1],
46
+ }) as never;
47
+ const item = (
48
+ fontName: string,
49
+ transform: number[],
50
+ height = 10,
51
+ ): FakeItem => ({
52
+ str: "word",
53
+ fontName,
54
+ transform,
55
+ width: 20,
56
+ height,
57
+ });
58
+
59
+ test("rawFontOf: not an object, not a font, and a font without a name", () => {
60
+ assert.deepEqual(
61
+ [
62
+ rawFontOf("a", null),
63
+ rawFontOf("b", { x: 1 }),
64
+ rawFontOf("c", { loadedName: "L" }),
65
+ ],
66
+ [
67
+ null,
68
+ null,
69
+ {
70
+ id: "c",
71
+ name: "",
72
+ type: undefined,
73
+ subtype: undefined,
74
+ isType3Font: false,
75
+ missingFile: false,
76
+ },
77
+ ],
78
+ );
79
+ });
80
+
81
+ test("a document with no pages is `empty`; text drawn with no font is refused", async () => {
82
+ assert.deepEqual(await factsOf(doc([]), lib), {
83
+ ok: false,
84
+ reason: "empty",
85
+ detail: "the PDF has no pages",
86
+ });
87
+ const r = await factsOf(
88
+ doc([page([item("f0", [10, 0, 0, 10, 50, 700])], {})]),
89
+ lib,
90
+ );
91
+ assert.equal(r.ok, false);
92
+ assert.equal(!r.ok && r.reason, "zero-fonts-on-text");
93
+ });
94
+
95
+ test("metrics, sizes and names fall back when pdf.js gives none", async () => {
96
+ const fonts = {
97
+ // a font with a name and an ascent but no descent
98
+ f1: { loadedName: "f1", name: "Named", ascent: 0.8 },
99
+ // a font with no name and no metrics at all
100
+ f2: { loadedName: "f2" },
101
+ };
102
+ const r = await factsOf(
103
+ doc([
104
+ page(
105
+ [
106
+ item("f1", [10, 0, 0, 10, 50, 700]),
107
+ // zero horizontal scale: the size comes from the item's height…
108
+ item("f2", [0, 0, 0, 0, 100, 700], 12),
109
+ // …or, with no height either, from the vertical scale
110
+ item("f2", [0, 0, 0, 9, 150, 700], 0),
111
+ // an item whose font pdf.js never resolved
112
+ item("gone", [10, 0, 0, 10, 200, 700]),
113
+ ],
114
+ fonts,
115
+ ),
116
+ ]),
117
+ lib,
118
+ );
119
+ assert.equal(r.ok, true);
120
+ const boxes = r.ok ? r.facts.layout[0]!.boxes : [];
121
+ assert.deepEqual(
122
+ boxes.map((b) => [b.font, b.size, b.fill]),
123
+ [
124
+ ["Named", 10, { kind: "unknown" }],
125
+ ["f2", 12, { kind: "unknown" }],
126
+ ["f2", 9, { kind: "unknown" }],
127
+ ["gone", 10, { kind: "unknown" }],
128
+ ],
129
+ );
130
+ assert.deepEqual(r.ok ? r.facts.last.words.map((w) => w.text) : [], [
131
+ "word",
132
+ "word",
133
+ "word",
134
+ "word",
135
+ ]);
136
+ });
137
+
138
+ test("a failure that is not an Error is named `Error` with its text", () => {
139
+ assert.deepEqual(failureOf("boom"), {
140
+ ok: false,
141
+ reason: "unreadable",
142
+ detail: "Error: boom",
143
+ });
144
+ });
package/src/pdf-facts.ts CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  } from "./pdf-geometry.ts";
34
34
  import type { PageLayout, TextBox } from "./domain/page-layout.ts";
35
35
  // eslint-disable-next-line boundaries/dependencies -- legacy layer, moves behind a port in #76
36
- import { fillsOf, isUpright } from "./adapters/pdfjs/fill.ts";
36
+ import { fillsFor, isUpright } from "./adapters/pdfjs/fill.ts";
37
37
 
38
38
  type PdfJs = Awaited<ReturnType<typeof import("unpdf").getResolvedPDFJS>>;
39
39
  type Doc = Awaited<ReturnType<PdfJs["getDocument"]>["promise"]>;
@@ -241,20 +241,18 @@ function layoutOf(
241
241
  lib: PdfJs,
242
242
  ): PageLayout {
243
243
  const vp = page.getViewport({ scale: 1 });
244
- const fills = fillsOf(
245
- lib.OPS,
246
- ops,
247
- items.map((it) => it.str),
244
+ const boxes = fillsFor(lib.OPS, ops, items, (it) => it.str).map(
245
+ ({ item, fill }): TextBox => ({ ...boxOf(page, vp, item, lib), fill }),
248
246
  );
249
- const boxes = items.map((it, k): TextBox => ({
250
- ...boxOf(page, vp, it, lib),
251
- fill: fills[k] ?? { kind: "unknown" },
252
- }));
253
247
  return { widthPt: vp.width, heightPt: vp.height, boxes };
254
248
  }
255
249
 
256
- /** Every page's fonts, and the last page's words. */
257
- async function factsOf(doc: Doc, lib: PdfJs): Promise<PdfRead> {
250
+ /**
251
+ * Every page's fonts, and the last page's words. Exported with `failureOf` so a test can hand it a
252
+ * document whose objects pdf.js shapes only in rare files (a font without metrics, a zero-scale
253
+ * transform, no pages) — `readPdf` below is the one production caller.
254
+ */
255
+ export async function factsOf(doc: Doc, lib: PdfJs): Promise<PdfRead> {
258
256
  const read: {
259
257
  page: Page;
260
258
  raw: RawPage;
@@ -290,7 +288,7 @@ async function factsOf(doc: Doc, lib: PdfJs): Promise<PdfRead> {
290
288
  }
291
289
 
292
290
  /** pdf.js's own exception names for the two conditions a caller can act on. */
293
- function failureOf(e: unknown): PdfRead {
291
+ export function failureOf(e: unknown): PdfRead {
294
292
  const err = e as { name?: string; message?: string };
295
293
  const detail = `${err.name ?? "Error"}: ${err.message ?? String(e)}`;
296
294
  return fail(
@@ -309,13 +307,13 @@ export const readPdf: PdfReader = async (path) => {
309
307
  }
310
308
  const lib = await loadPdfJs();
311
309
  const task = lib.getDocument({ data, ...PDFJS_OPTIONS });
312
- try {
313
- return await factsOf(await task.promise, lib);
314
- } catch (e) {
315
- return failureOf(e);
316
- } finally {
317
- await task.destroy();
318
- }
310
+ // A promise chain rather than try/catch/finally: every path returns a value here, and the code
311
+ // after such a statement is a block that can never run — which coverage reports as unrun.
312
+ const read = await task.promise
313
+ .then((doc) => factsOf(doc, lib))
314
+ .catch(failureOf);
315
+ await task.destroy();
316
+ return read;
319
317
  };
320
318
 
321
319
  /** The one wording of a failed read, as a build step or a CLI prints it. */
@@ -6,9 +6,9 @@
6
6
  * on 2026-08-29 (the midpoint cut, the review build, years mistaken for line numbers), ported from
7
7
  * the harness of the function this replaces.
8
8
  */
9
- import assert from "node:assert/strict";
10
9
  import { dirname, join } from "node:path";
11
10
  import { fileURLToPath } from "node:url";
11
+ import { createChecker } from "../lib/check.mjs";
12
12
 
13
13
  const HERE = dirname(fileURLToPath(import.meta.url));
14
14
  const {
@@ -21,11 +21,7 @@ const {
21
21
  STUB_WORDS,
22
22
  } = await import(join(HERE, "pdf-geometry.ts"));
23
23
 
24
- let n = 0;
25
- const check = (label, cond) => {
26
- assert.ok(cond, label);
27
- n++;
28
- };
24
+ const check = createChecker();
29
25
 
30
26
  const W = 612; // US letter, points
31
27
 
@@ -250,5 +246,5 @@ check(
250
246
  );
251
247
 
252
248
  console.log(
253
- `✓ ${String(n)} assertions passed — pdf-geometry: columns, stub/review, words from runs, fonts`,
249
+ `✓ ${String(check.count)} assertions passed — pdf-geometry: columns, stub/review, words from runs, fonts`,
254
250
  );
@@ -136,7 +136,7 @@ export function wordsOf(run: TextRun): Word[] {
136
136
  const y0 = run.baseline - run.ascent * run.size;
137
137
  const y1 = run.baseline - run.descent * run.size;
138
138
  return [...run.text.matchAll(/\S+/g)].map((m) => {
139
- const x0 = run.x + (m.index ?? 0) * perChar;
139
+ const x0 = run.x + m.index * perChar;
140
140
  return { x0, y0, x1: x0 + m[0].length * perChar, y1, text: m[0] };
141
141
  });
142
142
  }
@@ -230,6 +230,8 @@ describe("labelOf — display only, never used to resolve", () => {
230
230
  ["paperlint:agenticdev", "agenticdev"],
231
231
  ["./venues/usenix-sec.jsonc", "usenix-sec"],
232
232
  ["../shared/acl.json", "acl"],
233
+ ["./.hidden", ".hidden"],
234
+ ["paperlint:", ""],
233
235
  ])("%s → %s", (spec, label) => {
234
236
  expect(labelOf(spec)).toBe(label);
235
237
  });
package/src/presets.ts CHANGED
@@ -293,5 +293,11 @@ export function paperPresetProblem(
293
293
  if (p.kind === "preset-problem")
294
294
  return `${join(paperDir, CONFIG_FILE)}: ${presetProblemText(p.problem)}`;
295
295
  if (p.kind !== "settings-problem") return null;
296
- return `${join(paperDir, CONFIG_FILE)}: ${p.problem.why}`;
296
+ return settingsProblemLine(paperDir, p.problem);
297
297
  }
298
+
299
+ /** The one line naming a paper's `paperlint.json` that does not parse, and why. */
300
+ export const settingsProblemLine = (
301
+ paperDir: string,
302
+ problem: SettingsProblem,
303
+ ): string => `${join(paperDir, CONFIG_FILE)}: ${problem.why}`;