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/init.test.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  /**
2
- * `paperlint init` over a project that ALREADY declares its papers directory: the declaration is
3
- * the answer, and init reports it — not a directory it discovered elsewhere and then did not use.
2
+ * `paperlint init`: how the papers directory is decided and declared, the refusals over a
3
+ * `paperlint.json` it cannot use, and each section of its report, asserted whole.
4
4
  *
5
- * Seen migrating a consumer project: init printed "papers directory ✓ eslint-rules/fixtures — 4
6
- * candidates", then kept the declared papersDir. The line described a decision that was not made.
5
+ * The first block is the one this file was written for — a project that ALREADY declares its
6
+ * papers directory: the declaration is the answer, and init reports it, not a directory it
7
+ * discovered elsewhere and then did not use. Seen migrating a consumer project: init printed
8
+ * "papers directory ✓ eslint-rules/fixtures — 4 candidates", then kept the declared papersDir.
9
+ * The line described a decision that was not made.
7
10
  */
8
11
  import {
9
12
  existsSync,
@@ -17,7 +20,21 @@ import {
17
20
  import { tmpdir } from "node:os";
18
21
  import { join } from "node:path";
19
22
  import { afterEach, describe, expect, it } from "vitest";
20
- import { choosePapers, init } from "./init.ts";
23
+ import { FRESH_CLONE_NOTE } from "./hooks-settings.ts";
24
+ import {
25
+ UNPINNED_REF,
26
+ WORKFLOW_PATH,
27
+ choosePapers,
28
+ declarePapers,
29
+ init,
30
+ offerHooks,
31
+ offerTexLive,
32
+ reportHooks,
33
+ reportSkillLinks,
34
+ reportTexLive,
35
+ reportWorkflow,
36
+ } from "./init.ts";
37
+ import { runNode } from "../test/support.mjs";
21
38
 
22
39
  const dirs: string[] = [];
23
40
  afterEach(() => {
@@ -204,3 +221,289 @@ describe("paperlint init — TeX Live is offered with its cost, never installed
204
221
  expect(r.text).toMatch(/✓ paperlint's TeX Live is installed/);
205
222
  });
206
223
  });
224
+
225
+ /** A throwaway project from `{ path: contents }`. */
226
+ function tree(files: Record<string, string>): string {
227
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-init-")));
228
+ dirs.push(root);
229
+ for (const [p, text] of Object.entries(files)) {
230
+ mkdirSync(join(root, p, ".."), { recursive: true });
231
+ writeFileSync(join(root, p), text);
232
+ }
233
+ return root;
234
+ }
235
+
236
+ /** init with everything outside the papers step stubbed out; returns the code and both streams. */
237
+ async function initRun(
238
+ root: string,
239
+ opts: Parameters<typeof init>[1] = {},
240
+ ): Promise<{ code: number; out: string[]; err: string[] }> {
241
+ const out: string[] = [];
242
+ const err: string[] = [];
243
+ const code = await init(root, {
244
+ cwd: root,
245
+ log: (s: string) => out.push(s),
246
+ err: (s: string) => err.push(s),
247
+ interactive: false,
248
+ hooks: false,
249
+ run: (() => ({ status: 0 })) as never,
250
+ link: () => ({ ok: false, error: "not linked in this test" }),
251
+ ...opts,
252
+ });
253
+ return { code, out, err };
254
+ }
255
+
256
+ describe("paperlint init — a paperlint.json it cannot use stops it before anything is written", () => {
257
+ it("unparsable: exit 2, the reason, and nothing written", async () => {
258
+ const root = tree({ "paperlint.json": "{ nope" });
259
+ const r = await initRun(root);
260
+ expect(r.code).toBe(2);
261
+ expect(r.err[0]).toMatch(/^ {2}✗ paperlint\.json is not valid JSON: /);
262
+ expect(r.err.slice(1)).toEqual([
263
+ " nothing was written. The hooks read their papers directory from this file and",
264
+ " refuse every Bash command while it cannot be parsed — fix the JSON first.",
265
+ ]);
266
+ expect(readFileSync(join(root, "paperlint.json"), "utf8")).toBe("{ nope");
267
+ });
268
+
269
+ it("a papersDir that is not a string: exit 2, named", async () => {
270
+ const root = tree({ "paperlint.json": '{ "papersDir": 7 }' });
271
+ const r = await initRun(root);
272
+ expect([r.code, r.err]).toEqual([
273
+ 2,
274
+ [
275
+ " ✗ paperlint.json declares papersDir = 7 — it must be a directory path (a string)",
276
+ ],
277
+ ]);
278
+ });
279
+
280
+ it("a file without a trailing newline keeps having none", async () => {
281
+ const root = tree({
282
+ "paperlint.json": '{"x":1}',
283
+ "drafts/a/paper.md": "# a\n",
284
+ });
285
+ const decl = await declarePapers(root, () =>
286
+ choosePapers(root, { interactive: false }),
287
+ );
288
+ expect(decl.status).toBe("written");
289
+ expect(readFileSync(join(root, "paperlint.json"), "utf8")).toBe(
290
+ '{\n "x": 1,\n "papersDir": "drafts"\n}',
291
+ );
292
+ });
293
+ });
294
+
295
+ describe("paperlint init — how several candidate directories were decided, said", () => {
296
+ const several = () =>
297
+ tree({ "drafts/a/paper.md": "# a\n", "writing/b/paper.md": "# b\n" });
298
+ const section = (out: string[]) =>
299
+ out.slice(out.indexOf("papers directory") + 1, out.indexOf("settings") - 1);
300
+
301
+ it("picked by a human", async () => {
302
+ const r = await initRun(several(), {
303
+ interactive: true,
304
+ ask: async () => "2",
305
+ });
306
+ expect(section(r.out)).toEqual([
307
+ " ✓ writing — you picked it out of 2 candidates",
308
+ ]);
309
+ });
310
+
311
+ it("no terminal: the first, and the others named", async () => {
312
+ const r = await initRun(several());
313
+ expect(section(r.out)).toEqual([
314
+ " ✓ drafts — 2 candidates, stdin is not a terminal so nothing was asked",
315
+ ' the others: writing — set "papersDir" in paperlint.json if this is the wrong one',
316
+ ]);
317
+ });
318
+
319
+ it("an answer that picks nothing: the first, and said so", async () => {
320
+ const r = await initRun(several(), {
321
+ interactive: true,
322
+ ask: async () => "nine",
323
+ });
324
+ expect(section(r.out)[0]).toBe(
325
+ " ✓ drafts — 2 candidates, no answer was given, so the first one was taken",
326
+ );
327
+ });
328
+ });
329
+
330
+ const here = (p: string) => `<${p}>`;
331
+
332
+ describe("paperlint init — the workflow section, whole", () => {
333
+ it("the workflow: written unpinned, kept, declined, not asked", () => {
334
+ const ctx = { papersDir: "papers", why: "stdin is not a terminal" };
335
+ expect(reportWorkflow("written", ctx)).toEqual([
336
+ ` ✓ wrote ${WORKFLOW_PATH} — pin ${UNPINNED_REF} before pushing it`,
337
+ ]);
338
+ expect(reportWorkflow("written", { ...ctx, version: "1.2.3" })).toEqual([
339
+ ` ✓ wrote ${WORKFLOW_PATH}, pinned to v1.2.3`,
340
+ ]);
341
+ expect(reportWorkflow("kept", ctx)).toEqual([
342
+ ` ✓ ${WORKFLOW_PATH} is already there — kept, nothing overwritten`,
343
+ ]);
344
+ const step = [
345
+ " to run the same checks in CI, add this step to a workflow:",
346
+ ` - uses: zernie/paperlint@${UNPINNED_REF}`,
347
+ " with:",
348
+ " paths: papers",
349
+ ];
350
+ expect(reportWorkflow("declined", ctx)).toEqual([
351
+ " · declined — nothing written",
352
+ ...step,
353
+ ]);
354
+ expect(reportWorkflow("not-asked", ctx)).toEqual([
355
+ " · stdin is not a terminal, so nothing was asked. Default taken: NO file written.",
356
+ ...step,
357
+ ]);
358
+ });
359
+ });
360
+
361
+ describe("paperlint init — the hooks section, whole", () => {
362
+ it("the hooks: every outcome", () => {
363
+ const how = "HOW";
364
+ const r = (o: Parameters<typeof reportHooks>[0]) =>
365
+ reportHooks(o, { how, here });
366
+ expect(r({ status: "skipped" })).toEqual([
367
+ " · --no-hooks — nothing written",
368
+ ]);
369
+ expect(r({ status: "declined" })).toEqual([
370
+ " · declined — nothing written. `npx paperlint init` again wires them later",
371
+ ]);
372
+ expect(r({ status: "failed", reason: "no vigiles" })).toEqual([
373
+ " ✗ not wired — no vigiles",
374
+ " the hooks need vigiles to run at all; reinstall this package, then `npx paperlint init`",
375
+ ]);
376
+ expect(r({ status: "unparsable", path: "s.json", reason: "bad" })).toEqual([
377
+ " ✗ <s.json> does not parse — nothing written: bad",
378
+ ]);
379
+ expect(r({ status: "present", path: "s.json", names: ["a"] })).toEqual([
380
+ " ✓ already wired in <s.json> — nothing changed",
381
+ " HOW",
382
+ ` ${FRESH_CLONE_NOTE}`,
383
+ ]);
384
+ expect(
385
+ r({
386
+ status: "foreign",
387
+ path: "s.json",
388
+ names: ["a", "b"],
389
+ found: [{ name: "a", command: "node x.mjs" }],
390
+ missing: ["b"],
391
+ }),
392
+ ).toEqual([
393
+ " ✓ already wired under another spelling in <s.json> — nothing written, so nothing runs twice:",
394
+ " a: node x.mjs",
395
+ " ⚠ and NOT wired in any form: b — add them in that same form",
396
+ " to switch to the form init writes, delete those commands and run `npx paperlint init` again",
397
+ ]);
398
+ });
399
+ });
400
+
401
+ describe("paperlint init — the skills section, whole", () => {
402
+ it("the skills: nothing linked, and a mix with a skipped entry of unknown reason and a missing one", () => {
403
+ expect(reportSkillLinks({ ok: false, error: "no package" }, here)).toEqual([
404
+ "",
405
+ "skills (Claude Code finds project skills in .claude/skills/, not in node_modules)",
406
+ " ⚠ nothing linked — no package",
407
+ " install the package into this project (`npm i -D …`), then `npx paperlint init` again",
408
+ ]);
409
+ expect(
410
+ reportSkillLinks(
411
+ {
412
+ ok: true,
413
+ home: "h",
414
+ example: null,
415
+ links: [
416
+ { name: "a", status: "created" },
417
+ { name: "b", status: "foreign" },
418
+ { name: "c", status: "missing" },
419
+ ],
420
+ },
421
+ here,
422
+ ).slice(2),
423
+ ).toEqual([
424
+ " ⚠ 3 shipped: 1 linked now, 0 already linked, 1 skipped, 1 NOT linked",
425
+ " left untouched — the name is taken by something paperlint did not make:",
426
+ " b — occupied",
427
+ " those skills are NOT available in Claude Code until the entry is moved or removed",
428
+ ]);
429
+ });
430
+ });
431
+
432
+ describe("paperlint init — TeX Live and hooks failures, whole", () => {
433
+ it("TeX Live: a failed install says to run it again", async () => {
434
+ const t = await offerTexLive(
435
+ { installed: () => false, install: () => 1 },
436
+ { interactive: true, ask: async () => "yes" },
437
+ );
438
+ expect([t, reportTexLive(t, "")]).toEqual([
439
+ "failed",
440
+ [
441
+ " ✗ the install failed — see above. Run it again:",
442
+ " npx paperlint toolchain # ~270 MB, ~3 min, once",
443
+ ],
444
+ ]);
445
+ });
446
+
447
+ it("hooks: a merge that throws is a failed step, not a crash", async () => {
448
+ const root = tree({});
449
+ const outcome = await offerHooks(root, {
450
+ hooks: true,
451
+ interactive: false,
452
+ merge: () => {
453
+ throw new Error("merge exploded");
454
+ },
455
+ });
456
+ expect(outcome).toEqual({ status: "failed", reason: "merge exploded" });
457
+ });
458
+ });
459
+
460
+ describe("paperlint init — a first paper", () => {
461
+ it("Enter at the name prompt skips it: nothing is created", async () => {
462
+ const made: string[] = [];
463
+ const r = await initRun(tree({}), {
464
+ interactive: true,
465
+ ask: async () => "",
466
+ createPaper: async (_root, name) => (made.push(name), 0),
467
+ });
468
+ expect(made).toEqual([]);
469
+ expect(r.out[r.out.indexOf("first paper") + 1]).toBe(
470
+ " · none yet. `npx paperlint new <name>` or `--paper <name>` creates one",
471
+ );
472
+ });
473
+ });
474
+
475
+ describe("paperlint init — paths are shown relative to where it was run", () => {
476
+ it("a path that IS the working directory is shown whole, not as an empty string", async () => {
477
+ const root = tree({ "papers/a/paper.md": "# a\n" });
478
+ const home = join(root, ".claude", "skills");
479
+ mkdirSync(home, { recursive: true });
480
+ const r = await initRun(root, {
481
+ cwd: home,
482
+ link: () => ({
483
+ ok: true,
484
+ home,
485
+ example: "../../node_modules/paperlint/skills/x",
486
+ links: [{ name: "x", status: "present" }],
487
+ }),
488
+ });
489
+ expect(r.out).toContain(
490
+ ` ${join(home, "<name>")} → ../../node_modules/paperlint/skills/<name>`,
491
+ );
492
+ });
493
+ });
494
+
495
+ describe("askOnTerminal", () => {
496
+ it("reads one line from stdin, and the question goes to stdout", () => {
497
+ const probe = join(tree({}), "probe.mjs");
498
+ writeFileSync(
499
+ probe,
500
+ `const { askOnTerminal } = await import(${JSON.stringify(join(import.meta.dirname, "init.ts"))});\n` +
501
+ `console.log(JSON.stringify(await askOnTerminal("name? ")));\n`,
502
+ );
503
+ expect(runNode(probe, [], { input: "paper-one\n" })).toEqual({
504
+ status: 0,
505
+ stdout: 'name? "paper-one"\n',
506
+ stderr: "",
507
+ });
508
+ });
509
+ });
package/src/init.ts CHANGED
@@ -83,7 +83,7 @@ export type PapersHow =
83
83
  * declaration had already been written. So the install both succeeded and looked like a crash.
84
84
  * An unanswered question is an answer: take the default and say so.
85
85
  */
86
- async function askOrDefault(
86
+ export async function askOrDefault(
87
87
  ask: (q: string) => Promise<string>,
88
88
  question: string,
89
89
  ): Promise<string | null> {
@@ -857,8 +857,8 @@ export async function init(
857
857
  // 🔴 THE NAMES AND THE COUNT HERE, THE CONSEQUENCES — IN THE doctor REPORT BELOW, AND THIS IS
858
858
  // NOT ABOUT SAVING LINES. The first version printed here the same "✗ program — what goes silent
859
859
  // without it" table that doctor prints twenty lines later. Not only did the reader see it
860
- // twice — the harness assert could not tell one from the other and left a mutation that cut the
861
- // remedy OUT of init green. One fact is printed by one author.
860
+ // twice — the harness assert could not tell one from the other and would have let a defect
861
+ // cut the remedy OUT of init green. One fact is printed by one author.
862
862
  log(
863
863
  ` ✗ ${String(missing.length)} of ${String(PROGRAMS.length)} missing: ` +
864
864
  missing.map((p) => p.bin).join(", "),
@@ -5,9 +5,9 @@
5
5
  * memory: the two error spellings `-file-line-error` produces, the missing-package form, and a
6
6
  * marker that TeX broke at column 79.
7
7
  */
8
- import assert from "node:assert/strict";
9
8
  import { dirname, join } from "node:path";
10
9
  import { fileURLToPath } from "node:url";
10
+ import { createChecker } from "../lib/check.mjs";
11
11
 
12
12
  const HERE = dirname(fileURLToPath(import.meta.url));
13
13
  const {
@@ -20,11 +20,7 @@ const {
20
20
  MAX_PRINT_LINE,
21
21
  } = await import(join(HERE, "latex-log.ts"));
22
22
 
23
- let n = 0;
24
- const check = (label, cond) => {
25
- assert.ok(cond, label);
26
- n++;
27
- };
23
+ const check = createChecker();
28
24
 
29
25
  // ── the 79-column wrap ──────────────────────────────────────────────────────────────────
30
26
  // A package warning long enough that "Rerun to get" crosses column 79. A real acmart log shows the
@@ -221,6 +217,35 @@ check(
221
217
  .citations.length === 1,
222
218
  );
223
219
 
220
+ check(
221
+ "an \\@input aux that is not there (the default reader finds nothing) is skipped",
222
+ JSON.stringify(auxBib("\\@input{gone.aux}\n\\citation{x}\n").citations) ===
223
+ JSON.stringify(["x"]),
224
+ );
225
+ check(
226
+ "a log whose LAST line is exactly 79 bytes keeps it (nothing follows to join it to)",
227
+ JSON.stringify(unwrapLog(`a\n${"x".repeat(79)}`)) ===
228
+ JSON.stringify(["a", "x".repeat(79)]),
229
+ );
230
+ check(
231
+ "an error path must hold a dot or a slash; a bare word before the line number is not one",
232
+ isErrorLine("paper.tex:4: Undefined control sequence.") &&
233
+ isErrorLine("chapters/intro:9: x") &&
234
+ !isErrorLine("paper:4: x") &&
235
+ !isErrorLine("two words.tex:4: x"),
236
+ );
237
+ {
238
+ const flood = [
239
+ "./paper.tex:1: Undefined control sequence.",
240
+ ...Array.from({ length: 20 }, (_, i) => `noise ${String(i)}`),
241
+ "l.1 \\foo",
242
+ ];
243
+ check(
244
+ "an excerpt stops at MAX_EXCERPT lines even before the l.NNN line arrives",
245
+ errorExcerpt(flood).length === 12 && errorExcerpt(flood)[11] === "noise 10",
246
+ );
247
+ }
248
+
224
249
  console.log(
225
- `✓ ${String(n)} assertions passed — latex-log: markers across the 79-column wrap, both error spellings, aux`,
250
+ `✓ ${String(check.count)} assertions passed — latex-log: markers across the 79-column wrap, both error spellings, aux`,
226
251
  );
package/src/latex-log.ts CHANGED
@@ -39,13 +39,6 @@ export type LogMarker =
39
39
  | "undefined-references"
40
40
  | "undefined-citations";
41
41
 
42
- /** Markers that mean "the output of this pass is not final yet". */
43
- export const RERUN_MARKERS: readonly LogMarker[] = [
44
- "rerun-requested",
45
- "labels-changed",
46
- "rerunfilecheck",
47
- ];
48
-
49
42
  /**
50
43
  * Rejoin the lines TeX broke at column 79. A line of exactly 79 bytes continues on the next one.
51
44
  *
@@ -100,9 +93,8 @@ export function logMarkers(lines: readonly string[]): LogMarker[] {
100
93
  * contain a dot or a slash, so an ordinary warning ending in a number and a colon cannot pass.
101
94
  */
102
95
  export function isErrorLine(line: string): boolean {
103
- if (line.startsWith("!")) return true;
104
- const m = /^([^\s:]+):(\d+): /.exec(line);
105
- return m !== null && /[./]/.test(m[1] ?? "");
96
+ // `<path>:<line>: `, where the path (no space, no colon) holds at least one dot or slash.
97
+ return line.startsWith("!") || /^[^\s:]*[./][^\s:]*:\d+: /.test(line);
106
98
  }
107
99
 
108
100
  /** The `l.NNN …` line TeX prints to show where in the source it stopped. */
@@ -121,12 +113,13 @@ export function errorExcerpt(lines: readonly string[]): string[] {
121
113
  const start = lines.findIndex(isErrorLine);
122
114
  if (start < 0) return [];
123
115
  const out: string[] = [];
124
- for (let i = start; i < lines.length && out.length < MAX_EXCERPT; i++) {
125
- const line = lines[i] ?? "";
116
+ const rest = lines.slice(start);
117
+ for (const [i, line] of rest.entries()) {
118
+ if (out.length >= MAX_EXCERPT) break;
126
119
  if (line.trim() === "") continue;
127
120
  out.push(line);
128
121
  if (isContextLine(line)) {
129
- const next = lines[i + 1];
122
+ const next = rest[i + 1];
130
123
  if (next !== undefined && next.trim() !== "") out.push(next);
131
124
  break;
132
125
  }
@@ -8,20 +8,16 @@
8
8
  * The decision is pure, so none of this needs TeX. The real-pdflatex half lives in
9
9
  * `test/e2e/build.mjs`.
10
10
  */
11
- import assert from "node:assert/strict";
12
11
  import { dirname, join } from "node:path";
13
12
  import { fileURLToPath } from "node:url";
13
+ import { createChecker } from "../lib/check.mjs";
14
14
 
15
15
  const HERE = dirname(fileURLToPath(import.meta.url));
16
16
  const { nextStep, summarize, changedFiles, MAX_PASSES } = await import(
17
17
  join(HERE, "latex-loop.ts")
18
18
  );
19
19
 
20
- let n = 0;
21
- const check = (label, cond) => {
22
- assert.ok(cond, label);
23
- n++;
24
- };
20
+ const check = createChecker();
25
21
 
26
22
  // ── builders ────────────────────────────────────────────────────────────────────────────
27
23
  const H = (aux, toc = null, out = null, bbl = null) => ({ aux, toc, out, bbl });
@@ -444,6 +440,11 @@ check(
444
440
  changedFiles(H(null), H("a1")).includes("aux"),
445
441
  );
446
442
 
443
+ check(
444
+ "a history of only a bibtex run that left every file as it was: nothing unsettled",
445
+ JSON.stringify(summarize([bibtex({ after: H("a1") })]).unsettled) === "[]",
446
+ );
447
+
447
448
  console.log(
448
- `✓ ${String(n)} assertions passed — latex-loop: nextStep over State, summarize over histories, and the two composed, including the ${MAX_PASSES}-pass cap`,
449
+ `✓ ${String(check.count)} assertions passed — latex-loop: nextStep over State, summarize over histories, and the two composed, including the ${MAX_PASSES}-pass cap`,
449
450
  );
@@ -13,9 +13,7 @@
13
13
  * consumer.mjs — the same constant the linker and the install e2e read.
14
14
  *
15
15
  * Run: node src/link-skills.harness.mjs
16
- * Killed by: src/link-skills.mutations.mjs
17
16
  */
18
- import assert from "node:assert/strict";
19
17
  import {
20
18
  existsSync,
21
19
  mkdirSync,
@@ -30,6 +28,7 @@ import {
30
28
  import { tmpdir } from "node:os";
31
29
  import { dirname, isAbsolute, join } from "node:path";
32
30
  import { fileURLToPath } from "node:url";
31
+ import { createChecker } from "../lib/check.mjs";
33
32
 
34
33
  const HERE = dirname(fileURLToPath(import.meta.url));
35
34
  const { linkSkills, locatePackage, shippedSkills } = await import(
@@ -39,11 +38,7 @@ const { SHIPPED_SKILLS_DIR: SHIPS, PACKAGE_NAME: PKG } = await import(
39
38
  join(HERE, "..", "skills", "paper-pipeline", "scripts", "consumer.mjs")
40
39
  );
41
40
 
42
- let n = 0;
43
- const check = (label, cond) => {
44
- n++;
45
- assert.ok(cond, label);
46
- };
41
+ const check = createChecker();
47
42
 
48
43
  const SKILLS = ["alpha", "beta", "gamma"];
49
44
  const work = realpathSync(
@@ -234,5 +229,5 @@ try {
234
229
  }
235
230
 
236
231
  console.log(
237
- `✓ ${n} assertions passed — link-skills: the skills are where Claude Code looks`,
232
+ `✓ ${check.count} assertions passed — link-skills: the skills are where Claude Code looks`,
238
233
  );
@@ -0,0 +1,105 @@
1
+ /**
2
+ * link-skills.ts edges the harness does not reach: a resolver error without a code, a package that
3
+ * resolved outside every `node_modules` up the tree, a skills directory that is missing or holds a
4
+ * dangling link, a skills home that cannot be made, an entry that is a plain file, and — through
5
+ * the injected fs — an entry that cannot be resolved and a link that cannot be created.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import {
9
+ lstatSync,
10
+ mkdirSync,
11
+ readlinkSync,
12
+ realpathSync,
13
+ symlinkSync,
14
+ } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { test } from "vitest";
17
+ import { useTempDir, writeTree } from "../test/support.mjs";
18
+ import { linkSkills, locatePackage, type LinkFs } from "./link-skills.ts";
19
+
20
+ const root = useTempDir("paperlint-link-skills-");
21
+ const realFs: LinkFs = { lstatSync, realpathSync, readlinkSync, symlinkSync };
22
+
23
+ /** A package directory shipping skills `names` under `skills/`. */
24
+ function pkg(name: string, names: string[]): string {
25
+ const dir = join(root, name, "pkg");
26
+ mkdirSync(join(dir, "skills"), { recursive: true });
27
+ writeTree(
28
+ dir,
29
+ Object.fromEntries(names.map((n) => [`skills/${n}/SKILL.md`, "# s\n"])),
30
+ );
31
+ return dir;
32
+ }
33
+ const at = (dir: string) => () => ({ dir, spelled: dir });
34
+
35
+ test("locatePackage: a resolver error without a code; a package found outside every node_modules", () => {
36
+ assert.deepEqual(
37
+ locatePackage(root, {
38
+ resolveManifest: () => {
39
+ throw new Error("odd\nsecond line");
40
+ },
41
+ }),
42
+ { error: "error: odd" },
43
+ );
44
+ const elsewhere = pkg("elsewhere", []);
45
+ assert.deepEqual(
46
+ locatePackage(root, {
47
+ resolveManifest: () => join(elsewhere, "package.json"),
48
+ }),
49
+ { dir: elsewhere, spelled: elsewhere },
50
+ );
51
+ });
52
+
53
+ test("a package without a skills directory, or one holding a dangling link, is not linked", () => {
54
+ const bare = join(root, "bare");
55
+ mkdirSync(bare, { recursive: true });
56
+ const missing = linkSkills(join(root, "p1"), { locate: at(bare) });
57
+ assert.deepEqual(missing, {
58
+ ok: false,
59
+ error: `the package's skills directory is missing: ${join(bare, "skills")}`,
60
+ });
61
+ const dangling = pkg("dangling", ["a"]);
62
+ symlinkSync(join(root, "gone"), join(dangling, "skills", "b"));
63
+ const r = linkSkills(join(root, "p2"), { locate: at(dangling) });
64
+ assert.equal(r.ok, false);
65
+ assert.match(!r.ok ? r.error : "", /^1 skill link\(s\) in .* lead nowhere:/);
66
+ });
67
+
68
+ test("a skills home that cannot be made is named", () => {
69
+ const project = writeTree(join(root, "p3"), {
70
+ ".claude": "a file, not a directory",
71
+ });
72
+ const r = linkSkills(project, { locate: at(pkg("home", ["a"])) });
73
+ assert.equal(r.ok, false);
74
+ assert.match(!r.ok ? r.error : "", /^cannot create .*\.claude\/skills: /);
75
+ });
76
+
77
+ test("entries that are someone else's: a plain file, one that cannot be resolved, a link that cannot be made", () => {
78
+ const shipped = pkg("entries", ["file", "unresolved", "refused"]);
79
+ const project = writeTree(join(root, "p4"), { ".claude/skills/file": "x" });
80
+ mkdirSync(join(project, ".claude", "skills", "unresolved"));
81
+ const fs: LinkFs = {
82
+ ...realFs,
83
+ realpathSync: (p) => {
84
+ if (p.endsWith("unresolved")) throw new Error("ELOOP");
85
+ return realpathSync(p);
86
+ },
87
+ symlinkSync: () => {
88
+ throw new Error("EEXIST: raced");
89
+ },
90
+ };
91
+ const r = linkSkills(project, { locate: at(shipped), fs });
92
+ assert.deepEqual(r.ok ? r.links : [], [
93
+ { name: "file", status: "foreign", reason: "a file" },
94
+ {
95
+ name: "refused",
96
+ status: "foreign",
97
+ reason: "could not create the link: EEXIST: raced",
98
+ },
99
+ {
100
+ name: "unresolved",
101
+ status: "foreign",
102
+ reason: "an entry that cannot be resolved",
103
+ },
104
+ ]);
105
+ });