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
package/src/cli.ts CHANGED
@@ -56,6 +56,7 @@ import {
56
56
  } from "./paper-settings.ts";
57
57
  import { referenceRules, REFERENCE_RULE_LEVELS } from "./reference-rules.ts";
58
58
  import { onlineReferences } from "./adapters/references/index.ts";
59
+ import type { CheckReferences } from "./ports/check-references.ts";
59
60
  import {
60
61
  narrowToOwners,
61
62
  ownedPatterns,
@@ -64,7 +65,7 @@ import {
64
65
  } from "./paper-files.ts";
65
66
  import {
66
67
  paperPreset,
67
- paperPresetProblem,
68
+ settingsProblemLine,
68
69
  presetProblemText,
69
70
  resolvePreset,
70
71
  shippedPresets,
@@ -80,7 +81,12 @@ import {
80
81
  type TexRequirements,
81
82
  } from "./tex-requirements.ts";
82
83
  import { doctor } from "./doctor.ts";
83
- import { init, processInteractivity, askOnTerminal } from "./init.ts";
84
+ import {
85
+ askOnTerminal,
86
+ askOrDefault,
87
+ init,
88
+ processInteractivity,
89
+ } from "./init.ts";
84
90
  import {
85
91
  DEFAULT_FORMAT,
86
92
  FORMATS,
@@ -467,8 +473,8 @@ export function paperRuleBlocks(
467
473
  const out = { preset: [] as RuleBlock[], own: [] as RuleBlock[] };
468
474
  for (const dir of papers) {
469
475
  const p = paperPreset(dir, PRESET_DEPS);
470
- if (p.kind === "settings-problem" && p.problem.kind === "broken")
471
- return { ok: false, error: paperPresetProblem(dir, p) ?? dir };
476
+ if (p.kind === "settings-problem")
477
+ return { ok: false, error: settingsProblemLine(dir, p.problem) };
472
478
  const blocks = rulesOfPaper(dir, p);
473
479
  if (!blocks.ok) return blocks;
474
480
  out.preset.push(...blocks.value.preset);
@@ -484,9 +490,9 @@ export function paperRuleBlocks(
484
490
  */
485
491
  function rulesOfPaper(
486
492
  dir: string,
487
- p: PaperPreset,
493
+ p: Exclude<PaperPreset, { kind: "settings-problem" }>,
488
494
  ): Parsed<{ preset: RuleBlock[]; own: RuleBlock[] }> {
489
- const settings = "settings" in p ? p.settings : null;
495
+ const { settings } = p;
490
496
  const fromPreset =
491
497
  p.kind === "resolved"
492
498
  ? parseRuleEntries(
@@ -577,6 +583,9 @@ export function parseSettings(
577
583
  const lintRoot = (home: string, paths: readonly string[]): string =>
578
584
  commonDir([home, ...paths]);
579
585
 
586
+ /** A path as the reader typed it: relative to where the command runs, or whole when it IS there. */
587
+ const shown = (cwd: string, p: string): string => relative(cwd, p) || p;
588
+
580
589
  /** The longest shared leading run of path segments. */
581
590
  const commonDir = (paths: readonly string[]): string => {
582
591
  const [first = [], ...rest] = paths.map((p) => p.split(sep));
@@ -609,24 +618,27 @@ export function parseArgs(argv: readonly string[]): Args {
609
618
  // gate that fails on advice gets muted entirely.
610
619
  maxWarnings: -1,
611
620
  };
621
+ // Consumed from the front: a value flag takes the next argument by shifting it off.
612
622
  const rest = [...argv];
613
- if (rest[0] && !rest[0].startsWith("-")) out.cmd = rest.shift() ?? null;
623
+ const first = rest[0];
624
+ if (first && !first.startsWith("-")) {
625
+ out.cmd = first;
626
+ rest.shift();
627
+ }
614
628
 
615
629
  // 🔴 A FLAG WHOSE VALUE WAS TAKEN AWAY IS A REFUSAL, NOT A DEFAULT. The compiler found this
616
- // during the move to TypeScript: `rest[++i]` past the last argument gives `undefined`, and
630
+ // during the move to TypeScript: the value past the last argument is `undefined`, and
617
631
  // `paperlint lint --config` (the value forgotten, or eaten by a substitution in CI) silently turned
618
632
  // into "no config given" — that is, it went to auto-discovery and linted against SOMEONE ELSE'S
619
633
  // file, saying nothing. The failure is one-sided and toward silence, so it is cured by
620
634
  // behaviour, not by a type cast.
621
- const valueFor = (flag: string, i: number): string | undefined => {
622
- const v = rest[i];
635
+ const valueFor = (flag: string): string | undefined => {
636
+ const v = rest.shift();
623
637
  if (v === undefined) out.missingValue = flag;
624
638
  return v;
625
639
  };
626
640
 
627
- for (let i = 0; i < rest.length; i++) {
628
- const arg = rest[i];
629
- if (arg === undefined) continue;
641
+ for (let arg = rest.shift(); arg !== undefined; arg = rest.shift()) {
630
642
  // `--flag=value` is `--flag value` for every flag that takes a value: the CI action writes
631
643
  // `--max-warnings="$N"`, and that form used to fall through to the list of paths.
632
644
  const eq = arg.indexOf("=");
@@ -636,7 +648,7 @@ export function parseArgs(argv: readonly string[]): Args {
636
648
  : undefined;
637
649
  const a = inline === undefined ? arg : arg.slice(0, eq);
638
650
  const take = (): string | undefined => {
639
- if (inline === undefined) return valueFor(a, ++i);
651
+ if (inline === undefined) return valueFor(a);
640
652
  if (inline === "") out.missingValue = a;
641
653
  return inline === "" ? undefined : inline;
642
654
  };
@@ -684,11 +696,12 @@ const VALUE_FLAGS: ReadonlySet<string> = new Set([
684
696
  * the CI action to its release tag; an unreadable manifest yields `undefined`, and init then keeps
685
697
  * the placeholder instead of guessing.
686
698
  */
687
- function ownVersion(): string | undefined {
699
+ export function ownVersion(
700
+ readManifest: () => string = () =>
701
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
702
+ ): string | undefined {
688
703
  try {
689
- const v = JSON.parse(
690
- readFileSync(new URL("../package.json", import.meta.url), "utf8"),
691
- )?.version;
704
+ const v = JSON.parse(readManifest())?.version;
692
705
  return typeof v === "string" ? v : undefined;
693
706
  } catch {
694
707
  return undefined;
@@ -742,12 +755,9 @@ export function readConfig(
742
755
  //
743
756
  // 🔴 IN `--json` MODE — TO stderr. Machine output must be ONE parsable document: a line before
744
757
  // the array breaks any `| jq`, and it breaks it for the consumer, not for us.
745
- (a.json ? err : log)(`config: ${relative(cwd, configPath) || CONFIG_FILE}`);
746
- const settings = parseSettings(
747
- parsed,
748
- relative(cwd, configPath) || CONFIG_FILE,
749
- root,
750
- );
758
+ const shownPath = shown(cwd, configPath);
759
+ (a.json ? err : log)(`config: ${shownPath}`);
760
+ const settings = parseSettings(parsed, shownPath, root);
751
761
  if (!settings.ok) {
752
762
  err(settings.error);
753
763
  return { code: 2 };
@@ -885,7 +895,7 @@ export async function createPaperAt(
885
895
  },
886
896
  ): Promise<number> {
887
897
  const result = newPaper(papersRoot, name, format, { venue });
888
- const here = (p: string): string => relative(cwd, p) || p;
898
+ const here = (p: string): string => shown(cwd, p);
889
899
  for (const line of reportNewPaper(result, here)) log(line);
890
900
  if (!result.ok) return 2;
891
901
  const config = here(join(result.dir, CONFIG_FILE));
@@ -939,7 +949,7 @@ export async function chooseVenue(
939
949
  `--kind needs --venue: a kind is a page limit of one venue preset — \`paperlint new <name> --venue <preset> --kind ${flags.kind}\``,
940
950
  );
941
951
  const asked = async (q: string): Promise<string> =>
942
- (await ask(q).catch(() => "")).trim();
952
+ (await askOrDefault(ask, q))?.trim() ?? "";
943
953
  const venue =
944
954
  flags.venue ??
945
955
  (interactive
@@ -1047,10 +1057,10 @@ async function runNew(
1047
1057
  }
1048
1058
  format = a.format;
1049
1059
  } else if (processInteractivity(a.yes).interactive) {
1050
- const f = await ask(`format: tex / md [${DEFAULT_FORMAT}] `).catch(
1051
- () => "",
1052
- );
1053
- if (isFormat(f.trim())) format = f.trim() as PaperFormat;
1060
+ const f = (
1061
+ await askOrDefault(ask, `format: tex / md [${DEFAULT_FORMAT}] `)
1062
+ )?.trim();
1063
+ if (isFormat(f)) format = f;
1054
1064
  }
1055
1065
  const cfg = readConfig({ ...a, json: false }, { log: () => {}, err, cwd });
1056
1066
  if (cfg.code !== undefined) return cfg.code;
@@ -1064,7 +1074,7 @@ async function runNew(
1064
1074
  }
1065
1075
  if (roots.length > 1)
1066
1076
  log(
1067
- `several papers directories are declared — using the first: ${relative(cwd, papersRoot) || papersRoot}`,
1077
+ `several papers directories are declared — using the first: ${shown(cwd, papersRoot)}`,
1068
1078
  );
1069
1079
  const paperDir = join(papersRoot, name);
1070
1080
  // An existing paperlint.json is never overwritten, so a venue for it is refused, not dropped.
@@ -1105,7 +1115,13 @@ async function runBuild(
1105
1115
  log,
1106
1116
  err,
1107
1117
  cwd,
1108
- }: { log: typeof console.log; err: typeof console.error; cwd: string },
1118
+ checkReferences,
1119
+ }: {
1120
+ log: typeof console.log;
1121
+ err: typeof console.error;
1122
+ cwd: string;
1123
+ checkReferences: CheckReferences;
1124
+ },
1109
1125
  ): Promise<number> {
1110
1126
  const cfg = readConfig(a, { log, err, cwd });
1111
1127
  if (cfg.code !== undefined) return cfg.code;
@@ -1137,7 +1153,7 @@ async function runBuild(
1137
1153
  cwd,
1138
1154
  dryRun: a.dryRun,
1139
1155
  log,
1140
- checkReferences: onlineReferences,
1156
+ checkReferences,
1141
1157
  engine: () => engineEnv(targets, a, { log, err }),
1142
1158
  });
1143
1159
  if (out.kind === "no-engine") return 1;
@@ -1209,7 +1225,12 @@ const SIMPLE: Readonly<
1209
1225
  string,
1210
1226
  (
1211
1227
  a: Args,
1212
- io: { log: typeof console.log; err: typeof console.error; cwd: string },
1228
+ io: {
1229
+ log: typeof console.log;
1230
+ err: typeof console.error;
1231
+ cwd: string;
1232
+ checkReferences: CheckReferences;
1233
+ },
1213
1234
  ) => number | Promise<number>
1214
1235
  >
1215
1236
  > = {
@@ -1238,6 +1259,44 @@ function hostBanalInstaller(): ToolInstaller {
1238
1259
  return banalInstaller({ ...ports, download }, s);
1239
1260
  }
1240
1261
 
1262
+ /**
1263
+ * init's TeX Live step: whether paperlint's own tree is installed, and how to install it —
1264
+ * `runToolchain` over what this project's papers need. `toolchain` is the seam a test replaces:
1265
+ * the real one downloads ~270 MB.
1266
+ */
1267
+ export function initTexLive(
1268
+ a: Args,
1269
+ {
1270
+ log,
1271
+ err,
1272
+ cwd,
1273
+ }: { log: typeof console.log; err: typeof console.error; cwd: string },
1274
+ toolchain: typeof runToolchain = runToolchain,
1275
+ ): { readonly installed: () => boolean; readonly install: () => number } {
1276
+ return {
1277
+ installed: () => cachedTree(cacheRoot(process.env)) !== null,
1278
+ install: () =>
1279
+ toolchain({
1280
+ check: false,
1281
+ log,
1282
+ err,
1283
+ banal: hostBanalInstaller(),
1284
+ tex: toolchainTex(resolve(cwd, a.paths[0] ?? ".")),
1285
+ }),
1286
+ };
1287
+ }
1288
+
1289
+ /**
1290
+ * The first papers directory the CLI would lint from `cwd`, or null when the config does not read
1291
+ * or names none — what `doctor` (and `init`, through it) compares the hooks' directory against.
1292
+ */
1293
+ function cliPapers(a: Args, cwd: string): string | null {
1294
+ const read = readConfig(a, { log: () => {}, err: () => {}, cwd });
1295
+ return read.code === undefined
1296
+ ? (toPaths(papersDirOf(read.opts))[0] ?? null)
1297
+ : null;
1298
+ }
1299
+
1241
1300
  /** `paperlint init`: its flags checked here, the install itself in `init.ts`. */
1242
1301
  async function runInit(
1243
1302
  a: Args,
@@ -1270,26 +1329,9 @@ async function runInit(
1270
1329
  format: isFormat(a.format) ? a.format : null,
1271
1330
  createPaper: (papersRoot, name, format) =>
1272
1331
  createPaperAt(papersRoot, name, format, { log, err, cwd }),
1273
- tex: {
1274
- installed: () => cachedTree(cacheRoot(process.env)) !== null,
1275
- install: () =>
1276
- runToolchain({
1277
- check: false,
1278
- log,
1279
- err,
1280
- banal: hostBanalInstaller(),
1281
- tex: toolchainTex(resolve(cwd, a.paths[0] ?? ".")),
1282
- }),
1283
- },
1284
- resolveCliPapers: (root: string): string | null => {
1285
- const read = readConfig(
1286
- { ...a, config: null },
1287
- { log: () => {}, err: () => {}, cwd: root },
1288
- );
1289
- return read.code === undefined
1290
- ? (toPaths(papersDirOf(read.opts))[0] ?? null)
1291
- : null;
1292
- },
1332
+ tex: initTexLive(a, { log, err, cwd }),
1333
+ resolveCliPapers: (root: string): string | null =>
1334
+ cliPapers({ ...a, config: null }, root),
1293
1335
  });
1294
1336
  }
1295
1337
 
@@ -1299,10 +1341,16 @@ export async function run(
1299
1341
  log = console.log,
1300
1342
  err = console.error,
1301
1343
  cwd = process.cwd(),
1344
+ checkReferences = onlineReferences,
1302
1345
  }: {
1303
1346
  log?: typeof console.log;
1304
1347
  err?: typeof console.error;
1305
1348
  cwd?: string;
1349
+ /**
1350
+ * `build`'s online reference check — the real services by default. The e2e build passes a
1351
+ * fake: no test depends on Crossref, Semantic Scholar or DBLP answering.
1352
+ */
1353
+ checkReferences?: CheckReferences;
1306
1354
  } = {},
1307
1355
  ): Promise<number> {
1308
1356
  const a = parseArgs(argv);
@@ -1335,11 +1383,7 @@ export async function run(
1335
1383
  // broken is precisely its job. So a failed read becomes "the CLI would lint nothing", which is
1336
1384
  // what it prints, rather than an early exit that tells the reader nothing about the hooks.
1337
1385
  if (a.cmd === "doctor") {
1338
- const read = readConfig(a, { log: () => {}, err: () => {}, cwd });
1339
- const papers =
1340
- read.code === undefined
1341
- ? (toPaths(papersDirOf(read.opts))[0] ?? null)
1342
- : null;
1386
+ const papers = cliPapers(a, cwd);
1343
1387
  return doctor({
1344
1388
  log,
1345
1389
  cwd,
@@ -1348,7 +1392,7 @@ export async function run(
1348
1392
  });
1349
1393
  }
1350
1394
  const simple = SIMPLE[a.cmd];
1351
- if (simple) return await simple(a, { log, err, cwd });
1395
+ if (simple) return await simple(a, { log, err, cwd, checkReferences });
1352
1396
  if (a.cmd === "check")
1353
1397
  err(
1354
1398
  `\`check\` is now \`lint\` — running it anyway. Update the call to \`paperlint lint\`.`,
@@ -1404,13 +1448,15 @@ export async function run(
1404
1448
  rules: [...papers.value.preset, ...(opts.rules ?? []), ...papers.value.own],
1405
1449
  };
1406
1450
 
1451
+ // Loaded lazily because only `lint` needs it. If the module cannot be loaded (a broken install:
1452
+ // the file missing from the package), lint still runs over the markdown papers rather than
1453
+ // failing outright — the LaTeX rules simply have no language to run in.
1407
1454
  let texLanguage: unknown = null;
1408
1455
  try {
1409
- // @ts-expect-error — the module is .mjs and has no types; a missing LaTeX parser is a normal
1410
- // case here, it is caught by the catch below.
1456
+ // @ts-expect-error — the module is .mjs and has no types
1411
1457
  ({ texLanguage } = await import("../eslint-rules/latex-language.mjs"));
1412
1458
  } catch {
1413
- /* without a LaTeX parser we work over markdown */
1459
+ texLanguage = null;
1414
1460
  }
1415
1461
 
1416
1462
  const eslint = new ESLint({
@@ -1423,7 +1469,7 @@ export async function run(
1423
1469
  const unowned = await firstUnownedFile(eslint, paths);
1424
1470
  if (unowned !== null) {
1425
1471
  err(
1426
- `${relative(cwd, unowned) || unowned} is not a file paperlint lints — it lints ${PAPER_FILE_PATTERNS.join(", ")}`,
1472
+ `${shown(cwd, unowned)} is not a file paperlint lints — it lints ${PAPER_FILE_PATTERNS.join(", ")}`,
1427
1473
  );
1428
1474
  return 2;
1429
1475
  }
@@ -1450,7 +1496,7 @@ export async function run(
1450
1496
  // is not invoked — and, not being invoked, it physically cannot report that.
1451
1497
  if (results.length === 0) {
1452
1498
  err(
1453
- `nothing was linted under ${paths.map((x) => relative(cwd, x) || x).join(", ")} — no PIPELINE-STATUS.md, paper.md/tex or reviews/ found there. A clean report over zero files is not a clean report.`,
1499
+ `nothing was linted under ${paths.map((x) => shown(cwd, x)).join(", ")} — no PIPELINE-STATUS.md, paper.md/tex or reviews/ found there. A clean report over zero files is not a clean report.`,
1454
1500
  );
1455
1501
  return 1;
1456
1502
  }
@@ -1499,7 +1545,7 @@ const isEmptySet = (e: unknown): boolean => {
1499
1545
  return (
1500
1546
  fail?.messageTemplate === "file-not-found" ||
1501
1547
  fail?.messageTemplate === "all-matched-files-ignored" ||
1502
- /No files matching/i.test(fail?.message ?? "")
1548
+ /No files matching/i.test(String(fail?.message))
1503
1549
  );
1504
1550
  };
1505
1551
 
@@ -9,9 +9,7 @@
9
9
  * this file can tell the two apart.
10
10
  *
11
11
  * Run: npx vigiles test src/doctor.harness.mjs
12
- * Killed by: src/doctor.mutations.mjs
13
12
  */
14
- import assert from "node:assert/strict";
15
13
  import { PAPERS_DIR_FIELD } from "../lib/paper-config.mjs";
16
14
  import {
17
15
  mkdtempSync,
@@ -23,6 +21,7 @@ import {
23
21
  import { tmpdir } from "node:os";
24
22
  import { dirname, join } from "node:path";
25
23
  import { fileURLToPath } from "node:url";
24
+ import { createChecker } from "../lib/check.mjs";
26
25
 
27
26
  const HERE = dirname(fileURLToPath(import.meta.url));
28
27
  const { doctor, detectPapers, PROGRAMS, found } = await import(
@@ -32,11 +31,7 @@ const { papersRoot } = await import(
32
31
  join(HERE, "..", "hooks", "paper-edit-guard.hook.mjs")
33
32
  );
34
33
 
35
- let n = 0;
36
- const check = (label, cond) => {
37
- n++;
38
- assert.ok(cond, label);
39
- };
34
+ const check = createChecker();
40
35
 
41
36
  /** A consumer on disk: a papers directory, and maybe a declaration in the root paperlint.json. */
42
37
  function consumer({ papersDir, declared, makeDir = true }) {
@@ -333,5 +328,5 @@ const runDoctor = (
333
328
  }
334
329
 
335
330
  console.log(
336
- `✓ ${n} assertions passed — paperlint doctor: an install can vouch for itself`,
331
+ `✓ ${check.count} assertions passed — paperlint doctor: an install can vouch for itself`,
337
332
  );
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `doctor.ts` — the edges the harness does not stage: an unreadable directory during paper
3
+ * discovery, a settings file that names no papers directory, an occupied skill entry with no
4
+ * reason, and a hooks section that cannot be read at all.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import {
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ readdirSync,
11
+ realpathSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { test } from "vitest";
17
+ import { detectPapers, doctor } from "./doctor.ts";
18
+
19
+ const project = (): string =>
20
+ realpathSync(mkdtempSync(join(tmpdir(), "doctor-")));
21
+
22
+ test("detectPapers: a directory that does not exist has no papers", () => {
23
+ assert.deepEqual(detectPapers(join(project(), "gone")), []);
24
+ });
25
+
26
+ test("detectPapers: a child that cannot be listed is skipped, its siblings are still found", () => {
27
+ const root = project();
28
+ mkdirSync(join(root, "locked"));
29
+ mkdirSync(join(root, "papers", "p1"), { recursive: true });
30
+ writeFileSync(join(root, "papers", "p1", "paper.tex"), "");
31
+ const readdir = (dir: string) => {
32
+ if (dir === join(root, "locked")) throw new Error("EACCES");
33
+ return readdirSync(dir, { withFileTypes: true });
34
+ };
35
+ assert.deepEqual(detectPapers(root, 2, readdir), ["papers"]);
36
+ });
37
+
38
+ test("doctor: a settings file silent on papersDir, an occupied entry, an unreadable hooks file", () => {
39
+ const root = project();
40
+ writeFileSync(join(root, "paperlint.json"), "{}\n");
41
+ // `.claude/settings.json` as a DIRECTORY: it exists, and reading it throws EISDIR.
42
+ mkdirSync(join(root, ".claude", "settings.json"), { recursive: true });
43
+ const lines: string[] = [];
44
+ const code = doctor({
45
+ cwd: root,
46
+ log: (s: string) => lines.push(s),
47
+ run: (() => ({ status: 0 })) as never,
48
+ cliPapers: join(root, "papers"),
49
+ skillLinks: () => ({
50
+ ok: true,
51
+ home: join(root, ".claude", "skills"),
52
+ example: null,
53
+ links: [{ name: "paper-pipeline", status: "foreign" }],
54
+ }),
55
+ });
56
+ const out = lines.join("\n");
57
+ assert.match(
58
+ out,
59
+ /✓ paperlint\.json names no papersDir — the default "papers"/,
60
+ );
61
+ assert.match(out, /paper-pipeline — occupied, not the shipped skill/);
62
+ assert.match(out, /\nhooks\n {2}⚠ not checked — EISDIR/);
63
+ assert.equal(typeof code, "number");
64
+ });
package/src/doctor.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  * Prior art for the shape: `brew doctor`, `flutter doctor`, `npm doctor`, `expo-doctor`.
22
22
  */
23
23
  // eslint-disable-next-line boundaries/dependencies -- legacy I/O, moves behind a port in #76
24
- import { existsSync, readFileSync, readdirSync } from "node:fs";
24
+ import { type Dirent, existsSync, readFileSync, readdirSync } from "node:fs";
25
25
  import { join, relative, resolve } from "node:path";
26
26
  // eslint-disable-next-line boundaries/dependencies -- legacy I/O, moves behind a port in #76
27
27
  import { spawnSync } from "node:child_process";
@@ -101,14 +101,22 @@ export const PROGRAMS: readonly Program[] = [
101
101
  export const found = (bin: string, run = spawnSync): boolean =>
102
102
  run("command", ["-v", bin], { shell: true, stdio: "ignore" }).status === 0;
103
103
 
104
+ /** Lists a directory's entries; the disk by default, a test stages an unreadable child. */
105
+ type ReadDir = (dir: string) => Dirent[];
106
+ const readDir: ReadDir = (dir) => readdirSync(dir, { withFileTypes: true });
107
+
104
108
  /** Directories that look like papers, used to REPLACE a guessed default with a measurement. */
105
- export function detectPapers(cwd: string, depth = 2): string[] {
109
+ export function detectPapers(
110
+ cwd: string,
111
+ depth = 2,
112
+ readdir: ReadDir = readDir,
113
+ ): string[] {
106
114
  const hits: string[] = [];
107
115
  const skip = new Set(["node_modules", ".git", "dist", "_build", ".claude"]);
108
116
  const walk = (dir: string, left: number): void => {
109
117
  let entries;
110
118
  try {
111
- entries = readdirSync(dir, { withFileTypes: true });
119
+ entries = readdir(dir);
112
120
  } catch {
113
121
  return;
114
122
  }
@@ -119,7 +127,7 @@ export function detectPapers(cwd: string, depth = 2): string[] {
119
127
  // A directory whose CHILDREN look like papers is the papers root — not the paper itself.
120
128
  let children;
121
129
  try {
122
- children = readdirSync(here, { withFileTypes: true });
130
+ children = readdir(here);
123
131
  } catch {
124
132
  continue;
125
133
  }
@@ -10,6 +10,10 @@ test("map and andThen run on ok and pass an error through untouched", () => {
10
10
  map(ok(2), (n) => n + 1),
11
11
  ok(3),
12
12
  );
13
+ assert.deepEqual(
14
+ map(half(3), () => assert.fail("mapped after an error")),
15
+ err("3 is odd"),
16
+ );
13
17
  assert.deepEqual(andThen(ok(8), half), ok(4));
14
18
  // Guards: an error short-circuits — the function after it is not called.
15
19
  assert.deepEqual(
@@ -22,8 +22,3 @@ export const match = <T, E, R>(
22
22
  r: Result<T, E>,
23
23
  on: { readonly ok: (t: T) => R; readonly err: (e: E) => R },
24
24
  ): R => (r.ok ? on.ok(r.value) : on.err(r.error));
25
-
26
- /** The `default` of an exhaustive `switch`: a new variant is a compile error, not a silent fall-through. */
27
- export function assertNever(x: never): never {
28
- throw new Error(`unhandled variant: ${JSON.stringify(x)}`);
29
- }
@@ -7,7 +7,6 @@
7
7
  * `probeTree` through a fake runner.
8
8
  * All pure or port-driven: no TeX needed. The real-TeX half is `test/e2e/build.mjs`.
9
9
  */
10
- import assert from "node:assert/strict";
11
10
  import {
12
11
  chmodSync,
13
12
  mkdirSync,
@@ -19,6 +18,7 @@ import {
19
18
  import { tmpdir } from "node:os";
20
19
  import { dirname, join } from "node:path";
21
20
  import { fileURLToPath } from "node:url";
21
+ import { createChecker } from "../lib/check.mjs";
22
22
 
23
23
  const HERE = dirname(fileURLToPath(import.meta.url));
24
24
  const {
@@ -32,11 +32,7 @@ const {
32
32
  supportedPlatform,
33
33
  } = await import(join(HERE, "engine.ts"));
34
34
 
35
- let n = 0;
36
- const check = (label, cond, detail = "") => {
37
- assert.ok(cond, `${label}${detail ? ` — ${detail}` : ""}`);
38
- n++;
39
- };
35
+ const check = createChecker();
40
36
 
41
37
  // ── 1. resolveEngine ────────────────────────────────────────────────────────────────────
42
38
  const tree = (label, missing = []) => ({ label, bin: `/${label}`, missing });
@@ -301,4 +297,28 @@ check(
301
297
  ) === "[]",
302
298
  );
303
299
 
304
- console.log(`engine: ${n} checks passed`);
300
+ check(
301
+ "missingDependencies: a tlmgr that cannot start reports nothing (the tree is judged by probeTree alone)",
302
+ JSON.stringify(
303
+ missingDependencies("/tl/bin", () => ({ error: new Error("ENOENT") })),
304
+ ) === "[]",
305
+ );
306
+ check(
307
+ "missingDependencies: a section that runs to the end of the output, with no header after it",
308
+ JSON.stringify(
309
+ missingDependencies("/tl/bin", () => ({
310
+ stdout:
311
+ "\f DEPENDS WITHOUT PACKAGES:\nunicode-data in: latex-bin\nhyphen-base in: x",
312
+ status: 2,
313
+ })),
314
+ ) === '["hyphen-base","unicode-data"]',
315
+ );
316
+ check(
317
+ "a runner that returns no stdout at all: tlmgr lists nothing, kpsewhich finds nothing",
318
+ JSON.stringify(missingDependencies("/tl/bin", () => ({ status: 0 }))) ===
319
+ "[]" &&
320
+ JSON.stringify(probeTree("/tl/bin", PKGS, () => ({ status: 0 }))) ===
321
+ JSON.stringify(Object.keys(PKGS).sort()),
322
+ );
323
+
324
+ console.log(`engine: ${check.count} checks passed`);