paperlint 2.1.0 → 3.1.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 (219) hide show
  1. package/CLAUDE.md +6 -1
  2. package/README.md +243 -235
  3. package/action.yml +4 -6
  4. package/dist/adapters/references/index.d.ts +13 -0
  5. package/dist/adapters/references/index.d.ts.map +1 -0
  6. package/dist/adapters/references/index.js +55 -0
  7. package/dist/adapters/references/index.js.map +1 -0
  8. package/dist/adapters/references/reach.io.d.ts +3 -0
  9. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  10. package/dist/adapters/references/reach.io.js +14 -0
  11. package/dist/adapters/references/reach.io.js.map +1 -0
  12. package/dist/build-engine.d.ts.map +1 -1
  13. package/dist/build-engine.js +6 -2
  14. package/dist/build-engine.js.map +1 -1
  15. package/dist/build.d.ts +15 -0
  16. package/dist/build.d.ts.map +1 -1
  17. package/dist/build.js +60 -7
  18. package/dist/build.js.map +1 -1
  19. package/dist/cli.d.ts +94 -30
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +500 -288
  22. package/dist/cli.js.map +1 -1
  23. package/dist/doctor.d.ts.map +1 -1
  24. package/dist/doctor.js +40 -72
  25. package/dist/doctor.js.map +1 -1
  26. package/dist/engine.d.ts +9 -0
  27. package/dist/engine.d.ts.map +1 -1
  28. package/dist/engine.js +27 -0
  29. package/dist/engine.js.map +1 -1
  30. package/dist/facts-file.d.ts +2 -2
  31. package/dist/facts-file.js +2 -2
  32. package/dist/hooks-settings.d.ts +0 -21
  33. package/dist/hooks-settings.d.ts.map +1 -1
  34. package/dist/hooks-settings.js +17 -135
  35. package/dist/hooks-settings.js.map +1 -1
  36. package/dist/init.d.ts +39 -29
  37. package/dist/init.d.ts.map +1 -1
  38. package/dist/init.js +144 -173
  39. package/dist/init.js.map +1 -1
  40. package/dist/link-skills.d.ts +1 -1
  41. package/dist/link-skills.d.ts.map +1 -1
  42. package/dist/link-skills.js +11 -29
  43. package/dist/link-skills.js.map +1 -1
  44. package/dist/new-paper.d.ts +8 -1
  45. package/dist/new-paper.d.ts.map +1 -1
  46. package/dist/new-paper.js +49 -25
  47. package/dist/new-paper.js.map +1 -1
  48. package/dist/paper-files.d.ts +85 -0
  49. package/dist/paper-files.d.ts.map +1 -0
  50. package/dist/paper-files.js +96 -0
  51. package/dist/paper-files.js.map +1 -0
  52. package/dist/paper-settings.d.ts +13 -41
  53. package/dist/paper-settings.d.ts.map +1 -1
  54. package/dist/paper-settings.js +71 -71
  55. package/dist/paper-settings.js.map +1 -1
  56. package/dist/ports/check-references.d.ts +31 -0
  57. package/dist/ports/check-references.d.ts.map +1 -0
  58. package/dist/ports/check-references.js +7 -0
  59. package/dist/ports/check-references.js.map +1 -0
  60. package/dist/presets.d.ts +1 -1
  61. package/dist/presets.d.ts.map +1 -1
  62. package/dist/presets.js +4 -6
  63. package/dist/presets.js.map +1 -1
  64. package/dist/reference-rules.d.ts +47 -0
  65. package/dist/reference-rules.d.ts.map +1 -0
  66. package/dist/reference-rules.js +156 -0
  67. package/dist/reference-rules.js.map +1 -0
  68. package/dist/references.d.ts +36 -0
  69. package/dist/references.d.ts.map +1 -0
  70. package/dist/references.js +91 -0
  71. package/dist/references.js.map +1 -0
  72. package/dist/rules-config.d.ts +5 -3
  73. package/dist/rules-config.d.ts.map +1 -1
  74. package/dist/rules-config.js +15 -6
  75. package/dist/rules-config.js.map +1 -1
  76. package/dist/structure.js +3 -3
  77. package/dist/structure.js.map +1 -1
  78. package/dist/tex-requirements.js +2 -2
  79. package/dist/tex-requirements.js.map +1 -1
  80. package/dist/toolchain.d.ts +5 -1
  81. package/dist/toolchain.d.ts.map +1 -1
  82. package/dist/toolchain.js +22 -5
  83. package/dist/toolchain.js.map +1 -1
  84. package/dist/types.d.ts +26 -18
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/venue-rules.d.ts.map +1 -1
  87. package/dist/venue-rules.js +12 -10
  88. package/dist/venue-rules.js.map +1 -1
  89. package/docs/configuration.md +163 -104
  90. package/docs/e2e.md +1 -1
  91. package/docs/install.md +25 -40
  92. package/docs/optional-rules.md +5 -4
  93. package/docs/rules.md +65 -24
  94. package/docs/skills.md +80 -0
  95. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  96. package/eslint-rules/latex-language.mjs +29 -1
  97. package/eslint-rules/paper-stages.harness.mjs +1 -117
  98. package/eslint-rules/paper-stages.mjs +0 -120
  99. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  100. package/eslint-rules/paper-typography.mjs +306 -191
  101. package/eslint-rules/paper-typography.test.mjs +337 -0
  102. package/eslint-rules/papers.harness.mjs +8 -12
  103. package/eslint-rules/papers.mjs +17 -31
  104. package/eslint-rules/pdf-last-page-balance.mjs +4 -7
  105. package/eslint-rules/review-frontmatter.mjs +118 -0
  106. package/eslint-rules/review-frontmatter.schema.json +36 -0
  107. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  108. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  109. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  110. package/eslint.config.mjs +0 -15
  111. package/fixtures/real-markdown-paper/baseline.json +6 -7
  112. package/fixtures/toolchain-mirror/catalog.txt +1 -0
  113. package/fixtures/toolchain-mirror/install-tl +4 -1
  114. package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
  115. package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
  116. package/hooks/hooks.harness.mjs +39 -98
  117. package/hooks/hooks.mutations.mjs +6 -6
  118. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  119. package/hooks/paper-edit-guard.hook.mjs +31 -42
  120. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  121. package/hooks/paper-status-gates.hook.mjs +21 -19
  122. package/hooks/paper-status-gates.sh +8 -13
  123. package/lib/paper-config.d.mts +15 -25
  124. package/lib/paper-config.harness.mjs +18 -35
  125. package/lib/paper-config.mjs +83 -105
  126. package/lib/paper-config.mutations.mjs +2 -2
  127. package/lib/skill-checks.mjs +1 -1
  128. package/lib/skill-trigger-cases.harness.mjs +9 -8
  129. package/lib/skill-trigger-cases.mjs +7 -15
  130. package/package.json +1 -1
  131. package/scripts/eslint-report-guard.mjs +1 -1
  132. package/scripts/harness-api.frozen.json +0 -3
  133. package/scripts/mutation-batteries.frozen.json +5 -14
  134. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  135. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  136. package/skills/cold-read-diff/SKILL.md +20 -1
  137. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  138. package/skills/grade-paper-writing/SKILL.md +20 -1
  139. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  140. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  141. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  142. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  144. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  145. package/skills/plan-paper-timeline/SKILL.md +2 -2
  146. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  147. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  148. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  149. package/skills/render-paper/check-render.sh +2 -3
  150. package/skills/render-paper/render-paper.harness.mjs +2 -2
  151. package/skills/verify-citations/SKILL.md +10 -1
  152. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  153. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  154. package/src/adapters/references/index.ts +102 -0
  155. package/src/adapters/references/reach.io.ts +12 -0
  156. package/src/build-engine.ts +6 -1
  157. package/src/build.harness.mjs +54 -6
  158. package/src/build.ts +89 -11
  159. package/src/cli.harness.mjs +149 -294
  160. package/src/cli.mutations.mjs +35 -44
  161. package/src/cli.ts +637 -328
  162. package/src/doctor.harness.mjs +41 -84
  163. package/src/doctor.mutations.mjs +9 -9
  164. package/src/doctor.ts +44 -89
  165. package/src/engine.harness.mjs +50 -0
  166. package/src/engine.ts +35 -0
  167. package/src/facts-file.test.ts +0 -7
  168. package/src/facts-file.ts +2 -2
  169. package/src/hooks-settings.harness.mjs +7 -92
  170. package/src/hooks-settings.mutations.mjs +2 -17
  171. package/src/hooks-settings.ts +18 -162
  172. package/src/init.test.ts +206 -0
  173. package/src/init.ts +187 -231
  174. package/src/link-skills.harness.mjs +1 -38
  175. package/src/link-skills.mutations.mjs +9 -5
  176. package/src/link-skills.ts +10 -34
  177. package/src/lint-fix.test.ts +120 -0
  178. package/src/linted-set.test.ts +118 -0
  179. package/src/new-paper.harness.mjs +2 -0
  180. package/src/new-paper.mutations.mjs +0 -14
  181. package/src/new-paper.test.ts +173 -14
  182. package/src/new-paper.ts +82 -30
  183. package/src/paper-files.test.ts +395 -0
  184. package/src/paper-files.ts +200 -0
  185. package/src/paper-settings-commands.test.ts +121 -122
  186. package/src/paper-settings.test.ts +93 -64
  187. package/src/paper-settings.ts +99 -97
  188. package/src/parse-args.test.ts +48 -0
  189. package/src/ports/check-references.ts +28 -0
  190. package/src/presets.ts +5 -15
  191. package/src/reference-rules.ts +229 -0
  192. package/src/references.test.ts +209 -0
  193. package/src/references.ts +138 -0
  194. package/src/rules-config.harness.mjs +24 -8
  195. package/src/rules-config.ts +19 -7
  196. package/src/rules-off.test.ts +111 -0
  197. package/src/structure.harness.mjs +4 -2
  198. package/src/structure.ts +3 -3
  199. package/src/tex-requirements.ts +2 -2
  200. package/src/toolchain.harness.mjs +41 -6
  201. package/src/toolchain.ts +29 -5
  202. package/src/types.ts +36 -18
  203. package/src/venue-rules.test.ts +23 -11
  204. package/src/venue-rules.ts +12 -14
  205. package/templates/paper/paperlint.json +1 -1
  206. package/eslint-rules/doc-fields.harness.mjs +0 -336
  207. package/eslint-rules/doc-fields.mjs +0 -186
  208. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  209. package/eslint-rules/paper-typography.harness.mjs +0 -291
  210. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  211. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  212. package/eslint-rules/review-findings-cause.mjs +0 -135
  213. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  214. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  215. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  216. package/fixtures/review-findings-cause/clean.md +0 -17
  217. package/fixtures/review-findings-cause/defect.md +0 -14
  218. package/fixtures/review-findings-cause/old-debt.md +0 -14
  219. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -12,10 +12,7 @@
12
12
  * Killed by: src/doctor.mutations.mjs
13
13
  */
14
14
  import assert from "node:assert/strict";
15
- import {
16
- OLD_PAPERS_DIR_FIELD,
17
- PAPERS_DIR_FIELD,
18
- } from "../lib/paper-config.mjs";
15
+ import { PAPERS_DIR_FIELD } from "../lib/paper-config.mjs";
19
16
  import {
20
17
  mkdtempSync,
21
18
  mkdirSync,
@@ -41,8 +38,8 @@ const check = (label, cond) => {
41
38
  assert.ok(cond, label);
42
39
  };
43
40
 
44
- /** A consumer on disk: a papers directory, and maybe a declaration in package.json. */
45
- function consumer({ papersDir, pkgKey, makeDir = true }) {
41
+ /** A consumer on disk: a papers directory, and maybe a declaration in the root paperlint.json. */
42
+ function consumer({ papersDir, declared, makeDir = true }) {
46
43
  const dir = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-doctor-")));
47
44
  if (makeDir && papersDir) {
48
45
  mkdirSync(join(dir, papersDir, "some-paper"), { recursive: true });
@@ -51,9 +48,15 @@ function consumer({ papersDir, pkgKey, makeDir = true }) {
51
48
  "# s\n",
52
49
  );
53
50
  }
54
- const pkg = { name: "consumer", version: "1.0.0" };
55
- if (pkgKey !== undefined) pkg["paperlint"] = { [PAPERS_DIR_FIELD]: pkgKey };
56
- writeFileSync(join(dir, "package.json"), JSON.stringify(pkg, null, 2));
51
+ writeFileSync(
52
+ join(dir, "package.json"),
53
+ JSON.stringify({ name: "consumer", version: "1.0.0" }, null, 2),
54
+ );
55
+ if (declared !== undefined)
56
+ writeFileSync(
57
+ join(dir, "paperlint.json"),
58
+ JSON.stringify({ [PAPERS_DIR_FIELD]: declared }, null, 2),
59
+ );
57
60
  return dir;
58
61
  }
59
62
 
@@ -76,7 +79,7 @@ const runDoctor = (
76
79
 
77
80
  // ── I. A CONSISTENT INSTALL IS SILENT ───────────────────────────────────────────────────────
78
81
  {
79
- const dir = consumer({ papersDir: "papers", pkgKey: "papers" });
82
+ const dir = consumer({ papersDir: "papers", declared: "papers" });
80
83
  const r = runDoctor(dir, { cliPapers: "papers" });
81
84
  check("a consistent install — exit ZERO", r.code === 0);
82
85
  check(
@@ -86,30 +89,9 @@ const runDoctor = (
86
89
  rmSync(dir, { recursive: true, force: true });
87
90
  }
88
91
 
89
- // ── I½. THE OLD FIELD NAME IS A FAILURE, NAMED ─────────────────────────────────────────────
90
- {
91
- const dir = consumer({ papersDir: "papers", pkgKey: "papers" });
92
- writeFileSync(
93
- join(dir, "package.json"),
94
- JSON.stringify({
95
- name: "consumer",
96
- paperlint: { [OLD_PAPERS_DIR_FIELD]: "papers" },
97
- }),
98
- );
99
- const r = runDoctor(dir, { cliPapers: null });
100
- check("the old field name — exit NON-zero", r.code !== 0);
101
- check(
102
- "and doctor says what the field was renamed to",
103
- r.out.includes(
104
- `"${OLD_PAPERS_DIR_FIELD}" was renamed to "${PAPERS_DIR_FIELD}" in package.json → "paperlint"`,
105
- ),
106
- );
107
- rmSync(dir, { recursive: true, force: true });
108
- }
109
-
110
92
  // ── II. THE VERY DEFECT: AN INSTALL FOLLOWING THE DOCS ─────────────────────────────────────
111
- // The old `init` wrote its own config file and did not touch package.json; the hook reads package.json.
112
- // Measured 09-18.
93
+ // The CLI lints one directory and the hook guards another: the install that motivated doctor
94
+ // (issue #33). Here the CLI was pointed elsewhere while the hook took the default.
113
95
  {
114
96
  const dir = consumer({ papersDir: "writing/drafts" });
115
97
  const r = runDoctor(dir, { cliPapers: "writing/drafts" });
@@ -137,21 +119,31 @@ const runDoctor = (
137
119
  rmSync(dir, { recursive: true, force: true });
138
120
  }
139
121
 
140
- // ── II-bis. A MISSING DECLARATION THAT DOES NO HARM YET ────────────────────────────────────
141
- // The papers live exactly where the hook's default points. The install WORKS — by coincidence.
142
- // It must not fail here (an error-level false positive costs more than a miss), but it must
143
- // not stay silent either.
122
+ // ── II-bis. NO paperlint.json — EVERY DEFAULT, AND THAT IS A VALID PROJECT ─────────────────
123
+ // The CLI and the hooks both take `papers` when nothing is declared, so they cannot disagree.
144
124
  {
145
125
  const dir = consumer({ papersDir: "papers" });
146
126
  const r = runDoctor(dir, { cliPapers: "papers" });
147
- check("an install that works by coincidence does NOT crash", r.code === 0);
127
+ check("no paperlint.json — exit ZERO", r.code === 0);
148
128
  check(
149
- "but the missing declaration is NAMED, not skipped",
150
- /⚠ package\.json has no "paperlint"/.test(r.out),
129
+ "and it says the defaults are in play, naming the directory",
130
+ /✓ no paperlint\.json — every setting has its default; papers are in "papers"/.test(
131
+ r.out,
132
+ ),
151
133
  );
134
+ rmSync(dir, { recursive: true, force: true });
135
+ }
136
+
137
+ // ── II-quater. A paperlint.json THAT DOES NOT PARSE — the guard refuses every command ─────
138
+ {
139
+ const dir = consumer({ papersDir: "papers" });
140
+ writeFileSync(join(dir, "paperlint.json"), '{ "papersDir": <<<<<<< HEAD\n');
141
+ const r = runDoctor(dir, { cliPapers: "papers" });
142
+ check("an unparsable paperlint.json — a FAILURE", r.code === 2);
152
143
  check(
153
- "and it says exactly why that is risky — it works only until the directory moves",
154
- /works only while your papers happen to live there/.test(r.out),
144
+ "named, with the way out (a file edit, not a command)",
145
+ /✗ paperlint\.json is not valid JSON/.test(r.out) &&
146
+ /Edit or Write/.test(r.out),
155
147
  );
156
148
  rmSync(dir, { recursive: true, force: true });
157
149
  }
@@ -163,7 +155,7 @@ const runDoctor = (
163
155
  {
164
156
  const dir = consumer({
165
157
  papersDir: "papers",
166
- pkgKey: "papers",
158
+ declared: "papers",
167
159
  makeDir: false,
168
160
  });
169
161
  const r = runDoctor(dir, { cliPapers: "papers" });
@@ -177,42 +169,9 @@ const runDoctor = (
177
169
  rmSync(dir, { recursive: true, force: true });
178
170
  }
179
171
 
180
- // ── II-quater. THE KEY'S OLD NAME IS READ AND NAMED; TWO DIFFERENT KEYS ARE A FAILURE ──────
181
- {
182
- const dir = consumer({ papersDir: "papers" });
183
- writeFileSync(
184
- join(dir, "package.json"),
185
- JSON.stringify({
186
- "research-paper-pipeline": { [PAPERS_DIR_FIELD]: "papers" },
187
- }),
188
- );
189
- const r = runDoctor(dir, { cliPapers: "papers" });
190
- check(
191
- "the old key — read (not a failure), and named with the new one",
192
- r.code === 0 &&
193
- /⚠ "research-paper-pipeline" in package\.json is the old name/.test(
194
- r.out,
195
- ) &&
196
- /the hooks will guard papers/.test(r.out),
197
- );
198
- writeFileSync(
199
- join(dir, "package.json"),
200
- JSON.stringify({
201
- paperlint: { [PAPERS_DIR_FIELD]: "papers" },
202
- "research-paper-pipeline": { [PAPERS_DIR_FIELD]: "elsewhere" },
203
- }),
204
- );
205
- const both = runDoctor(dir, { cliPapers: "papers" });
206
- check(
207
- "both keys with different contents — a failure",
208
- both.code === 2 && /✗ package\.json has both/.test(both.out),
209
- );
210
- rmSync(dir, { recursive: true, force: true });
211
- }
212
-
213
172
  // ── III. TWO DECLARATIONS HAVE DRIFTED APART ────────────────────────────────────────────────
214
173
  {
215
- const dir = consumer({ papersDir: "writing/drafts", pkgKey: "papers" });
174
+ const dir = consumer({ papersDir: "writing/drafts", declared: "papers" });
216
175
  mkdirSync(join(dir, "papers"), { recursive: true });
217
176
  const r = runDoctor(dir, { cliPapers: "writing/drafts" });
218
177
  check("both declarations exist, but differ — a FAILURE", r.code === 2);
@@ -223,12 +182,10 @@ const runDoctor = (
223
182
  // This is load-bearing: a copy of the logic would drift silently and print a confident wrong
224
183
  // answer.
225
184
  {
226
- const dir = consumer({ papersDir: "docs/papers", pkgKey: "docs/papers/" });
185
+ const dir = consumer({ papersDir: "docs/papers", declared: "docs/papers/" });
227
186
  const r = runDoctor(dir, { cliPapers: "docs/papers" });
228
187
  const fromHook = papersRoot(
229
- JSON.stringify({
230
- paperlint: { [PAPERS_DIR_FIELD]: "docs/papers/" },
231
- }),
188
+ JSON.stringify({ [PAPERS_DIR_FIELD]: "docs/papers/" }),
232
189
  );
233
190
  check("the hook itself trims the trailing slash", fromHook === "docs/papers");
234
191
  check(
@@ -244,7 +201,7 @@ const runDoctor = (
244
201
 
245
202
  // ── V. THE HOOK'S REFUSAL IS PASSED ALONG, NOT TURNED INTO A DIRECTORY ─────────────────────
246
203
  {
247
- const dir = consumer({ papersDir: "papers", pkgKey: "" });
204
+ const dir = consumer({ papersDir: "papers", declared: "" });
248
205
  const r = runDoctor(dir, { cliPapers: "papers" });
249
206
  check(
250
207
  "an empty string in the declaration — the hook refuses, and doctor NAMES it",
@@ -255,7 +212,7 @@ const runDoctor = (
255
212
 
256
213
  // ── VI. EXTERNAL PROGRAMS ARE A FACT, NOT A VERDICT ─────────────────────────────────────────
257
214
  {
258
- const dir = consumer({ papersDir: "papers", pkgKey: "papers" });
215
+ const dir = consumer({ papersDir: "papers", declared: "papers" });
259
216
  const none = runDoctor(dir, { cliPapers: "papers", have: () => 1 });
260
217
  check(
261
218
  "🔴 a missing tex install does NOT fail the run — a gate on advice would mute the whole thing",
@@ -330,7 +287,7 @@ const runDoctor = (
330
287
  // nothing anywhere said so. The link state itself is `link-skills.harness.mjs`'s subject; here
331
288
  // only doctor's REPORTING of it is judged, so the state is handed in rather than built on disk.
332
289
  {
333
- const dir = consumer({ papersDir: "papers", pkgKey: "papers" });
290
+ const dir = consumer({ papersDir: "papers", declared: "papers" });
334
291
  const state = {
335
292
  ok: true,
336
293
  home: join(dir, ".claude", "skills"),
@@ -46,8 +46,8 @@ process.exit(
46
46
  edits: [
47
47
  [
48
48
  SRC,
49
- " const hookRoot = rawPkg ? papersRoot(rawPkg) : null;",
50
- ' const hookRoot = rawPkg ? (JSON.parse(rawPkg)?.["research-paper-pipeline"]?.papersDir ?? "papers") : null;',
49
+ ' const hookRoot = papersRoot(raw ?? "{}");',
50
+ ' const hookRoot = raw === null ? "papers" : (() => { try { return JSON.parse(raw)?.papersDir ?? "papers"; } catch { return "papers"; } })();',
51
51
  ],
52
52
  ],
53
53
  },
@@ -115,18 +115,18 @@ process.exit(
115
115
  ],
116
116
  },
117
117
  {
118
- name: "a missing declaration is reported as all-clear",
118
+ name: "an unparsable paperlint.json is reported as all-clear",
119
119
  harness: HARNESS,
120
- expect: "the missing declaration is NAMED, not skipped",
120
+ expect: "an unparsable paperlint.json — a FAILURE",
121
121
  disables:
122
- "the only trace of issue #33 left on an install that STILL works: `paperlint init` writes " +
123
- "one file, the hook reads another, and the directories matching up rests on the " +
124
- "default. A cheerful checkmark instead of a warning turns a coincidence into a confirmation",
122
+ "the one failure left in the settings file: while it does not parse, the edit guard " +
123
+ "refuses every Bash command. A doctor that counts it as fine sends the reader looking " +
124
+ "everywhere but the file",
125
125
  edits: [
126
126
  [
127
127
  SRC,
128
- ' ` ⚠ package.json has no "${CONFIG_KEY}": { "${PAPERS_DIR_FIELD}": … } — the hooks fall back to "${DEFAULT_PAPERS_ROOT}"`,',
129
- " ` ✓ package.json`,",
128
+ " ` the edit guard refuses every Bash command until it parses — fix it with Edit or Write`,\n ],\n bad: 1,",
129
+ " ` the edit guard refuses every Bash command until it parses — fix it with Edit or Write`,\n ],\n bad: 0,",
130
130
  ],
131
131
  ],
132
132
  },
package/src/doctor.ts CHANGED
@@ -29,19 +29,11 @@ import { spawnSync } from "node:child_process";
29
29
  // the hook itself rather than at a shared module.
30
30
  import {
31
31
  papersRoot,
32
- CONFIG_KEY,
33
32
  DEFAULT_PAPERS_ROOT,
34
33
  PAPERS_DIR_FIELD,
35
- OLD_PAPERS_DIR_FIELD,
34
+ CONFIG_FILE,
36
35
  } from "../hooks/paper-edit-guard.hook.mjs";
37
- import {
38
- LEGACY_CONFIG_KEY,
39
- LEGACY_KEY_MESSAGE,
40
- LEGACY_PAPER_SETTINGS_FILE,
41
- PAPER_SETTINGS_FILE,
42
- declaredSettings,
43
- } from "../lib/paper-config.mjs";
44
- import { PAPER_MARKERS, papersIn } from "./build.ts";
36
+ import { PAPER_MARKERS } from "./build.ts";
45
37
  import { linkSkills, SKILLS_HOME, type LinkReport } from "./link-skills.ts";
46
38
  import { doctorHooks } from "./hooks-settings.ts";
47
39
 
@@ -148,51 +140,44 @@ export function detectPapers(cwd: string, depth = 2): string[] {
148
140
  }
149
141
 
150
142
  /**
151
- * The declaration's verdict. The old field name and two differing keys are failures (every reader
152
- * refuses them); the old KEY is read and named; a missing declaration is a warning.
143
+ * The root `paperlint.json`'s verdict. Absent, or silent on `papersDir`, is fine: the default is
144
+ * `papers`, and the CLI and the hooks take it alike. Only a file that does not parse is a failure —
145
+ * the edit guard refuses every Bash command while it cannot read it.
153
146
  */
154
- function declarationVerdict(rawPkg: string): { lines: string[]; bad: number } {
155
- const out: string[] = [];
156
- const found = (() => {
157
- try {
158
- return declaredSettings(JSON.parse(rawPkg));
159
- } catch {
160
- return declaredSettings(undefined);
161
- }
162
- })();
163
- if (found.conflict !== null)
164
- return { lines: [` ✗ ${found.conflict}`], bad: 1 };
165
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- #49: replace with a real type
166
- const settings = found.settings as Record<string, any> | undefined;
167
- const key = found.legacy ? LEGACY_CONFIG_KEY : CONFIG_KEY;
168
- const declared = settings?.[PAPERS_DIR_FIELD];
169
- if (found.legacy) out.push(` ⚠ ${LEGACY_KEY_MESSAGE}`);
170
- // The old field name is a failure, not a warning: every reader refuses it.
171
- if (settings && Object.hasOwn(settings, OLD_PAPERS_DIR_FIELD))
147
+ function declarationVerdict(raw: string | null): {
148
+ lines: string[];
149
+ bad: number;
150
+ } {
151
+ if (raw === null)
152
+ return {
153
+ lines: [
154
+ ` ✓ no ${CONFIG_FILE} — every setting has its default; papers are in "${DEFAULT_PAPERS_ROOT}"`,
155
+ ],
156
+ bad: 0,
157
+ };
158
+ let settings: unknown;
159
+ try {
160
+ settings = JSON.parse(raw);
161
+ } catch (e) {
172
162
  return {
173
163
  lines: [
174
- ...out,
175
- ` ✗ "${OLD_PAPERS_DIR_FIELD}" was renamed to "${PAPERS_DIR_FIELD}" in package.json → "${key}"`,
164
+ ` ✗ ${CONFIG_FILE} is not valid JSON: ${(e as Error).message}`,
165
+ ` the edit guard refuses every Bash command until it parses — fix it with Edit or Write`,
176
166
  ],
177
167
  bad: 1,
178
168
  };
179
- // 🔴 A MISSING DECLARATION IS A WARNING, NOT A REFUSAL, and that is a decision, not an
180
- // oversight. Without it the hook takes the `papers` default; if the papers do live there, the
181
- // install WORKS — just by coincidence, and it will break silently on the day the directory
182
- // moves. Failing on a working install is not allowed here: for an `error`-level check a false
183
- // positive costs more than a miss, because people do not fix it, they switch it off — together
184
- // with the binary findings below, which the command was written for. A real breakage (the roots
185
- // drifted apart, the directory does not exist) is caught where it is binary.
186
- if (declared === undefined)
187
- out.push(
188
- ` ⚠ package.json has no "${CONFIG_KEY}": { "${PAPERS_DIR_FIELD}": … } — the hooks fall back to "${DEFAULT_PAPERS_ROOT}"`,
189
- ` it works only while your papers happen to live there; declare it and it keeps working`,
190
- );
191
- else
192
- out.push(
193
- ` ✓ package.json → ${key}.${PAPERS_DIR_FIELD} = ${JSON.stringify(declared)}`,
194
- );
195
- return { lines: out, bad: 0 };
169
+ }
170
+ const declared = (settings as Record<string, unknown> | null)?.[
171
+ PAPERS_DIR_FIELD
172
+ ];
173
+ return {
174
+ lines: [
175
+ declared === undefined
176
+ ? ` ✓ ${CONFIG_FILE} names no ${PAPERS_DIR_FIELD} — the default "${DEFAULT_PAPERS_ROOT}"`
177
+ : ` ✓ ${CONFIG_FILE} → ${PAPERS_DIR_FIELD} = ${JSON.stringify(declared)}`,
178
+ ],
179
+ bad: 0,
180
+ };
196
181
  }
197
182
 
198
183
  /**
@@ -235,28 +220,6 @@ function papersVerdict(
235
220
  return { lines: out, bad };
236
221
  }
237
222
 
238
- /**
239
- * A pre-2.1.0 `venue.json` left in a paper: it is no longer read, so that paper's venue checks
240
- * silently would not run if nothing named it. `init` moves it.
241
- */
242
- function leftoverSettingsVerdict(
243
- root: string,
244
- papersDir: string | null,
245
- ): { lines: string[]; bad: number } {
246
- if (papersDir === null) return { lines: [], bad: 0 };
247
- const lines = papersIn(resolve(root, papersDir), [
248
- ...PAPER_MARKERS,
249
- LEGACY_PAPER_SETTINGS_FILE,
250
- ])
251
- .map((dir) => join(dir, LEGACY_PAPER_SETTINGS_FILE))
252
- .filter((f) => existsSync(f))
253
- .map(
254
- (f) =>
255
- ` ✗ ${relative(root, f)} is no longer read — paperlint 2.1.0 renamed it ${PAPER_SETTINGS_FILE}, so this paper's venue checks do not run. \`npx paperlint init\` moves it`,
256
- );
257
- return { lines, bad: lines.length };
258
- }
259
-
260
223
  export interface DoctorOptions {
261
224
  log?: typeof console.log;
262
225
  cwd?: string;
@@ -285,7 +248,7 @@ export function doctor({
285
248
  skillLinks = (r: string) => linkSkills(r, { write: false }),
286
249
  }: DoctorOptions = {}): number {
287
250
  const root = projectDir ?? cwd;
288
- const pkgPath = join(root, "package.json");
251
+ const configPath = join(root, CONFIG_FILE);
289
252
  const out: string[] = [
290
253
  "",
291
254
  "paperlint doctor — what is wired, and what only looks wired",
@@ -293,22 +256,15 @@ export function doctor({
293
256
  ];
294
257
  let bad = 0;
295
258
 
296
- out.push("declaration");
297
- const rawPkg = existsSync(pkgPath) ? readFileSync(pkgPath, "utf8") : "";
298
- if (!rawPkg) {
299
- out.push(` ✗ no package.json at ${root}`);
300
- out.push(
301
- ` the hooks read their papers directory from there and refuse without it`,
302
- );
303
- bad++;
304
- } else {
305
- const declaration = declarationVerdict(rawPkg);
306
- out.push(...declaration.lines);
307
- bad += declaration.bad;
308
- }
259
+ out.push("settings");
260
+ const raw = existsSync(configPath) ? readFileSync(configPath, "utf8") : null;
261
+ const declaration = declarationVerdict(raw);
262
+ out.push(...declaration.lines);
263
+ bad += declaration.bad;
309
264
 
310
265
  out.push("", "papers directory");
311
- const hookRoot = rawPkg ? papersRoot(rawPkg) : null;
266
+ // The hook is given what its provider gives it: the file, or `{}` when there is none.
267
+ const hookRoot = papersRoot(raw ?? "{}");
312
268
  const hookSays = typeof hookRoot === "string" ? hookRoot : null;
313
269
  out.push(
314
270
  ` the CLI will lint ${cliPapers ?? "(nothing — no declaration found)"}`,
@@ -317,9 +273,8 @@ export function doctor({
317
273
  ` the hooks will guard ${hookSays ?? "(nothing — the guard refuses and says why on first use)"}`,
318
274
  );
319
275
  const verdict = papersVerdict(root, cliPapers, hookSays);
320
- const leftover = leftoverSettingsVerdict(root, cliPapers ?? hookSays);
321
- out.push(...verdict.lines, ...leftover.lines);
322
- bad += verdict.bad + leftover.bad;
276
+ out.push(...verdict.lines);
277
+ bad += verdict.bad;
323
278
 
324
279
  // A skill that is not linked is ADVISORY, like a missing program: `paperlint lint`, the hooks and CI
325
280
  // work without it, and an entry of the same name that `init` refused to replace is the
@@ -28,6 +28,7 @@ const {
28
28
  whichOnPath,
29
29
  isExecutable,
30
30
  probeTree,
31
+ missingDependencies,
31
32
  supportedPlatform,
32
33
  } = await import(join(HERE, "engine.ts"));
33
34
 
@@ -251,4 +252,53 @@ check(
251
252
  }
252
253
  }
253
254
 
255
+ // ── missingDependencies: what an install that "continued anyway" left out ──────────────────
256
+ // Real stdout of `tlmgr check depends` on a tree whose install-tl lost unicode-data to a mirror
257
+ // (2026-09-26): the pdflatex format could not be built, yet every venue .sty was present.
258
+ // Byte for byte: tlmgr opens every section header with a FORM FEED, then a space.
259
+ const BROKEN_DEPENDS =
260
+ "\f DEPENDS WITHOUT PACKAGES:\n" +
261
+ "unicode-data in: collection-basic latex-bin luahbtex luatex\n" +
262
+ "\f PACKAGES NOT IN ANY COLLECTION: acmart booktabs caption\n";
263
+ {
264
+ const calls = [];
265
+ const run = (cmd, args) => {
266
+ calls.push([cmd, args]);
267
+ return {
268
+ stdout: BROKEN_DEPENDS,
269
+ stderr: "tlmgr: An error has occurred.",
270
+ status: 2,
271
+ };
272
+ };
273
+ const missing = missingDependencies("/tl/bin", run);
274
+ check(
275
+ "missingDependencies: asks THAT tree's tlmgr `check depends`",
276
+ calls.length === 1 &&
277
+ calls[0][0] === "/tl/bin/tlmgr" &&
278
+ JSON.stringify(calls[0][1]) === '["check","depends"]',
279
+ JSON.stringify(calls),
280
+ );
281
+ // Guards: a package the tree's own database requires but never installed is a gap.
282
+ check(
283
+ "🔴 missingDependencies: names the dependency the install dropped",
284
+ JSON.stringify(missing) === '["unicode-data"]',
285
+ JSON.stringify(missing),
286
+ );
287
+ }
288
+ check(
289
+ "missingDependencies: packages outside any collection are not gaps",
290
+ JSON.stringify(
291
+ missingDependencies("/tl/bin", () => ({
292
+ stdout: "\f PACKAGES NOT IN ANY COLLECTION: acmart booktabs\n",
293
+ status: 2,
294
+ })),
295
+ ) === "[]",
296
+ );
297
+ check(
298
+ "missingDependencies: a clean tree has none",
299
+ JSON.stringify(
300
+ missingDependencies("/tl/bin", () => ({ stdout: "", status: 0 })),
301
+ ) === "[]",
302
+ );
303
+
254
304
  console.log(`engine: ${n} checks passed`);
package/src/engine.ts CHANGED
@@ -164,6 +164,41 @@ export function probeTree(
164
164
  return missingPackages(packages, String(r.stdout ?? ""));
165
165
  }
166
166
 
167
+ /**
168
+ * Packages the tree's OWN database lists as dependencies but never installed, by its `tlmgr check
169
+ * depends`. 🔴 The venue's `.sty` files are not enough evidence of a working tree: install-tl that
170
+ * cannot download a package prints "continuing anyway", and a missing `unicode-data` leaves every
171
+ * `.sty` in place while the pdflatex format cannot be built — pdflatex then dies before writing a
172
+ * log. Only paperlint's cache trees are asked: their tlmgr writes to a directory we own. A tlmgr
173
+ * that cannot start returns nothing here; the tree is then judged by `probeTree` alone.
174
+ */
175
+ export function missingDependencies(
176
+ bin: string,
177
+ run: Runner = spawnSync,
178
+ ): string[] {
179
+ const r = run(join(bin, "tlmgr"), ["check", "depends"], {
180
+ encoding: "utf8",
181
+ stdio: ["ignore", "pipe", "pipe"],
182
+ });
183
+ if (r.error) return [];
184
+ const lines = String(r.stdout ?? "").split("\n");
185
+ const start = lines.findIndex(
186
+ (l) => l.trim() === "DEPENDS WITHOUT PACKAGES:",
187
+ );
188
+ if (start < 0) return [];
189
+ // The section runs until tlmgr's next header. Headers open with a form feed and a space, entries
190
+ // with the package name, so a header is any line with leading whitespace.
191
+ const section = lines.slice(start + 1);
192
+ const end = section.findIndex((l) => l !== l.trimStart() || !l.trim());
193
+ return [
194
+ ...new Set(
195
+ (end < 0 ? section : section.slice(0, end)).map((l) =>
196
+ l.split(" in: ")[0]!.trim(),
197
+ ),
198
+ ),
199
+ ].sort();
200
+ }
201
+
167
202
  /** The first directory on `path` holding a runnable `name` — what a shell would run. */
168
203
  export function whichOnPath(
169
204
  name: string,
@@ -272,13 +272,6 @@ test("declaredVenue: the label is the preset's, and an extends that resolves now
272
272
  assert.throws(() => declaredVenue(typo, PAPER), /shipped presets/);
273
273
  });
274
274
 
275
- test("declaredVenue: a venue.json alone is refused with the command that moves it, not read", () => {
276
- const files = memoryFiles({
277
- [`${PAPER}/venue.json`]: '{"venue":"agenticdev"}',
278
- });
279
- assert.throws(() => declaredVenue(files, PAPER), /npx paperlint init/);
280
- });
281
-
282
275
  test("declaredVenue: a paperlint.json with an unknown key is refused, naming the key", () => {
283
276
  const files = memoryFiles({
284
277
  [`${PAPER}/paperlint.json`]: '{"venu":"agenticdev"}',
package/src/facts-file.ts CHANGED
@@ -75,8 +75,8 @@ export interface VenueDecl {
75
75
 
76
76
  /**
77
77
  * What a paper's `paperlint.json` declares, or null when it has none. THROWS with a one-line reason
78
- * when the file does not parse, its `extends` does not resolve, or only the pre-2.1.0 `venue.json`
79
- * is there: the build reports that as its `facts` failure rather than building against no venue.
78
+ * when the file does not parse or its `extends` does not resolve: the build reports that as its
79
+ * `facts` failure rather than building against no venue.
80
80
  */
81
81
  export function declaredVenue(
82
82
  files: Files,