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
@@ -31,6 +31,7 @@ import {
31
31
  PACKAGE_NAME,
32
32
  } from "../skills/paper-pipeline/scripts/consumer.mjs";
33
33
  import { messageOf } from "./domain/text.ts";
34
+ import { fieldOf, isRecord } from "./domain/record.ts";
34
35
 
35
36
  /** The committed, shared settings file — husky's `.husky/` analogue, except git carries it. */
36
37
  export const SETTINGS_PATH = join(".claude", "settings.json");
@@ -151,6 +152,25 @@ const prop = (v: unknown): Readonly<Record<string, unknown>> | undefined =>
151
152
  ? Object.fromEntries(Object.entries(v))
152
153
  : undefined;
153
154
 
155
+ const isHookCommand = (v: unknown): v is HookCommand =>
156
+ typeof fieldOf(v, "type") === "string" &&
157
+ typeof fieldOf(v, "command") === "string";
158
+
159
+ function isHookEntry(v: unknown): v is HookEntry {
160
+ const matcher = fieldOf(v, "matcher");
161
+ const hooks = fieldOf(v, "hooks");
162
+ return (
163
+ ["undefined", "string"].includes(typeof matcher) &&
164
+ Array.isArray(hooks) &&
165
+ hooks.every(isHookCommand)
166
+ );
167
+ }
168
+
169
+ /** A `hooks` map of the shape Claude Code reads: event → entries → commands. */
170
+ const isHooksMap = (v: unknown): v is HooksMap =>
171
+ isRecord(v) &&
172
+ Object.values(v).every((es) => Array.isArray(es) && es.every(isHookEntry));
173
+
154
174
  export interface Wiring {
155
175
  readonly compiled: HooksMap;
156
176
  /** Derived from the commands, in order. Never empty — an empty set throws. */
@@ -162,8 +182,12 @@ export interface Wiring {
162
182
  * check that silently wired nothing would report "✓ wired" over an empty set.
163
183
  */
164
184
  export function shippedWiring(file: string = WIRING_FILE): Wiring {
165
- const json = JSON.parse(readFileSync(file, "utf8")) as { hooks?: HooksMap };
166
- const compiled = json.hooks ?? {};
185
+ const json: unknown = JSON.parse(readFileSync(file, "utf8"));
186
+ const compiled = fieldOf(json, "hooks") ?? {};
187
+ if (!isHooksMap(compiled))
188
+ throw new Error(
189
+ `${file}: "hooks" is not a map of event → [{ matcher?, hooks: [{ type, command }] }]`,
190
+ );
167
191
  const names = commandsIn({ hooks: compiled })
168
192
  .map((c) => hookRun(c.command)?.name)
169
193
  .filter((n): n is string => typeof n === "string");
@@ -225,9 +249,9 @@ export function readSettings(root: string): SettingsRead {
225
249
  const raw = readFileSync(path, "utf8");
226
250
  try {
227
251
  const parsed: unknown = JSON.parse(raw);
228
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
252
+ if (!isRecord(parsed))
229
253
  return { status: "unparsable", path, reason: "not a JSON object" };
230
- return { status: "read", path, settings: parsed as Settings, raw };
254
+ return { status: "read", path, settings: parsed, raw };
231
255
  } catch (e) {
232
256
  return { status: "unparsable", path, reason: messageOf(e) };
233
257
  }
package/src/init.test.ts CHANGED
@@ -70,7 +70,7 @@ describe("paperlint init — a declared papers directory", () => {
70
70
  err: (s: string) => out.push(s),
71
71
  interactive: false,
72
72
  hooks: false,
73
- run: (() => ({ status: 0 })) as never,
73
+ run: () => ({ status: 0 }),
74
74
  link: () => ({ ok: false, error: "not linked in this test" }),
75
75
  });
76
76
  const text = out.join("\n");
@@ -97,7 +97,7 @@ describe("paperlint init — a declared papers directory", () => {
97
97
  interactive: true,
98
98
  ask: (q) => Promise.resolve((asked.push(q), "n")),
99
99
  hooks: false,
100
- run: (() => ({ status: 0 })) as never,
100
+ run: () => ({ status: 0 }),
101
101
  link: () => ({ ok: false, error: "not linked in this test" }),
102
102
  });
103
103
  expect(asked.filter((q) => /papers roots/.test(q))).toEqual([]);
@@ -116,7 +116,7 @@ describe("paperlint init — writes paperlint.json only when something differs f
116
116
  err: () => {},
117
117
  interactive: false,
118
118
  hooks: false,
119
- run: (() => ({ status: 0 })) as never,
119
+ run: () => ({ status: 0 }),
120
120
  link: () => ({ ok: false as const, error: "not linked in this test" }),
121
121
  };
122
122
  const fresh = (files: Record<string, string>): string => {
@@ -170,7 +170,7 @@ const texRun = async (
170
170
  ask: (q) =>
171
171
  Promise.resolve((asked.push(q), /TeX Live/.test(q) ? answer : "")),
172
172
  hooks: false,
173
- run: (() => ({ status: 0 })) as never,
173
+ run: () => ({ status: 0 }),
174
174
  link: () => ({ ok: false as const, error: "not linked in this test" }),
175
175
  tex,
176
176
  });
@@ -249,7 +249,7 @@ async function initRun(
249
249
  err: (s: string) => err.push(s),
250
250
  interactive: false,
251
251
  hooks: false,
252
- run: (() => ({ status: 0 })) as never,
252
+ run: () => ({ status: 0 }),
253
253
  link: () => ({ ok: false, error: "not linked in this test" }),
254
254
  ...opts,
255
255
  });
@@ -280,6 +280,26 @@ describe("paperlint init — a paperlint.json it cannot use stops it before anyt
280
280
  ]);
281
281
  });
282
282
 
283
+ it("🔴 a paperlint.json holding JSON that is not an object is refused, not read as settings", async () => {
284
+ // Guards: `null` threw a TypeError reading the papers field; `[1]` was spread into a settings
285
+ // object and the consumer's file rewritten as `{ "0": 1, "papersDir": … }`.
286
+ for (const json of ["null\n", "[1]\n"]) {
287
+ const root = tree({
288
+ "paperlint.json": json,
289
+ "drafts/a/paper.md": "# a\n",
290
+ });
291
+ const decl = await declarePapers(root, () =>
292
+ choosePapers(root, { interactive: false }),
293
+ );
294
+ expect(decl).toEqual({
295
+ status: "unparsable",
296
+ path: join(root, "paperlint.json"),
297
+ reason: "not a JSON object",
298
+ });
299
+ expect(readFileSync(join(root, "paperlint.json"), "utf8")).toBe(json);
300
+ }
301
+ });
302
+
283
303
  it("a file without a trailing newline keeps having none", async () => {
284
304
  const root = tree({
285
305
  "paperlint.json": '{"x":1}',
package/src/init.ts CHANGED
@@ -46,7 +46,13 @@ import {
46
46
  // eslint-disable-next-line boundaries/dependencies -- legacy I/O, moves behind a port in #76
47
47
  import { spawnSync } from "node:child_process";
48
48
  import { dirname, join, relative, resolve } from "node:path";
49
- import { doctor, detectPapers, found, PROGRAMS } from "./doctor.ts";
49
+ import {
50
+ doctor,
51
+ detectPapers,
52
+ found,
53
+ PROGRAMS,
54
+ type ProgramProbe,
55
+ } from "./doctor.ts";
50
56
  import { PAPER_MARKERS, papersIn } from "./build.ts";
51
57
  import { linkSkills, SKILLS_HOME, type LinkReport } from "./link-skills.ts";
52
58
  import { actionRef } from "./action-ref.ts";
@@ -73,6 +79,7 @@ import {
73
79
  PAPERS_DIR_FIELD,
74
80
  } from "#lib/paper-config";
75
81
  import { messageOf } from "./domain/text.ts";
82
+ import { isRecord } from "./domain/record.ts";
76
83
 
77
84
  /** How the papers directory was arrived at. Printed, because a guess must not read as a fact. */
78
85
  export type PapersHow =
@@ -227,13 +234,17 @@ export async function declarePapers(
227
234
  ): Promise<DeclarationResult> {
228
235
  const path = join(root, CONFIG_FILE);
229
236
  const raw = existsSync(path) ? readFileSync(path, "utf8") : null;
230
- let settings: Record<string, unknown> = {};
237
+ let settings: Readonly<Record<string, unknown>> = {};
231
238
  if (raw !== null) {
239
+ let parsed: unknown;
232
240
  try {
233
- settings = JSON.parse(raw) as Record<string, unknown>;
241
+ parsed = JSON.parse(raw);
234
242
  } catch (e) {
235
243
  return { status: "unparsable", path, reason: messageOf(e) };
236
244
  }
245
+ if (!isRecord(parsed))
246
+ return { status: "unparsable", path, reason: "not a JSON object" };
247
+ settings = parsed;
237
248
  const existing = settings[PAPERS_DIR_FIELD];
238
249
  if (existing !== undefined)
239
250
  return { status: "kept", path, papers: existing };
@@ -362,7 +373,7 @@ export function reportWorkflow(
362
373
  * passing checker produce the same silence.
363
374
  */
364
375
  export function missingPrograms(
365
- run = spawnSync,
376
+ run: ProgramProbe = spawnSync,
366
377
  ): readonly (typeof PROGRAMS)[number][] {
367
378
  return PROGRAMS.filter((p) => !found(p.bin, run));
368
379
  }
@@ -584,7 +595,7 @@ export interface InitOptions {
584
595
  name: string,
585
596
  format: PaperFormat,
586
597
  ) => Promise<number>;
587
- run?: typeof spawnSync;
598
+ run?: ProgramProbe;
588
599
  /**
589
600
  * What `paperlint lint` would resolve from the declaration, asked of the CLI's OWN reader. A second
590
601
  * implementation here would be a second source of truth — the very defect `doctor` reports.
@@ -749,21 +760,28 @@ export async function init(
749
760
  for (const line of papersLines(decl, why)) log(line);
750
761
  log(``);
751
762
  log(`settings`);
752
- if (decl.status === "written")
763
+ // Every step below uses the DECLARED directory. A kept declaration outranks what init
764
+ // measured or guessed: otherwise the first paper and the workflow would land in the
765
+ // guessed directory while lint and the hooks keep reading the declared one.
766
+ let papersDir: string;
767
+ if (decl.status === "written") {
768
+ papersDir = decl.papers;
753
769
  log(
754
770
  ` ✓ ${here(decl.path)} → { "${PAPERS_DIR_FIELD}": ${JSON.stringify(decl.papers)} }`,
755
771
  );
756
- else if (decl.status === "default")
772
+ } else if (decl.status === "default") {
773
+ papersDir = decl.papers;
757
774
  log(
758
775
  ` ✓ nothing to write — "${decl.papers}" is the default, so no ${CONFIG_FILE} is needed`,
759
776
  );
760
- else if (decl.status === "kept") {
777
+ } else if (decl.status === "kept") {
761
778
  if (typeof decl.papers !== "string") {
762
779
  err(
763
780
  ` ✗ ${here(decl.path)} declares ${PAPERS_DIR_FIELD} = ${JSON.stringify(decl.papers)} — it must be a directory path (a string)`,
764
781
  );
765
782
  return 2;
766
783
  }
784
+ papersDir = decl.papers;
767
785
  log(
768
786
  ` ✓ ${here(decl.path)} already declares ${PAPERS_DIR_FIELD} = ${JSON.stringify(decl.papers)} — kept, nothing overwritten`,
769
787
  );
@@ -777,10 +795,6 @@ export async function init(
777
795
  );
778
796
  return 2;
779
797
  }
780
- // Every step below uses the DECLARED directory. A kept declaration outranks what init
781
- // measured or guessed: otherwise the first paper and the workflow would land in the
782
- // guessed directory while lint and the hooks keep reading the declared one.
783
- const papersDir = decl.papers as string;
784
798
 
785
799
  // ── 3. the skills, linked where Claude Code looks for them ─────────────────────────────
786
800
  for (const line of reportSkillLinks(link(root), here)) log(line);
@@ -7,9 +7,8 @@
7
7
  * from a missing file into a created one;
8
8
  * - the scorecard carries `researchQuestion` and NO `stages` — a new paper has shipped nothing,
9
9
  * and a placeholder stage would be a red `paper/stages` on the very first run;
10
- * - the scorecard PARSES as a scorecard (`pipeline-check.mjs` finds its four sections). The
11
- * template used to live in a code fence; moved into a file, it must still be the format the
12
- * skills' checker reads;
10
+ * - the scorecard carries the `**Readiness verdict:**` line the status hook surfaces on every
11
+ * paper edit — without it the hook reports that the paper cannot say whether it is ready;
13
12
  * - it never overwrites, and on an existing folder adds only what is missing;
14
13
  * - the project's `<papers>/.template/` wins over the package, file by file.
15
14
  *
@@ -25,7 +24,6 @@ import {
25
24
  rmSync,
26
25
  writeFileSync,
27
26
  } from "node:fs";
28
- import { spawnSync } from "node:child_process";
29
27
  import { tmpdir } from "node:os";
30
28
  import { dirname, join } from "node:path";
31
29
  import { fileURLToPath } from "node:url";
@@ -124,26 +122,12 @@ try {
124
122
  /1 problem \(0 errors, 1 warning\)/.test(l.text),
125
123
  );
126
124
 
127
- // The format contract of the skills' checker: four sections, parsed.
128
- const pc = spawnSync(
129
- process.execPath,
130
- [
131
- join(ROOT, "skills", "paper-pipeline", "scripts", "pipeline-check.mjs"),
132
- join(papers, "demo"),
133
- "--json",
134
- "--today=2026-09-23",
135
- ],
136
- { cwd: work, encoding: "utf8" },
137
- );
138
- let parsed = null;
139
- try {
140
- parsed = JSON.parse(pc.stdout);
141
- } catch {
142
- parsed = null;
143
- }
125
+ // What the status hook reads on every paper edit: the verdict line.
144
126
  check(
145
- "the scorecard PARSES for pipeline-check — its four sections are found (it prints JSON, not the format warning)",
146
- Array.isArray(parsed) && !/expected the four sections/.test(pc.stderr),
127
+ "the scorecard carries the `**Readiness verdict:**` line the status hook surfaces",
128
+ /^\*\*Readiness verdict:\*\*/m.test(
129
+ readFileSync(join(papers, "demo", STATUS_FILE), "utf8"),
130
+ ),
147
131
  );
148
132
 
149
133
  // ── never overwrite; on an existing folder only the missing files ─────────────────────
package/src/new-paper.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  import { join } from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import { CONFIG_FILE } from "#lib/paper-config";
29
+ import { isRecord } from "./domain/record.ts";
29
30
 
30
31
  export type PaperFormat = "tex" | "md";
31
32
  export const FORMATS: readonly PaperFormat[] = ["tex", "md"];
@@ -115,15 +116,18 @@ function withVenue(
115
116
  src: string,
116
117
  venue: VenueSetting,
117
118
  ): { ok: true; text: string } | { ok: false; reason: string } {
118
- let obj: Record<string, unknown>;
119
+ let obj: unknown;
119
120
  try {
120
- obj = JSON.parse(text) as Record<string, unknown>;
121
+ obj = JSON.parse(text);
121
122
  } catch {
123
+ obj = null;
124
+ }
125
+ // Not JSON, or JSON that is not an object: there is no field to write "extends" into.
126
+ if (!isRecord(obj))
122
127
  return {
123
128
  ok: false,
124
129
  reason: `--venue cannot be written into ${src}: it is not plain JSON — set "extends" by hand`,
125
130
  };
126
- }
127
131
  const out: Record<string, unknown> = { ...obj, extends: venue.extends };
128
132
  delete out["$comment"];
129
133
  if (venue.kind !== null) out["kind"] = venue.kind;
@@ -8,7 +8,7 @@
8
8
  * 74 errors — parse errors and "Definition for rule … was not found" — none of them on a paper
9
9
  * file, and the run failed. Nothing in the settings could turn that off.
10
10
  */
11
- import { ESLint, type Linter } from "eslint";
11
+ import { ESLint } from "eslint";
12
12
  import {
13
13
  mkdirSync,
14
14
  mkdtempSync,
@@ -19,7 +19,7 @@ import {
19
19
  import { tmpdir } from "node:os";
20
20
  import { join } from "node:path";
21
21
  import { afterEach, describe, expect, it } from "vitest";
22
- import { buildConfig, run } from "./cli.ts";
22
+ import { buildConfig, eslintConfig, run } from "./cli.ts";
23
23
  import {
24
24
  narrowToOwners,
25
25
  ownedPatterns,
@@ -28,6 +28,10 @@ import {
28
28
  scopeToOwned,
29
29
  } from "./paper-files.ts";
30
30
  import { lintReport } from "../test/lint-report.ts";
31
+ import { z } from "zod";
32
+
33
+ /** What `calculateConfigForFile` answers for a file inside the config's scope, read for its rules. */
34
+ const Computed = z.object({ rules: z.record(z.string(), z.unknown()) });
31
35
 
32
36
  const dirs: string[] = [];
33
37
  afterEach(() => {
@@ -232,25 +236,24 @@ describe("`rules` blocks — where the rule lands", () => {
232
236
  const eslint = new ESLint({
233
237
  cwd: root,
234
238
  overrideConfigFile: true,
235
- overrideConfig: buildConfig(
236
- {
237
- rules: [
238
- {
239
- basePath: root,
240
- files: ["papers/**"],
241
- rules: { "paper/source": "warn", "paper/section-word": "off" },
242
- },
243
- ],
244
- },
245
- null,
246
- ) as Linter.Config[],
239
+ overrideConfig: eslintConfig(
240
+ buildConfig(
241
+ {
242
+ rules: [
243
+ {
244
+ basePath: root,
245
+ files: ["papers/**"],
246
+ rules: { "paper/source": "warn", "paper/section-word": "off" },
247
+ },
248
+ ],
249
+ },
250
+ null,
251
+ ),
252
+ ),
247
253
  });
248
254
  const on = async (file: string) =>
249
- (
250
- (await eslint.calculateConfigForFile(join(root, file))) as {
251
- rules: Record<string, unknown>;
252
- }
253
- ).rules;
255
+ Computed.parse(await eslint.calculateConfigForFile(join(root, file)))
256
+ .rules;
254
257
  const status = await on("papers/a/PIPELINE-STATUS.md");
255
258
  expect(status["paper/source"]).toEqual([1]);
256
259
  expect(status["paper/section-word"]).toBeUndefined();
@@ -40,16 +40,7 @@
40
40
  * - later global-ignore patterns win, across blocks, in config order;
41
41
  * - `files: [["a", "b"]]` matches a file only when BOTH patterns match.
42
42
  */
43
- import type { RuleBlock, RuleEntry } from "./rules-config.ts";
44
-
45
- /** What this module reads of a config block. */
46
- interface Block {
47
- readonly files?: readonly unknown[];
48
- readonly ignores?: readonly unknown[];
49
- readonly plugins?: Readonly<
50
- Record<string, { readonly rules?: Readonly<Record<string, unknown>> }>
51
- >;
52
- }
43
+ import type { ConfigBlock, RuleBlock, RuleEntry } from "./rules-config.ts";
53
44
 
54
45
  /**
55
46
  * A set of files paperlint lints: an own block's globs and the exceptions that block makes. A file is
@@ -99,20 +90,18 @@ const merged = (scopes: readonly OwnedScope[]): OwnedScope[] => {
99
90
  * THE FILES PAPERLINT LINTS: every own block that names `files`, with its `ignores`. A block without
100
91
  * `files` (a global ignore, or the block registering `pdf` for every file) claims nothing.
101
92
  */
102
- export function ownedScopes(own: readonly unknown[]): OwnedScope[] {
93
+ export function ownedScopes(own: readonly ConfigBlock[]): OwnedScope[] {
103
94
  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
- ),
95
+ own.flatMap((b) =>
96
+ b.files === undefined
97
+ ? []
98
+ : [{ files: globsOf(b.files), ignores: stringsOf(b.ignores) }],
99
+ ),
111
100
  );
112
101
  }
113
102
 
114
103
  /** Every glob paperlint's own blocks lint, once, in config order. */
115
- export function ownedPatterns(own: readonly unknown[]): string[] {
104
+ export function ownedPatterns(own: readonly ConfigBlock[]): string[] {
116
105
  return [...new Set(ownedScopes(own).flatMap((s) => s.files))];
117
106
  }
118
107
 
@@ -134,7 +123,7 @@ export function scopeToOwned(owned: readonly string[]): {
134
123
  }
135
124
 
136
125
  /** Every `<plugin>/<rule>` a block registers. */
137
- const ruleIdsOf = (b: Block): string[] =>
126
+ const ruleIdsOf = (b: ConfigBlock): string[] =>
138
127
  Object.entries(b.plugins ?? {}).flatMap(([name, plugin]) =>
139
128
  Object.keys(plugin.rules ?? {}).map((rule) => `${name}/${rule}`),
140
129
  );
@@ -145,12 +134,11 @@ const ruleIdsOf = (b: Block): string[] =>
145
134
  * one file more.
146
135
  */
147
136
  export function ruleOwners(
148
- own: readonly unknown[],
137
+ own: readonly ConfigBlock[],
149
138
  ): Map<string, readonly OwnedScope[]> {
150
139
  const all = ownedScopes(own);
151
140
  const found = new Map<string, OwnedScope[]>();
152
- for (const raw of own) {
153
- const b = raw as Block;
141
+ for (const b of own) {
154
142
  const scopes =
155
143
  b.files === undefined
156
144
  ? all
@@ -171,17 +159,18 @@ export function narrowToOwners(
171
159
  block: RuleBlock,
172
160
  owners: ReadonlyMap<string, readonly OwnedScope[]>,
173
161
  ): ScopedBlock[] {
162
+ // Keyed by the owner's JSON, so rules with equal owners share one group of blocks.
174
163
  const groups = new Map<string, Record<string, RuleEntry>>();
164
+ const ownerOf = new Map<string, readonly OwnedScope[]>();
175
165
  for (const [id, entry] of Object.entries(block.rules)) {
176
166
  const owner = owners.get(id);
177
167
  if (owner === undefined) continue;
178
168
  const key = JSON.stringify(owner);
179
169
  groups.set(key, { ...groups.get(key), [id]: entry });
170
+ ownerOf.set(key, owner);
180
171
  }
181
- return [...groups].flatMap(([key, rules]) =>
182
- (JSON.parse(key) as OwnedScope[]).map((scope) =>
183
- within(block, scope, rules),
184
- ),
172
+ return [...ownerOf].flatMap(([key, owner]) =>
173
+ owner.map((scope) => within(block, scope, { ...groups.get(key) })),
185
174
  );
186
175
  }
187
176
 
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { describe, expect, it } from "vitest";
6
6
  import { memoryFiles } from "./adapters/memory/index.ts";
7
- import type { AbsolutePath } from "./domain/paths.ts";
7
+ import { absolutePath } from "./domain/paths.ts";
8
8
  import { findProjectRoot } from "../lib/paper-config.ts";
9
9
  import {
10
10
  paperRules,
@@ -215,7 +215,7 @@ describe("findProjectRoot — where the root paperlint.json is looked for", () =
215
215
  [`${PAPER}/paperlint.json`]: '{"kind":"research"}',
216
216
  [`${ROOT}/paperlint.json`]: '{"extends":"paperlint:aisec"}',
217
217
  });
218
- const isFile = (p: string) => files.isFile(p as AbsolutePath);
218
+ const isFile = (p: string) => files.isFile(absolutePath(p));
219
219
  expect(findProjectRoot(PAPER, isFile)).toBe(ROOT);
220
220
  // No root file anywhere: the package.json directory.
221
221
  expect(findProjectRoot(PAPER, (p) => p === `${ROOT}/package.json`)).toBe(
@@ -22,7 +22,7 @@ import {
22
22
  SETTINGS_KEYS,
23
23
  findProjectRoot,
24
24
  } from "#lib/paper-config";
25
- import type { AbsolutePath } from "./domain/paths.ts";
25
+ import { callerPath } from "./caller-path.ts";
26
26
  import { err, ok, type Result } from "./domain/result.ts";
27
27
  import type { Files } from "./ports/files.ts";
28
28
  import {
@@ -114,14 +114,12 @@ export function parsePaperSettings(
114
114
  });
115
115
  }
116
116
 
117
- const at = (p: string): AbsolutePath => p as AbsolutePath;
118
-
119
117
  /** A JSON file through `files`: undefined when absent, the problem when it does not parse. */
120
118
  function readJson(
121
119
  files: Files,
122
120
  path: string,
123
121
  ): Result<unknown, SettingsProblem> {
124
- const bytes = files.readBytes(at(path));
122
+ const bytes = files.readBytes(callerPath(path));
125
123
  if (bytes === null) return ok(undefined);
126
124
  try {
127
125
  return ok(JSON.parse(new TextDecoder().decode(bytes)));
@@ -139,7 +137,9 @@ function rootDefaults(
139
137
  files: Files,
140
138
  paperDir: string,
141
139
  ): Result<Defaults | null, SettingsProblem> {
142
- const root = findProjectRoot(dirname(paperDir), (p) => files.isFile(at(p)));
140
+ const root = findProjectRoot(dirname(paperDir), (p) =>
141
+ files.isFile(callerPath(p)),
142
+ );
143
143
  const json = readJson(files, join(root, CONFIG_FILE));
144
144
  if (!json.ok) return json;
145
145
  if (!isObject(json.value)) return ok(null);
@@ -40,11 +40,11 @@ function page(items: FakeItem[], fonts: Record<string, unknown>) {
40
40
  }),
41
41
  };
42
42
  }
43
- const doc = (pages: ReturnType<typeof page>[]) =>
44
- ({
45
- numPages: pages.length,
46
- getPage: (i: number) => Promise.resolve(pages[i - 1]),
47
- }) as never;
43
+ const doc = (pages: ReturnType<typeof page>[]) => ({
44
+ numPages: pages.length,
45
+ getPage: (i: number) =>
46
+ Promise.resolve(present(pages[i - 1], `page ${String(i)}`)),
47
+ });
48
48
  const item = (
49
49
  fontName: string,
50
50
  transform: number[],
@@ -141,3 +141,14 @@ test("a failure that is not an Error is named `Error` with its text", () => {
141
141
  detail: "Error: boom",
142
142
  });
143
143
  });
144
+
145
+ test("🔴 a rejection with no value at all is a failure too, not a TypeError out of `readPdf`", () => {
146
+ // Guards: `readPdf` ends in `.catch(failureOf)`, and reading `.name` off `null` threw from inside it.
147
+ assert.deepEqual(
148
+ [failureOf(null), failureOf(undefined)],
149
+ [
150
+ { ok: false, reason: "unreadable", detail: "Error: null" },
151
+ { ok: false, reason: "unreadable", detail: "Error: undefined" },
152
+ ],
153
+ );
154
+ });