paperlint 3.2.0 → 4.0.0

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 (312) hide show
  1. package/CLAUDE.md +2 -2
  2. package/dist/adapters/banal/failure.d.ts +1 -7
  3. package/dist/adapters/banal/failure.d.ts.map +1 -1
  4. package/dist/adapters/banal/failure.js +5 -7
  5. package/dist/adapters/banal/failure.js.map +1 -1
  6. package/dist/adapters/banal/index.d.ts.map +1 -1
  7. package/dist/adapters/banal/index.js +8 -11
  8. package/dist/adapters/banal/index.js.map +1 -1
  9. package/dist/adapters/banal/install.d.ts.map +1 -1
  10. package/dist/adapters/banal/install.js +4 -3
  11. package/dist/adapters/banal/install.js.map +1 -1
  12. package/dist/adapters/banal/invocation.d.ts +9 -4
  13. package/dist/adapters/banal/invocation.d.ts.map +1 -1
  14. package/dist/adapters/banal/invocation.js +13 -1
  15. package/dist/adapters/banal/invocation.js.map +1 -1
  16. package/dist/adapters/banal/locate.d.ts +12 -9
  17. package/dist/adapters/banal/locate.d.ts.map +1 -1
  18. package/dist/adapters/banal/locate.js +19 -12
  19. package/dist/adapters/banal/locate.js.map +1 -1
  20. package/dist/adapters/banal/run.d.ts +10 -1
  21. package/dist/adapters/banal/run.d.ts.map +1 -1
  22. package/dist/adapters/banal/run.js +8 -4
  23. package/dist/adapters/banal/run.js.map +1 -1
  24. package/dist/adapters/banal/settings.d.ts +1 -1
  25. package/dist/adapters/banal/settings.d.ts.map +1 -1
  26. package/dist/adapters/banal/settings.js +2 -1
  27. package/dist/adapters/banal/settings.js.map +1 -1
  28. package/dist/adapters/memory/index.d.ts.map +1 -1
  29. package/dist/adapters/memory/index.js +8 -2
  30. package/dist/adapters/memory/index.js.map +1 -1
  31. package/dist/adapters/node/process.io.d.ts +6 -3
  32. package/dist/adapters/node/process.io.d.ts.map +1 -1
  33. package/dist/adapters/node/process.io.js.map +1 -1
  34. package/dist/adapters/node/workspace.io.d.ts.map +1 -1
  35. package/dist/adapters/node/workspace.io.js +4 -2
  36. package/dist/adapters/node/workspace.io.js.map +1 -1
  37. package/dist/adapters/references/index.d.ts.map +1 -1
  38. package/dist/adapters/references/index.js +4 -2
  39. package/dist/adapters/references/index.js.map +1 -1
  40. package/dist/build-engine.d.ts +1 -1
  41. package/dist/build-engine.d.ts.map +1 -1
  42. package/dist/build-engine.js +8 -1
  43. package/dist/build-engine.js.map +1 -1
  44. package/dist/build.d.ts +2 -3
  45. package/dist/build.d.ts.map +1 -1
  46. package/dist/build.js +5 -6
  47. package/dist/build.js.map +1 -1
  48. package/dist/caller-path.d.ts +3 -0
  49. package/dist/caller-path.d.ts.map +1 -0
  50. package/dist/caller-path.js +13 -0
  51. package/dist/caller-path.js.map +1 -0
  52. package/dist/cli.d.ts +10 -17
  53. package/dist/cli.d.ts.map +1 -1
  54. package/dist/cli.js +62 -12
  55. package/dist/cli.js.map +1 -1
  56. package/dist/doctor.d.ts +12 -3
  57. package/dist/doctor.d.ts.map +1 -1
  58. package/dist/doctor.js +2 -1
  59. package/dist/doctor.js.map +1 -1
  60. package/dist/domain/paths.d.ts +8 -2
  61. package/dist/domain/paths.d.ts.map +1 -1
  62. package/dist/domain/paths.js +12 -1
  63. package/dist/domain/paths.js.map +1 -1
  64. package/dist/domain/ready.d.ts +24 -0
  65. package/dist/domain/ready.d.ts.map +1 -0
  66. package/dist/domain/ready.js +11 -0
  67. package/dist/domain/ready.js.map +1 -0
  68. package/dist/domain/record.d.ts +8 -0
  69. package/dist/domain/record.d.ts.map +1 -0
  70. package/dist/domain/record.js +8 -0
  71. package/dist/domain/record.js.map +1 -0
  72. package/dist/domain/sha256.d.ts.map +1 -1
  73. package/dist/domain/sha256.js +4 -2
  74. package/dist/domain/sha256.js.map +1 -1
  75. package/dist/engine.d.ts +23 -22
  76. package/dist/engine.d.ts.map +1 -1
  77. package/dist/engine.js.map +1 -1
  78. package/dist/eslint-rules/bib-reachable-entry.d.ts +1 -1
  79. package/dist/eslint-rules/bib-reachable-entry.js.map +1 -1
  80. package/dist/eslint-rules/install-path-literals.d.ts +2 -2
  81. package/dist/eslint-rules/install-path-literals.js.map +1 -1
  82. package/dist/eslint-rules/paper-research-question.d.ts +1 -1
  83. package/dist/eslint-rules/paper-research-question.js.map +1 -1
  84. package/dist/eslint-rules/paper-stages.d.ts +2 -2
  85. package/dist/eslint-rules/paper-stages.js.map +1 -1
  86. package/dist/eslint-rules/paper-typography.d.ts +6 -6
  87. package/dist/eslint-rules/paper-typography.js.map +1 -1
  88. package/dist/eslint-rules/pdf-last-page-balance.d.ts +1 -1
  89. package/dist/eslint-rules/pdf-last-page-balance.js.map +1 -1
  90. package/dist/eslint-rules/review-frontmatter.d.ts +2 -2
  91. package/dist/eslint-rules/review-frontmatter.js.map +1 -1
  92. package/dist/eslint-rules/sibling-frontmatter.d.ts +1 -1
  93. package/dist/eslint-rules/sibling-frontmatter.d.ts.map +1 -1
  94. package/dist/eslint-rules/temp-root-realpath.d.ts +1 -1
  95. package/dist/eslint-rules/temp-root-realpath.js.map +1 -1
  96. package/dist/eslint-rules/tex-build.d.ts +2 -2
  97. package/dist/eslint-rules/tex-build.js.map +1 -1
  98. package/dist/facts-file.d.ts.map +1 -1
  99. package/dist/facts-file.js +29 -26
  100. package/dist/facts-file.js.map +1 -1
  101. package/dist/hooks-settings.d.ts.map +1 -1
  102. package/dist/hooks-settings.js +17 -2
  103. package/dist/hooks-settings.js.map +1 -1
  104. package/dist/init.d.ts +3 -4
  105. package/dist/init.d.ts.map +1 -1
  106. package/dist/init.js +18 -8
  107. package/dist/init.js.map +1 -1
  108. package/dist/lib/paper-config.d.ts.map +1 -1
  109. package/dist/lib/paper-config.js +1 -5
  110. package/dist/lib/paper-config.js.map +1 -1
  111. package/dist/new-paper.d.ts.map +1 -1
  112. package/dist/new-paper.js +5 -1
  113. package/dist/new-paper.js.map +1 -1
  114. package/dist/paper-files.d.ts +4 -4
  115. package/dist/paper-files.d.ts.map +1 -1
  116. package/dist/paper-files.js +6 -6
  117. package/dist/paper-files.js.map +1 -1
  118. package/dist/paper-settings.js +3 -3
  119. package/dist/paper-settings.js.map +1 -1
  120. package/dist/pdf-facts.d.ts +51 -1
  121. package/dist/pdf-facts.d.ts.map +1 -1
  122. package/dist/pdf-facts.js +23 -15
  123. package/dist/pdf-facts.js.map +1 -1
  124. package/dist/pdf-geometry.d.ts.map +1 -1
  125. package/dist/pdf-geometry.js +2 -3
  126. package/dist/pdf-geometry.js.map +1 -1
  127. package/dist/ports/tool-installer.d.ts +2 -14
  128. package/dist/ports/tool-installer.d.ts.map +1 -1
  129. package/dist/presets.d.ts.map +1 -1
  130. package/dist/presets.js +16 -4
  131. package/dist/presets.js.map +1 -1
  132. package/dist/references.d.ts.map +1 -1
  133. package/dist/references.js +4 -4
  134. package/dist/references.js.map +1 -1
  135. package/dist/rules-config.d.ts +26 -1
  136. package/dist/rules-config.d.ts.map +1 -1
  137. package/dist/rules-config.js +9 -9
  138. package/dist/rules-config.js.map +1 -1
  139. package/dist/tex-requirements.d.ts +4 -0
  140. package/dist/tex-requirements.d.ts.map +1 -1
  141. package/dist/tex-requirements.js +14 -2
  142. package/dist/tex-requirements.js.map +1 -1
  143. package/dist/types.d.ts +5 -3
  144. package/dist/types.d.ts.map +1 -1
  145. package/dist/venue-rules.d.ts.map +1 -1
  146. package/dist/venue-rules.js +20 -7
  147. package/dist/venue-rules.js.map +1 -1
  148. package/docs/configuration.md +2 -2
  149. package/eslint-rules/bib-reachable-entry.ts +1 -1
  150. package/eslint-rules/install-path-literals.ts +2 -2
  151. package/eslint-rules/paper-research-question.ts +1 -1
  152. package/eslint-rules/paper-stages.ts +2 -2
  153. package/eslint-rules/paper-typography.ts +2 -2
  154. package/eslint-rules/pdf-last-page-balance.ts +1 -1
  155. package/eslint-rules/review-frontmatter.ts +1 -1
  156. package/eslint-rules/temp-root-realpath.ts +1 -1
  157. package/eslint-rules/tex-build.ts +2 -2
  158. package/eslint.config.mjs +7 -0
  159. package/fixtures/install-path-literals/clean.fixture.mjs +1 -1
  160. package/fixtures/install-path-literals/clean.md +2 -2
  161. package/fixtures/install-path-literals/defect.fixture.mjs +1 -1
  162. package/fixtures/install-path-literals/defect.md +2 -2
  163. package/hooks/paper-status-gates.sh +9 -19
  164. package/lib/paper-config.harness.mjs +1 -1
  165. package/lib/paper-config.ts +1 -5
  166. package/lib/skill-checks.mjs +24 -381
  167. package/lib/skill-checks.test.mjs +63 -115
  168. package/lib/skill-corpus.mjs +10 -251
  169. package/lib/skill-corpus.test.mjs +2 -13
  170. package/lib/skill-trigger-cases.mjs +1 -1
  171. package/package.json +1 -1
  172. package/scripts/coverage-config.test.ts +0 -1
  173. package/scripts/harness-api.frozen.json +0 -1
  174. package/skills/analyze-sibling-paper/SKILL.md +1 -39
  175. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +0 -38
  176. package/skills/argument-arc/SKILL.md +2 -36
  177. package/skills/argument-arc/SKILL.md.spec.ts +1 -43
  178. package/skills/build-benchmark/SKILL.md +1 -41
  179. package/skills/build-benchmark/SKILL.md.spec.ts +0 -40
  180. package/skills/camera-ready/SKILL.md +1 -34
  181. package/skills/camera-ready/SKILL.md.spec.ts +0 -33
  182. package/skills/cold-read-diff/SKILL.md +1 -35
  183. package/skills/cold-read-diff/SKILL.md.spec.ts +0 -34
  184. package/skills/draft-paper/SKILL.md +2 -41
  185. package/skills/draft-paper/SKILL.md.spec.ts +1 -50
  186. package/skills/extend-paper/SKILL.md +2 -33
  187. package/skills/extend-paper/SKILL.md.spec.ts +1 -42
  188. package/skills/find-venue/SKILL.md +2 -38
  189. package/skills/find-venue/SKILL.md.spec.ts +1 -47
  190. package/skills/grade-paper-writing/SKILL.md +3 -38
  191. package/skills/grade-paper-writing/SKILL.md.spec.ts +2 -47
  192. package/skills/grade-paper-writing/prose-lint.mjs +2 -3
  193. package/skills/grade-paper-writing/prose-lint.test.ts +1 -1
  194. package/skills/harden-paper/SKILL.md +1 -39
  195. package/skills/harden-paper/SKILL.md.spec.ts +0 -38
  196. package/skills/map-prior-work/SKILL.md +1 -35
  197. package/skills/map-prior-work/SKILL.md.spec.ts +0 -34
  198. package/skills/osf-artifact-upload/osf-artifact-upload.harness.ts +3 -4
  199. package/skills/paper-adversarial-review/SKILL.md +2 -35
  200. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -43
  201. package/skills/paper-pipeline/SKILL.md +2 -28
  202. package/skills/paper-pipeline/SKILL.md.spec.ts +1 -27
  203. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +1 -1
  204. package/skills/paper-pipeline/paper-pipeline.harness.mjs +25 -40
  205. package/skills/paper-pipeline/references/acceptance-gate.md +1 -6
  206. package/skills/paper-pipeline/references/pipeline-status-template.md +8 -35
  207. package/skills/paper-pipeline/scripts/README.md +30 -225
  208. package/skills/paper-pipeline/scripts/artifact-coverage.harness.mjs +4 -8
  209. package/skills/paper-pipeline/scripts/consumer.harness.mjs +6 -104
  210. package/skills/paper-pipeline/scripts/consumer.mjs +23 -104
  211. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +2 -3
  212. package/skills/paper-pipeline/scripts/population-map.harness.mjs +3 -3
  213. package/skills/paper-status/SKILL.md +2 -42
  214. package/skills/paper-status/SKILL.md.spec.ts +1 -41
  215. package/skills/pc-panel-review/SKILL.md +2 -46
  216. package/skills/pc-panel-review/SKILL.md.spec.ts +1 -45
  217. package/skills/plan-paper-timeline/SKILL.md +2 -36
  218. package/skills/plan-paper-timeline/SKILL.md.spec.ts +0 -36
  219. package/skills/render-paper/SKILL.md +1 -37
  220. package/skills/render-paper/SKILL.md.spec.ts +0 -36
  221. package/skills/research-ideate/SKILL.md +2 -39
  222. package/skills/research-ideate/SKILL.md.spec.ts +1 -47
  223. package/skills/study-accepted-papers/SKILL.md +1 -34
  224. package/skills/study-accepted-papers/SKILL.md.spec.ts +0 -33
  225. package/skills/submit-paper/SKILL.md +1 -33
  226. package/skills/submit-paper/SKILL.md.spec.ts +0 -32
  227. package/skills/sweep-design-space/SKILL.md +1 -38
  228. package/skills/sweep-design-space/SKILL.md.spec.ts +0 -37
  229. package/skills/tighten-paper/SKILL.md +2 -39
  230. package/skills/tighten-paper/SKILL.md.spec.ts +1 -47
  231. package/skills/tighten-paper/structure.mjs +4 -11
  232. package/skills/verify-citations/SKILL.md +2 -38
  233. package/skills/verify-citations/SKILL.md.spec.ts +1 -37
  234. package/src/adapters/banal/failure.ts +9 -10
  235. package/src/adapters/banal/index.test.ts +2 -2
  236. package/src/adapters/banal/index.ts +8 -10
  237. package/src/adapters/banal/install.ts +9 -4
  238. package/src/adapters/banal/invocation.test.ts +20 -8
  239. package/src/adapters/banal/invocation.ts +23 -8
  240. package/src/adapters/banal/locate.test.ts +2 -2
  241. package/src/adapters/banal/locate.ts +27 -21
  242. package/src/adapters/banal/run.test.ts +2 -2
  243. package/src/adapters/banal/run.ts +16 -10
  244. package/src/adapters/banal/settings.ts +2 -2
  245. package/src/adapters/curl/download.test.ts +4 -4
  246. package/src/adapters/memory/index.ts +4 -3
  247. package/src/adapters/node/files.test.ts +5 -3
  248. package/src/adapters/node/process.io.ts +9 -2
  249. package/src/adapters/node/process.test.ts +4 -4
  250. package/src/adapters/node/workspace.io.ts +9 -4
  251. package/src/adapters/references/index.ts +7 -2
  252. package/src/build-engine.test.ts +11 -6
  253. package/src/build-engine.ts +10 -5
  254. package/src/build.test.ts +10 -11
  255. package/src/build.ts +8 -9
  256. package/src/caller-path.ts +13 -0
  257. package/src/cli.harness.mjs +0 -2
  258. package/src/cli.test.ts +19 -8
  259. package/src/cli.ts +82 -25
  260. package/src/doctor.test.ts +1 -1
  261. package/src/doctor.ts +14 -5
  262. package/src/domain/paths.test.ts +15 -0
  263. package/src/domain/paths.ts +20 -1
  264. package/src/domain/ready.ts +31 -0
  265. package/src/domain/record.test.ts +20 -0
  266. package/src/domain/record.ts +12 -0
  267. package/src/domain/sha256.ts +5 -3
  268. package/src/engine.ts +30 -2
  269. package/src/facts-file.ts +43 -28
  270. package/src/hooks-settings.harness.ts +17 -0
  271. package/src/hooks-settings.ts +28 -4
  272. package/src/init.test.ts +25 -5
  273. package/src/init.ts +26 -12
  274. package/src/new-paper.harness.mjs +7 -23
  275. package/src/new-paper.ts +7 -3
  276. package/src/paper-files.test.ts +22 -19
  277. package/src/paper-files.ts +16 -27
  278. package/src/paper-settings.test.ts +2 -2
  279. package/src/paper-settings.ts +5 -5
  280. package/src/pdf-facts.test.ts +16 -5
  281. package/src/pdf-facts.ts +79 -22
  282. package/src/pdf-geometry.ts +5 -3
  283. package/src/ports/tool-installer.ts +2 -17
  284. package/src/presets.ts +28 -14
  285. package/src/references.test.ts +25 -19
  286. package/src/references.ts +4 -5
  287. package/src/rules-config.ts +40 -11
  288. package/src/tex-requirements.test.ts +21 -0
  289. package/src/tex-requirements.ts +29 -6
  290. package/src/toolchain.test.ts +9 -5
  291. package/src/toolchain.ts +2 -2
  292. package/src/types.ts +5 -3
  293. package/src/venue-rules.test.ts +8 -4
  294. package/src/venue-rules.ts +21 -11
  295. package/skills/paper-pipeline/scripts/announce.mjs +0 -67
  296. package/skills/paper-pipeline/scripts/announce.test.mjs +0 -102
  297. package/skills/paper-pipeline/scripts/fixtures/clean/PIPELINE-STATUS.md +0 -51
  298. package/skills/paper-pipeline/scripts/fixtures/dirty/PIPELINE-STATUS.md +0 -52
  299. package/skills/paper-pipeline/scripts/fixtures/dirty/paper.md +0 -6
  300. package/skills/paper-pipeline/scripts/fixtures/scorecard.ts +0 -100
  301. package/skills/paper-pipeline/scripts/ledger.cli.test.mjs +0 -302
  302. package/skills/paper-pipeline/scripts/ledger.mjs +0 -623
  303. package/skills/paper-pipeline/scripts/ledger.test.mjs +0 -254
  304. package/skills/paper-pipeline/scripts/pipeline-check.cli.test.mjs +0 -474
  305. package/skills/paper-pipeline/scripts/pipeline-check.harness.mjs +0 -328
  306. package/skills/paper-pipeline/scripts/pipeline-check.mjs +0 -750
  307. package/skills/paper-pipeline/scripts/pipeline-edges.mjs +0 -169
  308. package/skills/paper-pipeline/scripts/pipeline-edges.test.mjs +0 -73
  309. package/skills/paper-pipeline/scripts/run-mechanical.mjs +0 -648
  310. package/skills/paper-pipeline/scripts/run-mechanical.test.mjs +0 -468
  311. package/skills/paper-pipeline/scripts/status.mjs +0 -295
  312. package/skills/paper-pipeline/scripts/status.test.mjs +0 -232
package/src/cli.ts CHANGED
@@ -30,6 +30,7 @@ import { spawnSync } from "node:child_process";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import { join, dirname, resolve, relative, basename, sep } from "node:path";
32
32
  import markdown from "@eslint/markdown";
33
+ import { z } from "zod";
33
34
  // Types come from consumer.d.mts beside it.
34
35
  import {
35
36
  isMain,
@@ -112,6 +113,7 @@ import {
112
113
  shippedRuleIds,
113
114
  unknownKeys,
114
115
  type Parsed,
116
+ type ConfigBlock,
115
117
  type RuleBlock,
116
118
  type RuleEntry,
117
119
  } from "./rules-config.ts";
@@ -127,6 +129,7 @@ import reviewFrontmatter from "#eslint-rules/review-frontmatter";
127
129
  import siblingFrontmatter from "#eslint-rules/sibling-frontmatter";
128
130
  import pdfRules from "#eslint-rules/pdf-last-page-balance";
129
131
  import { messageOf } from "./domain/text.ts";
132
+ import { isRecord } from "./domain/record.ts";
130
133
 
131
134
  /** The shipped venue presets, read from the package's venues directory — the one list. */
132
135
  const SHIPPED_VENUES = (): string[] => shippedPresets(packageVenuesDir());
@@ -220,7 +223,7 @@ settings — paperlint.json, at two levels, one schema. Both are optional.
220
223
  export function buildConfig(
221
224
  opts: PaperlintConfig = {},
222
225
  texLanguage: unknown,
223
- ): unknown[] {
226
+ ): ConfigBlock[] {
224
227
  const paperRules = { ...researchQuestion.rules, ...typography.rules };
225
228
  // The reference rules judge `_build/references.json`, and only on `paper.tex`.
226
229
  const texPaperRules = {
@@ -238,7 +241,7 @@ export function buildConfig(
238
241
  languageOptions: { frontmatter: "yaml" },
239
242
  };
240
243
 
241
- const cfg: unknown[] = [
244
+ const cfg: ConfigBlock[] = [
242
245
  // 🔴 THE PROJECT'S PAPER TEMPLATE IS NOT A PAPER. `paperlint new` reads `<papers>/.template/`, and
243
246
  // its files carry every marker a paper does. Flat config does NOT ignore dot-directories by
244
247
  // default (only `node_modules/` and `.git/`), so without this block `paperlint lint` would lint the
@@ -329,6 +332,21 @@ export function buildConfig(
329
332
  return cfg;
330
333
  }
331
334
 
335
+ /**
336
+ * paperlint's blocks as ESLint's config type — the one place the two meet. ESLint types a plugin's
337
+ * rule as one that runs on ANY language; paperlint's run on the language they were written for, so
338
+ * no static proof that a block fits exists (`ConfigBlock`, rules-config.ts). ESLint checks every block
339
+ * when it loads the config and throws on one it cannot use; what is left to check here is that each
340
+ * is an object.
341
+ */
342
+ const isEslintBlock = (b: unknown): b is Linter.Config =>
343
+ typeof b === "object" && b !== null;
344
+
345
+ export function eslintConfig(blocks: readonly ConfigBlock[]): Linter.Config[] {
346
+ const loose: readonly unknown[] = blocks;
347
+ return loose.filter(isEslintBlock);
348
+ }
349
+
332
350
  /**
333
351
  * The rule ids a consumer may name in `rules`: every rule paperlint's own config defines, read off that
334
352
  * config rather than listed again. `@eslint/markdown` is a dependency's plugin, not paperlint's.
@@ -357,9 +375,7 @@ export interface RulePlugin {
357
375
  export function rulePlugins(texLanguage?: unknown): Record<string, RulePlugin> {
358
376
  const out: Record<string, { rules: Record<string, unknown> }> = {};
359
377
  for (const block of buildConfig({}, texLanguage ?? { sentinel: "tex" }))
360
- for (const [name, plugin] of Object.entries(
361
- (block as { plugins?: Record<string, RulePlugin> }).plugins ?? {},
362
- ))
378
+ for (const [name, plugin] of Object.entries(block.plugins ?? {}))
363
379
  if (plugin !== markdown)
364
380
  out[name] = { rules: { ...out[name]?.rules, ...plugin.rules } };
365
381
  if (out["tex"] && texLanguage !== undefined)
@@ -382,12 +398,10 @@ export function rulePlugins(texLanguage?: unknown): Record<string, RulePlugin> {
382
398
  * switched off in THIS run — `paperlint lint` still reports a directive that silences nothing.
383
399
  */
384
400
  export function rulesOff(texLanguage?: unknown): Linter.Config[] {
385
- return [
401
+ return eslintConfig([
386
402
  {
387
403
  name: "paperlint/rules-off",
388
- plugins: rulePlugins(texLanguage) as NonNullable<
389
- Linter.Config["plugins"]
390
- >,
404
+ plugins: rulePlugins(texLanguage),
391
405
  rules: Object.fromEntries([...SHIPPED_RULES].map((id) => [id, "off"])),
392
406
  },
393
407
  {
@@ -395,7 +409,7 @@ export function rulesOff(texLanguage?: unknown): Linter.Config[] {
395
409
  files: PAPER_FILE_PATTERNS,
396
410
  linterOptions: { reportUnusedDisableDirectives: "off" },
397
411
  },
398
- ];
412
+ ]);
399
413
  }
400
414
 
401
415
  /** Whether a rule entry (`"error"`, `2`, `["warn", {…}]`) turns the rule on. */
@@ -412,7 +426,7 @@ export const OPTIONAL_RULES: ReadonlySet<string> = new Set(
412
426
  [...SHIPPED_RULES].filter(
413
427
  (id) =>
414
428
  !buildConfig({}, { sentinel: "tex language" }).some((b) =>
415
- isOn((b as { rules?: Record<string, unknown> }).rules?.[id]),
429
+ isOn(b.rules?.[id]),
416
430
  ),
417
431
  ),
418
432
  );
@@ -429,8 +443,13 @@ export const OPTIONAL_RULES: ReadonlySet<string> = new Set(
429
443
  * papers a and b (#103) — a block that reaches a paper outside this run is not dead. A glob that
430
444
  * reaches no paper of the project at all still fails, from a subset as from the whole.
431
445
  */
446
+ /** What `calculateConfigForFile` answers, read for its rules: undefined for a file outside ESLint's cwd or scope. */
447
+ const ComputedConfig = z
448
+ .object({ rules: z.record(z.string(), z.unknown()).optional() })
449
+ .optional();
450
+
432
451
  export async function silentOptionalRules(
433
- eslint: ESLint,
452
+ eslint: Pick<ESLint, "calculateConfigForFile">,
434
453
  files: readonly string[],
435
454
  opts: PaperlintConfig,
436
455
  ): Promise<string[]> {
@@ -444,8 +463,7 @@ export async function silentOptionalRules(
444
463
  const reached = new Set<string>();
445
464
  for (const f of new Set(files.filter((p) => basename(p) === MAIN))) {
446
465
  // Undefined for a file outside ESLint's cwd or scope: it reaches nothing.
447
- const cfg = (await eslint.calculateConfigForFile(f)) as
448
- { rules?: Record<string, unknown> } | undefined;
466
+ const cfg = ComputedConfig.parse(await eslint.calculateConfigForFile(f));
449
467
  for (const id of turnedOn) if (isOn(cfg?.rules?.[id])) reached.add(id);
450
468
  }
451
469
  return [...turnedOn].filter((id) => !reached.has(id));
@@ -543,6 +561,20 @@ const PAPER_FILE_PATTERNS: string[] = ownedPatterns(
543
561
  buildConfig({}, { sentinel: "tex language" }),
544
562
  );
545
563
 
564
+ /** `structure` as `checkStructure` reads it: off, or lists of file names over the defaults. */
565
+ const Names = z.array(z.string()).exactOptional();
566
+ const Structure = z
567
+ .union([
568
+ z.literal(false),
569
+ z.object({
570
+ markers: Names,
571
+ require: Names,
572
+ requireOneOf: z.array(z.array(z.string())).exactOptional(),
573
+ ignore: Names,
574
+ }),
575
+ ])
576
+ .optional();
577
+
546
578
  /**
547
579
  * The root `paperlint.json` after the boundary: an unknown key is refused by name, the paper
548
580
  * defaults (`extends`, `kind`, `pdf`) are checked the way a paper's own are, and `rules` becomes
@@ -553,10 +585,9 @@ export function parseSettings(
553
585
  where: string,
554
586
  baseDir: string,
555
587
  ): Parsed<PaperlintConfig> {
556
- if (typeof json !== "object" || json === null || Array.isArray(json))
588
+ if (!isRecord(json))
557
589
  return { ok: false, error: `${where}: must be a JSON object` };
558
- const raw = json as Record<string, unknown>;
559
- const opts = raw as PaperlintConfig;
590
+ const raw = json;
560
591
  const unknown = unknownKeys(raw);
561
592
  if (unknown.length > 0)
562
593
  return {
@@ -567,9 +598,22 @@ export function parseSettings(
567
598
  };
568
599
  const defaults = stringFields(raw);
569
600
  if (!defaults.ok) return { ok: false, error: `${where}: ${defaults.error}` };
601
+ const structure = Structure.safeParse(raw["structure"]);
602
+ if (!structure.success)
603
+ return {
604
+ ok: false,
605
+ error: `${where}: "structure" must be false, or an object whose markers, require and ignore are lists of file names and requireOneOf a list of such lists`,
606
+ };
570
607
  const rules = parseRuleBlocks(raw["rules"], where, SHIPPED_RULES, baseDir);
571
608
  if (!rules.ok) return rules;
572
- return { ok: true, value: { ...opts, rules: rules.value } };
609
+ return {
610
+ ok: true,
611
+ value: {
612
+ papersDir: raw[PAPERS_DIR_FIELD],
613
+ structure: structure.data,
614
+ rules: rules.value,
615
+ },
616
+ };
573
617
  }
574
618
 
575
619
  /**
@@ -770,7 +814,7 @@ export function readConfig(
770
814
 
771
815
  /** The papers directory field of the settings, read by its one declared name; the default when absent. */
772
816
  export function papersDirOf(opts: PaperlintConfig): unknown {
773
- const declared = (opts as Record<string, unknown>)[PAPERS_DIR_FIELD];
817
+ const declared = opts.papersDir;
774
818
  return declared === undefined ? DEFAULT_PAPERS_ROOT : declared;
775
819
  }
776
820
 
@@ -839,7 +883,12 @@ export function runHook(
839
883
  createRequire(import.meta.url).resolve(spec),
840
884
  }: {
841
885
  err?: typeof console.error;
842
- run?: typeof spawnSync;
886
+ /** Runs the hook with the terminal's streams: `spawnSync` is one, and only its status is read. */
887
+ run?: (
888
+ file: string,
889
+ args: readonly string[],
890
+ options: { readonly stdio: "inherit" },
891
+ ) => { readonly status: number | null };
843
892
  resolve?: (spec: string) => string;
844
893
  } = {},
845
894
  ): number {
@@ -1466,7 +1515,7 @@ export async function run(
1466
1515
  const eslint = new ESLint({
1467
1516
  cwd: lintRoot(root, paths),
1468
1517
  overrideConfigFile: true,
1469
- overrideConfig: buildConfig(withPapers, texLanguage) as Linter.Config[],
1518
+ overrideConfig: eslintConfig(buildConfig(withPapers, texLanguage)),
1470
1519
  fix: a.fix,
1471
1520
  });
1472
1521
 
@@ -1543,12 +1592,20 @@ async function firstUnownedFile(
1543
1592
  * is outside paperlint's scope (`all-matched-files-ignored` — a papers directory holding only
1544
1593
  * vendored scripts). Both mean "nothing was linted", which the caller reports itself.
1545
1594
  */
1595
+ /** What a thrown ESLint error says about itself; anything else thrown says nothing. */
1596
+ const EslintFailure = z
1597
+ .object({
1598
+ messageTemplate: z.string().optional(),
1599
+ message: z.string().optional(),
1600
+ })
1601
+ .catch({});
1602
+
1546
1603
  const isEmptySet = (e: unknown): boolean => {
1547
- const fail = e as { messageTemplate?: string; message?: string } | null;
1604
+ const fail = EslintFailure.parse(e);
1548
1605
  return (
1549
- fail?.messageTemplate === "file-not-found" ||
1550
- fail?.messageTemplate === "all-matched-files-ignored" ||
1551
- /No files matching/i.test(String(fail?.message))
1606
+ fail.messageTemplate === "file-not-found" ||
1607
+ fail.messageTemplate === "all-matched-files-ignored" ||
1608
+ /No files matching/i.test(String(fail.message))
1552
1609
  );
1553
1610
  };
1554
1611
 
@@ -44,7 +44,7 @@ test("doctor: a settings file silent on papersDir, an occupied entry, an unreada
44
44
  const code = doctor({
45
45
  cwd: root,
46
46
  log: (s: string) => lines.push(s),
47
- run: (() => ({ status: 0 })) as never,
47
+ run: () => ({ status: 0 }),
48
48
  cliPapers: join(root, "papers"),
49
49
  skillLinks: () => ({
50
50
  ok: true,
package/src/doctor.ts CHANGED
@@ -37,6 +37,7 @@ import { PAPER_MARKERS } from "./build.ts";
37
37
  import { linkSkills, SKILLS_HOME, type LinkReport } from "./link-skills.ts";
38
38
  import { doctorHooks } from "./hooks-settings.ts";
39
39
  import { messageOf } from "./domain/text.ts";
40
+ import { fieldOf } from "./domain/record.ts";
40
41
 
41
42
  export interface Program {
42
43
  readonly bin: string;
@@ -99,7 +100,17 @@ export const PROGRAMS: readonly Program[] = [
99
100
  },
100
101
  ];
101
102
 
102
- export const found = (bin: string, run = spawnSync): boolean =>
103
+ /**
104
+ * Asks the shell whether a program is on PATH. `spawnSync` is one; a fake answers with a status
105
+ * alone, because a status is all `found` reads.
106
+ */
107
+ export type ProgramProbe = (
108
+ file: string,
109
+ args: readonly string[],
110
+ options: { readonly shell: true; readonly stdio: "ignore" },
111
+ ) => { readonly status: number | null };
112
+
113
+ export const found = (bin: string, run: ProgramProbe = spawnSync): boolean =>
103
114
  run("command", ["-v", bin], { shell: true, stdio: "ignore" }).status === 0;
104
115
 
105
116
  /** Lists a directory's entries; the disk by default, a test stages an unreadable child. */
@@ -176,9 +187,7 @@ function declarationVerdict(raw: string | null): {
176
187
  bad: 1,
177
188
  };
178
189
  }
179
- const declared = (settings as Record<string, unknown> | null)?.[
180
- PAPERS_DIR_FIELD
181
- ];
190
+ const declared = fieldOf(settings, PAPERS_DIR_FIELD);
182
191
  return {
183
192
  lines: [
184
193
  declared === undefined
@@ -234,7 +243,7 @@ export interface DoctorOptions {
234
243
  cwd?: string;
235
244
  /** The project root the hooks are anchored to. Claude Code sets this; it defaults to `cwd`. */
236
245
  projectDir?: string;
237
- run?: typeof spawnSync;
246
+ run?: ProgramProbe;
238
247
  /** The directory the CLI resolved from its own config, so both sides can be compared. */
239
248
  cliPapers?: string | null;
240
249
  /** Reads the skill links without writing any. Injected only so a test can fake the install. */
@@ -0,0 +1,15 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "vitest";
3
+ import { absolutePath, joinPath } from "./paths.ts";
4
+
5
+ test("absolutePath keeps an absolute path as it is", () => {
6
+ assert.equal(absolutePath("/a/b"), "/a/b");
7
+ });
8
+
9
+ test("absolutePath refuses a relative path instead of letting the process's cwd decide it", () => {
10
+ assert.throws(() => absolutePath("a/b"), /not an absolute path: a\/b/);
11
+ });
12
+
13
+ test("joinPath stays under its absolute base", () => {
14
+ assert.equal(joinPath(absolutePath("/a"), "b", "c"), "/a/b/c");
15
+ });
@@ -1,5 +1,24 @@
1
1
  /** Paths as the domain holds them. */
2
+ import { isAbsolute, join } from "node:path";
2
3
  import type { Opaque } from "ts-essentials";
3
4
 
4
- /** An absolute path. Minted at the composition root (`abs`), so nothing inside resolves a cwd. */
5
+ /**
6
+ * An absolute path. Minted by `absolutePath` (a checked string) or `joinPath` (an absolute base and
7
+ * segments) only, so nothing holding one has to ask whether it is relative.
8
+ */
5
9
  export type AbsolutePath = Opaque<string, "AbsolutePath">;
10
+
11
+ // The brand's one owner: the predicate IS the check, so no assertion is needed to mint the type.
12
+ const isAbsolutePath = (p: string): p is AbsolutePath => isAbsolute(p);
13
+
14
+ /** A path the caller holds as text, checked where it becomes a value. A relative one throws. */
15
+ export function absolutePath(p: string): AbsolutePath {
16
+ if (!isAbsolutePath(p)) throw new Error(`not an absolute path: ${p}`);
17
+ return p;
18
+ }
19
+
20
+ /** `segments` under `base`: absolute because `base` is. */
21
+ export const joinPath = (
22
+ base: AbsolutePath,
23
+ ...segments: string[]
24
+ ): AbsolutePath => absolutePath(join(base, ...segments));
@@ -0,0 +1,31 @@
1
+ /** A program `paperlint toolchain` installed or found: present, verified, running. */
2
+
3
+ // Not exported: a `Ready` carries this property, and the one way to write it is `ready` below.
4
+ const READY = Symbol("Ready");
5
+
6
+ /**
7
+ * Present, verified, running. BRANDED: a literal `{ where, fresh, verified }` is not a `Ready`, so
8
+ * only an installer's adapter — one minting call each (banal's is `ready` in
9
+ * `adapters/banal/index.ts`) — can make one, and a ready line cannot be printed without it.
10
+ */
11
+ export interface Ready {
12
+ /** Where it is: the program's path or its bin directory. */
13
+ readonly where: string;
14
+ /** This run installed it. */
15
+ readonly fresh: boolean;
16
+ /** What was checked before calling it ready, as a person reads it. */
17
+ readonly verified: string;
18
+ readonly [READY]: true;
19
+ }
20
+
21
+ /** An installer's verdict that its program is ready. Called by an installer adapter, after its checks. */
22
+ export const ready = (r: {
23
+ readonly where: string;
24
+ readonly fresh: boolean;
25
+ readonly verified: string;
26
+ }): Ready => ({
27
+ where: r.where,
28
+ fresh: r.fresh,
29
+ verified: r.verified,
30
+ [READY]: true,
31
+ });
@@ -0,0 +1,20 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "vitest";
3
+ import { fieldOf, isRecord, numbersOf } from "./record.ts";
4
+
5
+ test("isRecord: an object, and not null, an array or a string", () => {
6
+ assert.deepEqual(
7
+ [isRecord({}), isRecord(null), isRecord([]), isRecord("x")],
8
+ [true, false, false, false],
9
+ );
10
+ });
11
+
12
+ test("fieldOf reads an own or inherited field of a record, and nothing of anything else", () => {
13
+ assert.equal(fieldOf({ a: 1 }, "a"), 1);
14
+ assert.equal(fieldOf(new TypeError("x"), "name"), "TypeError");
15
+ assert.equal(fieldOf("text", "length"), undefined);
16
+ });
17
+
18
+ test("numbersOf keeps numbers as they are", () => {
19
+ assert.deepEqual(numbersOf([1, -0.5, 0]), [1, -0.5, 0]);
20
+ });
@@ -0,0 +1,12 @@
1
+ /** Reading fields off a value typed `unknown` — a parsed JSON file, an object a library hands back. */
2
+
3
+ /** Any non-null object that is not an array, readable by key. */
4
+ export const isRecord = (v: unknown): v is Readonly<Record<string, unknown>> =>
5
+ typeof v === "object" && v !== null && !Array.isArray(v);
6
+
7
+ /** `v[key]` when `v` is a record; undefined for anything else. Inherited fields count, as `.` reads them. */
8
+ export const fieldOf = (v: unknown, key: string): unknown =>
9
+ isRecord(v) ? v[key] : undefined;
10
+
11
+ /** A number array a library types as `any[]`, read as the numbers it holds. */
12
+ export const numbersOf = (xs: readonly unknown[]): number[] => xs.map(Number);
@@ -9,13 +9,15 @@ import type { Opaque } from "ts-essentials";
9
9
  export type Sha256 = Opaque<string, "Sha256">;
10
10
 
11
11
  const SHA256 = /^[0-9a-f]{64}$/;
12
+ // The brand's one owner: the predicate IS the check, so no assertion is needed to mint the type.
13
+ const isSha256 = (s: string): s is Sha256 => SHA256.test(s);
12
14
 
13
15
  /** A sha256 written down as text — a pin, an option — checked where it is written, not where it is used. */
14
16
  export function parseSha256(s: string): Sha256 {
15
- if (!SHA256.test(s))
17
+ if (!isSha256(s))
16
18
  throw new Error(`not a sha256 (64 lowercase hex digits): ${s}`);
17
- return s as Sha256;
19
+ return s;
18
20
  }
19
21
 
20
22
  export const sha256Hex = (bytes: Uint8Array): Sha256 =>
21
- createHash("sha256").update(bytes).digest("hex") as Sha256;
23
+ parseSha256(createHash("sha256").update(bytes).digest("hex"));
package/src/engine.ts CHANGED
@@ -26,8 +26,36 @@ import { basename, delimiter, join } from "node:path";
26
26
  import type { TexRequirements } from "./tex-requirements.ts";
27
27
  import { printed } from "./domain/text.ts";
28
28
 
29
- /** A process runner with `spawnSync`'s shape — the port the harness replaces. */
30
- export type Runner = typeof spawnSync;
29
+ /** The options paperlint hands a process runner: text out, both streams captured. */
30
+ export interface RunOptions {
31
+ readonly encoding: "utf8" | "latin1";
32
+ readonly stdio: ["ignore", "pipe", "pipe"];
33
+ readonly env?: NodeJS.ProcessEnv;
34
+ readonly cwd?: string;
35
+ readonly maxBuffer?: number;
36
+ readonly timeout?: number;
37
+ }
38
+
39
+ /**
40
+ * What paperlint reads of a finished process. `spawnSync`'s result has this shape; a fake needs
41
+ * nothing more. Both streams are optional: they are `undefined` when the spawn itself failed.
42
+ */
43
+ export interface RunResult {
44
+ readonly status: number | null;
45
+ readonly stdout?: string;
46
+ readonly stderr?: string;
47
+ readonly error?: Error;
48
+ }
49
+
50
+ /**
51
+ * A process runner: `spawnSync`'s text overload, narrowed to what paperlint passes and reads — the
52
+ * port the harness replaces. `spawnSync` itself is one.
53
+ */
54
+ export type Runner = (
55
+ file: string,
56
+ args: readonly string[],
57
+ options: RunOptions,
58
+ ) => RunResult;
31
59
 
32
60
  /** One TeX Live and what it lacks for the papers at hand. */
33
61
  export interface TreeProbe {
package/src/facts-file.ts CHANGED
@@ -45,7 +45,7 @@ import {
45
45
  } from "./domain/geometry.ts";
46
46
  import { parseSha256, sha256Hex, type Sha256 } from "./domain/sha256.ts";
47
47
  import type { MeasureGeometry } from "./ports/measure-geometry.ts";
48
- import type { AbsolutePath } from "./domain/paths.ts";
48
+ import { callerPath } from "./caller-path.ts";
49
49
  import type { Files } from "./ports/files.ts";
50
50
  import { err, ok, type Result } from "./domain/result.ts";
51
51
  import { paperPreset, paperPresetProblem } from "./presets.ts";
@@ -60,12 +60,6 @@ export const FACTS_FILE = "paper.facts.json";
60
60
  export const factsPath = (paperDir: string): string =>
61
61
  join(paperDir, FACTS_DIR, FACTS_FILE);
62
62
 
63
- /**
64
- * A path as the caller gave it. The `Files` adapter resolves a relative one against the process's
65
- * cwd, which is what these callers have always meant; the brand is not a claim this code checked it.
66
- */
67
- const at = (p: string): AbsolutePath => p as AbsolutePath;
68
-
69
63
  /** What the facts need from a paper's `paperlint.json`: its venue's label, its kind, where its PDF is. */
70
64
  export interface VenueDecl {
71
65
  /** The venue preset's display label (`agenticdev`), or null when the paper extends none. */
@@ -213,7 +207,7 @@ export async function measurePaper(
213
207
  ): Promise<Result<Measured, string>> {
214
208
  const r = await o.readPdf(pdf);
215
209
  if (!r.ok) return err(describeFailure(r, pdf));
216
- const bytes = o.files.readBytes(at(pdf));
210
+ const bytes = o.files.readBytes(callerPath(pdf));
217
211
  if (bytes === null) return err(`${pdf}: gone after pdf.js read it`);
218
212
  const geometry = o.measure.measure(r.facts.layout);
219
213
  const decl = declaredVenue(o.files, paperDir);
@@ -239,7 +233,7 @@ export function writeFactsFile(
239
233
  ): string {
240
234
  const out = factsPath(paperDir);
241
235
  files.writeAtomic(
242
- at(out),
236
+ callerPath(out),
243
237
  new TextEncoder().encode(`${JSON.stringify(facts, null, 2)}\n`),
244
238
  );
245
239
  return out;
@@ -288,6 +282,34 @@ const MAYBE_NUMBERS = [
288
282
  ] as const;
289
283
  const NUMBERS = ["body_pages", "ref_pages", "appendix_pages"] as const;
290
284
 
285
+ /** The geometry columns as the file holds them once checked. */
286
+ type GeometryColumns = {
287
+ readonly [K in (typeof MAYBE_NUMBERS)[number]]: number | null;
288
+ } & { readonly [K in (typeof NUMBERS)[number]]: number } & {
289
+ readonly pages_by_type: Readonly<Record<string, number>>;
290
+ };
291
+
292
+ const isCounts = (v: unknown): v is Readonly<Record<string, number>> =>
293
+ isRecord(v) && Object.values(v).every(isNum);
294
+
295
+ /** Every geometry column has its type: the check the type claims, and nothing else. */
296
+ const hasGeometryColumns = (
297
+ d: Readonly<Record<string, unknown>>,
298
+ ): d is Readonly<Record<string, unknown>> & GeometryColumns =>
299
+ MAYBE_NUMBERS.every((k) => numOrNull(d[k])) &&
300
+ NUMBERS.every((k) => isNum(d[k])) &&
301
+ isCounts(d["pages_by_type"]);
302
+
303
+ /** Which geometry column is wrong, for a record `hasGeometryColumns` refused. */
304
+ function geometryError(d: Readonly<Record<string, unknown>>): string {
305
+ const bad =
306
+ MAYBE_NUMBERS.find((k) => !numOrNull(d[k])) ??
307
+ NUMBERS.find((k) => !isNum(d[k]));
308
+ return bad
309
+ ? `\`${bad}\` is not a number`
310
+ : "`pages_by_type` is not an object of page counts";
311
+ }
312
+
291
313
  /** The geometry columns when a measurer ran; the shape error otherwise. */
292
314
  function geometryOf(
293
315
  d: Readonly<Record<string, unknown>>,
@@ -295,24 +317,17 @@ function geometryOf(
295
317
  if (d["geometry_source"] === null) return ok(null);
296
318
  if (typeof d["geometry_source"] !== "string")
297
319
  return err("`geometry_source` is neither a measurer's name nor null");
298
- const bad =
299
- MAYBE_NUMBERS.find((k) => !numOrNull(d[k])) ??
300
- NUMBERS.find((k) => !isNum(d[k]));
301
- if (bad) return err(`\`${bad}\` is not a number`);
302
- const pagesByType = d["pages_by_type"];
303
- if (!isRecord(pagesByType) || !Object.values(pagesByType).every(isNum))
304
- return err("`pages_by_type` is not an object of page counts");
305
- const n = (k: (typeof MAYBE_NUMBERS)[number]) => d[k] as number | null;
320
+ if (!hasGeometryColumns(d)) return err(geometryError(d));
306
321
  return ok({
307
- page_w_in: n("page_w_in"),
308
- page_h_in: n("page_h_in"),
309
- columns: n("columns"),
310
- body_pt: n("body_pt"),
311
- ref_pt: n("ref_pt"),
312
- body_pages: d["body_pages"] as number,
313
- ref_pages: d["ref_pages"] as number,
314
- appendix_pages: d["appendix_pages"] as number,
315
- pages_by_type: pagesByType as Readonly<Record<string, number>>,
322
+ page_w_in: d.page_w_in,
323
+ page_h_in: d.page_h_in,
324
+ columns: d.columns,
325
+ body_pt: d.body_pt,
326
+ ref_pt: d.ref_pt,
327
+ body_pages: d.body_pages,
328
+ ref_pages: d.ref_pages,
329
+ appendix_pages: d.appendix_pages,
330
+ pages_by_type: d.pages_by_type,
316
331
  });
317
332
  }
318
333
 
@@ -348,7 +363,7 @@ export function parseFactsText(text: string): Result<ReadFacts, FactsProblem> {
348
363
  if (!artifact.ok) return err({ kind: "broken", why: artifact.error });
349
364
  const rawFonts = d["fonts"];
350
365
  const fonts = Array.isArray(rawFonts) ? rawFonts.map(fontOf) : null;
351
- if (fonts === null || fonts.some((f) => f === null))
366
+ if (fonts === null || !fonts.every((f) => f !== null))
352
367
  return err({
353
368
  kind: "broken",
354
369
  why: "`fonts` is not a list of { name, type, embedded, program }",
@@ -357,7 +372,7 @@ export function parseFactsText(text: string): Result<ReadFacts, FactsProblem> {
357
372
  if (!geometry.ok) return err({ kind: "broken", why: geometry.error });
358
373
  return ok({
359
374
  ...artifact.value,
360
- fonts: fonts as FontEntry[],
375
+ fonts,
361
376
  geometry: geometry.value,
362
377
  });
363
378
  }
@@ -118,6 +118,23 @@ try {
118
118
  threw,
119
119
  );
120
120
 
121
+ const malformed = join(work, "malformed-hooks.json");
122
+ writeFileSync(
123
+ malformed,
124
+ '{"hooks":{"PreToolUse":[{"hooks":[{"type":"command"}]}]}}',
125
+ );
126
+ let why = "";
127
+ try {
128
+ shippedWiring(malformed);
129
+ } catch (e) {
130
+ why = e instanceof Error ? e.message : String(e);
131
+ }
132
+ check(
133
+ "a wiring file whose `hooks` is not Claude Code's shape THROWS, naming the shape",
134
+ why.startsWith(`${malformed}: "hooks" is not a map of event`),
135
+ why,
136
+ );
137
+
121
138
  // ── which spelling runs which hook ─────────────────────────────────────────────────────
122
139
  const ours = `node "\${CLAUDE_PROJECT_DIR}/${MANAGED_BY}" hook paper-edit-guard`;
123
140
  const cases: [string, { name: string; ours: boolean } | null][] = [