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
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { afterAll as after, test } from "vitest";
7
7
  import type { AbsolutePath } from "../../domain/paths.ts";
8
- import { nodeFiles } from "./files.io.ts";
8
+ import { errorCode, nodeFiles } from "./files.io.ts";
9
9
 
10
10
  const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-files-test-")));
11
11
  after(() => rmSync(root, { recursive: true, force: true }));
@@ -26,3 +26,15 @@ test("Files: writeAtomic creates the directory and leaves no .part behind", () =
26
26
  );
27
27
  assert.equal(existsSync(`${p}.part`), false);
28
28
  });
29
+
30
+ test("Files: a read that fails for another reason than absence throws — a directory is not an empty file", () => {
31
+ assert.throws(() => nodeFiles.readBytes(at()), { code: "EISDIR" });
32
+ });
33
+
34
+ test("errorCode: an Error's errno code; a thrown non-Error has none", () => {
35
+ assert.equal(
36
+ errorCode(Object.assign(new Error("x"), { code: "ENOENT" })),
37
+ "ENOENT",
38
+ );
39
+ assert.equal(errorCode("ENOENT"), undefined);
40
+ });
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import assert from "node:assert/strict";
7
7
  import { test } from "vitest";
8
+ import type { AbsolutePath } from "../../domain/paths.ts";
8
9
  import type { Command } from "../../ports/process.ts";
9
10
  import { spawnProcess } from "./process.io.ts";
10
11
 
@@ -44,3 +45,40 @@ test("the environment is the Command's, whole: nothing leaks in from the parent"
44
45
  // Guards: `Command.env` is the child's entire environment — the adapter merges nothing.
45
46
  assert.deepEqual(r.kind === "exited" && r.stdout, "[][1]\n");
46
47
  });
48
+
49
+ test("a program killed by a signal is `signalled`, with what it printed first", () => {
50
+ assert.deepEqual(run.run(sh("echo before; kill -TERM $$")), {
51
+ kind: "signalled",
52
+ signal: "SIGTERM",
53
+ stdout: "before\n",
54
+ stderr: "",
55
+ });
56
+ });
57
+
58
+ test("output past maxOutputBytes is a spawn failure, named by Node's own message", () => {
59
+ const r = run.run({ ...sh("head -c 100000 /dev/zero"), maxOutputBytes: 10 });
60
+ assert.equal(r.kind, "spawn-failed");
61
+ assert.match(r.kind === "spawn-failed" ? r.message : "", /ENOBUFS/);
62
+ });
63
+
64
+ test("cwd is the Command's when it names one", () => {
65
+ const r = run.run({ ...sh("pwd"), cwd: "/" as AbsolutePath });
66
+ assert.deepEqual(r.kind === "exited" && r.stdout, "/\n");
67
+ });
68
+
69
+ test("a spawn that reports no status, no signal and no error reads as exit 1, not success", () => {
70
+ const silent = spawnProcess((() => ({
71
+ pid: 0,
72
+ output: [],
73
+ stdout: "",
74
+ stderr: "",
75
+ status: null,
76
+ signal: null,
77
+ })) as never);
78
+ assert.deepEqual(silent.run(sh("true")), {
79
+ kind: "exited",
80
+ status: 1,
81
+ stdout: "",
82
+ stderr: "",
83
+ });
84
+ });
@@ -109,3 +109,37 @@ check(
109
109
  "fillsOf: an empty item is `unknown`",
110
110
  L.fillsOf(OPS, ops(), [" "])[0]?.kind === "unknown",
111
111
  );
112
+
113
+ // ── argument shapes pdf.js versions differ on ────────────────────────────────────────────
114
+ test("isUpright on a short transform; hexOf on a non-array and on non-numbers", () => {
115
+ assert.deepEqual(
116
+ [L.isUpright([]), L.hexOf(null), L.hexOf([255.4, "x", undefined])],
117
+ [true, "#000000", "#ff0000"],
118
+ );
119
+ });
120
+
121
+ test("the walk reads odd show-text arguments as no characters, and odd state operators as defaults", () => {
122
+ const drawn = L.drawnCharacters(
123
+ OPS,
124
+ ops(
125
+ [OPS.restore], // a restore with nothing saved: the default state
126
+ [OPS.setTextRenderingMode], // a mode with no argument: 0
127
+ [99], // an operator the walk does not read
128
+ [OPS.showText, "not an array"],
129
+ [OPS.showText, ["not glyphs"]],
130
+ [OPS.showText, [[7, null, { unicode: undefined }, { unicode: "A" }]]],
131
+ ),
132
+ );
133
+ assert.deepEqual(drawn, [{ ch: "A", fill: { kind: "unknown" } }]);
134
+ });
135
+
136
+ test("an item that matches only up to the end of the drawn text is not found", () => {
137
+ const list = ops(
138
+ [OPS.setFillRGBColor, ["#112233"]],
139
+ [OPS.showText, glyphs("ab")],
140
+ );
141
+ assert.deepEqual(L.fillsOf(OPS, list, ["bc", "b"]), [
142
+ { kind: "unknown" },
143
+ { kind: "rgb", hex: "#112233" },
144
+ ]);
145
+ });
@@ -81,40 +81,45 @@ function applyState(
81
81
  return true;
82
82
  }
83
83
 
84
+ /** One drawn character and the fill it was drawn in. */
85
+ interface Drawn {
86
+ readonly ch: string;
87
+ readonly fill: Fill;
88
+ }
89
+
84
90
  /** Every drawn character, in content order, with the fill it was drawn in. */
85
- export function drawnCharacters(
86
- ops: ColourOps,
87
- list: OperatorList,
88
- ): { chars: string[]; fills: Fill[] } {
91
+ export function drawnCharacters(ops: ColourOps, list: OperatorList): Drawn[] {
89
92
  const s = { cur: { fill: { kind: "unknown" } as Fill, mode: 0 }, stack: [] };
90
- const chars: string[] = [];
91
- const fills: Fill[] = [];
93
+ const drawn: Drawn[] = [];
92
94
  list.fnArray.forEach((fn, i) => {
93
95
  const args = list.argsArray[i];
94
96
  if (applyState(ops, fn, args, s)) return;
95
97
  if (fn !== ops.showText && fn !== ops.showSpacedText) return;
96
98
  const fill: Fill = s.cur.mode === 3 ? { kind: "invisible" } : s.cur.fill;
97
- for (const ch of squeeze(glyphText(args))) {
98
- chars.push(ch);
99
- fills.push(fill);
100
- }
99
+ for (const ch of squeeze(glyphText(args))) drawn.push({ ch, fill });
101
100
  });
102
- return { chars, fills };
101
+ return drawn;
103
102
  }
104
103
 
105
104
  /** How far ahead the walk looks for an item it cannot find where it expects it. */
106
105
  export const LOOKAHEAD = 4000;
107
106
 
108
- /** The position at or after `from` where `want` starts in `chars`, or -1 within the look-ahead. */
107
+ /**
108
+ * Where `want` starts in `drawn`, at or after `from` within the look-ahead, and the fill it was
109
+ * drawn in — or null. The fill comes from the matched element itself, not a second lookup.
110
+ */
109
111
  function findRun(
110
- chars: readonly string[],
112
+ drawn: readonly Drawn[],
111
113
  want: readonly string[],
112
114
  from: number,
113
- ): number {
114
- const limit = Math.min(chars.length, from + LOOKAHEAD);
115
- for (let q = from; q < limit; q++)
116
- if (want.every((ch, k) => chars[q + k] === ch)) return q;
117
- return -1;
115
+ ): { readonly at: number; readonly fill: Fill } | null {
116
+ const window = drawn.slice(from, from + LOOKAHEAD);
117
+ for (const [i, d] of window.entries()) {
118
+ const at = from + i;
119
+ if (want.every((ch, k) => drawn[at + k]?.ch === ch))
120
+ return { at, fill: d.fill };
121
+ }
122
+ return null;
118
123
  }
119
124
 
120
125
  /**
@@ -128,14 +133,28 @@ export function fillsOf(
128
133
  list: OperatorList,
129
134
  items: readonly string[],
130
135
  ): Fill[] {
131
- const { chars, fills } = drawnCharacters(ops, list);
136
+ return fillsFor(ops, list, items, (text) => text).map((p) => p.fill);
137
+ }
138
+
139
+ /**
140
+ * Each item paired with its fill — `fillsOf` for items that are not bare strings, so a caller never
141
+ * matches the two lists up by index.
142
+ */
143
+ export function fillsFor<T>(
144
+ ops: ColourOps,
145
+ list: OperatorList,
146
+ items: readonly T[],
147
+ textOf: (item: T) => string,
148
+ ): { readonly item: T; readonly fill: Fill }[] {
149
+ const drawn = drawnCharacters(ops, list);
132
150
  let at = 0;
133
- return items.map((text): Fill => {
151
+ const fillOf = (text: string): Fill => {
134
152
  const want = [...squeeze(text)];
135
153
  if (want.length === 0) return { kind: "unknown" };
136
- const q = findRun(chars, want, at);
137
- if (q < 0) return { kind: "unknown" };
138
- at = q + want.length;
139
- return fills[q] ?? { kind: "unknown" };
140
- });
154
+ const hit = findRun(drawn, want, at);
155
+ if (hit === null) return { kind: "unknown" };
156
+ at = hit.at + want.length;
157
+ return hit.fill;
158
+ };
159
+ return items.map((item) => ({ item, fill: fillOf(textOf(item)) }));
141
160
  }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The online references adapter — verify-cites' existence check and bib-authors' author check
3
+ * over one bibliography, merged per entry — driven with a fake `fetch` that answers every service
4
+ * by URL. DBLP's gentle-client pauses run under fake timers.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { afterEach, test, vi } from "vitest";
8
+ import { onlineReferences } from "./index.ts";
9
+ // @ts-expect-error — a skill script in .mjs, it has no types
10
+ import * as cites from "../../../skills/verify-citations/scripts/verify-cites.mjs";
11
+
12
+ afterEach(() => {
13
+ vi.unstubAllGlobals();
14
+ vi.useRealTimers();
15
+ });
16
+
17
+ const json = (status: number, body?: unknown) =>
18
+ new Response(body === undefined ? null : JSON.stringify(body), { status });
19
+ const dblp = (title: string, authors: string[]) =>
20
+ json(200, {
21
+ result: {
22
+ hits: {
23
+ hit: [
24
+ {
25
+ info: {
26
+ title,
27
+ venue: "ICSE",
28
+ year: "2024",
29
+ type: "Conference and Workshop Papers",
30
+ authors: { author: authors.map((text) => ({ text })) },
31
+ },
32
+ },
33
+ ],
34
+ },
35
+ },
36
+ });
37
+
38
+ /** Route by URL prefix; the longest prefix wins. */
39
+ function fakeFetch(routes: Record<string, () => Response>) {
40
+ vi.stubGlobal("fetch", async (url: string) => {
41
+ const hit = Object.keys(routes)
42
+ .filter((p) => url.startsWith(p))
43
+ .sort((a, b) => b.length - a.length)[0];
44
+ if (hit === undefined) return json(404);
45
+ return routes[hit]!();
46
+ });
47
+ }
48
+
49
+ /** Await `p` while fake time runs, so DBLP's pauses between requests elapse at once. */
50
+ async function settle<T>(p: Promise<T>): Promise<T> {
51
+ let done = false;
52
+ const out = p.finally(() => (done = true));
53
+ while (!done) await vi.advanceTimersByTimeAsync(1000);
54
+ return out;
55
+ }
56
+
57
+ test("no service answers: not-checked, with the reason", async () => {
58
+ vi.stubGlobal("fetch", async () => {
59
+ throw new Error("getaddrinfo ENOTFOUND api.crossref.org");
60
+ });
61
+ assert.deepEqual(await onlineReferences("@misc{k, title={T}}"), {
62
+ kind: "not-checked",
63
+ why: "the citation services cannot be reached (getaddrinfo ENOTFOUND api.crossref.org)",
64
+ });
65
+ });
66
+
67
+ test("each entry gets its existence and its authors: confirmed, fabricated, mismatched, unchecked, skipped", async () => {
68
+ vi.useFakeTimers();
69
+ fakeFetch({
70
+ "https://api.crossref.org/works/10.1%2Fgood": () =>
71
+ json(200, {
72
+ message: { title: ["Good Paper"], issued: { "date-parts": [[2024]] } },
73
+ }),
74
+ "https://doi.org/api/handles/10.1/good": () =>
75
+ json(200, { responseCode: 1 }),
76
+ "https://doi.org/api/handles/10.1/fake": () => json(404),
77
+ "https://dblp.org/search/publ/api/?q=Good%20Paper": () =>
78
+ dblp("Good Paper", ["Ada Lovelace"]),
79
+ "https://dblp.org/search/publ/api/?q=Drift%20Paper": () =>
80
+ dblp("Drift Paper", ["Ada Lovelace", "Alan Turing"]),
81
+ "https://dblp.org/search/publ/api/?q=Flaky%20Paper": () => json(500),
82
+ "https://api.crossref.org/": () => json(200),
83
+ // Any other title: DBLP knows nothing, so the author check does not apply.
84
+ "https://dblp.org/": () => json(200, {}),
85
+ });
86
+ const bib = [
87
+ "@inproceedings{good, author={Ada Lovelace}, title={Good Paper}, booktitle={ICSE}, doi={10.1/good}}",
88
+ "@inproceedings{fake, author={Ada Lovelace}, title={Fake Paper}, booktitle={ICSE}, doi={10.1/fake}}",
89
+ "@inproceedings{drift, author={Ada Lovelace}, title={Drift Paper}, booktitle={ICSE}}",
90
+ "@inproceedings{flaky, author={Ada Lovelace}, title={Flaky Paper}, booktitle={ICSE}}",
91
+ "@misc{pre, author={Ada Lovelace}, title={A Preprint}, journal={arXiv preprint}}",
92
+ ].join("\n");
93
+ const r = await settle(onlineReferences(bib));
94
+ assert.equal(r.kind, "checked");
95
+ const byKey = Object.fromEntries(
96
+ (r.kind === "checked" ? r.entries : []).map((e) => [
97
+ e.key,
98
+ [e.exists, e.authors, e.why ?? ""],
99
+ ]),
100
+ );
101
+ assert.deepEqual(
102
+ {
103
+ good: byKey["good"]!.slice(0, 2),
104
+ fake: byKey["fake"]!.slice(0, 2),
105
+ drift: byKey["drift"]!.slice(0, 2),
106
+ driftWhy: String(byKey["drift"]![2]).split("; ").pop(),
107
+ flaky: [byKey["flaky"]![1], String(byKey["flaky"]![2]).split("; ").pop()],
108
+ pre: byKey["pre"]!.slice(0, 2),
109
+ },
110
+ {
111
+ good: ["true", "match"],
112
+ fake: ["false", "skipped"],
113
+ drift: ["unresolvable", "mismatch"],
114
+ driftWhy: "missing turing (DBLP: ICSE 2024)",
115
+ flaky: ["unchecked", "DBLP lookup failed: DBLP 500"],
116
+ pre: ["unresolvable", "skipped"],
117
+ },
118
+ );
119
+ });
120
+
121
+ test("an author finding names what is extra and what is out of order; an entry only the author check read is unresolvable", async () => {
122
+ vi.useFakeTimers();
123
+ fakeFetch({
124
+ "https://dblp.org/search/publ/api/?q=Extra%20Paper": () =>
125
+ dblp("Extra Paper", ["Ada Lovelace"]),
126
+ "https://dblp.org/search/publ/api/?q=Swapped%20Paper": () =>
127
+ dblp("Swapped Paper", ["Ada Lovelace", "Alan Turing"]),
128
+ "https://dblp.org/search/publ/api/?q=Comment%20Paper": () =>
129
+ dblp("Comment Paper", ["Grace Hopper"]),
130
+ "https://dblp.org/": () => json(200, {}),
131
+ "https://api.crossref.org/": () => json(200),
132
+ });
133
+ const bib = [
134
+ "@inproceedings{extra, author={Ada Lovelace and Grace Hopper}, title={Extra Paper}, booktitle={ICSE}}",
135
+ "@inproceedings{swapped, author={Alan Turing and Ada Lovelace}, title={Swapped Paper}, booktitle={ICSE}}",
136
+ // verify-cites skips @comment blocks; bib-authors reads them — so only one checker sees this key.
137
+ "@comment{commented, author={Ada Lovelace}, title={Comment Paper}, booktitle={ICSE}}",
138
+ ].join("\n");
139
+ const r = await settle(onlineReferences(bib));
140
+ const why = Object.fromEntries(
141
+ (r.kind === "checked" ? r.entries : []).map((e) => [
142
+ e.key,
143
+ [e.exists, e.why],
144
+ ]),
145
+ );
146
+ assert.deepEqual(why, {
147
+ extra: [
148
+ "unresolvable",
149
+ "not found by title in any database (no resolvable id to disprove) — could be a legit unindexed/regional/pre-digital work, NOT fabrication; extra hopper (DBLP: ICSE 2024)",
150
+ ],
151
+ swapped: [
152
+ "unresolvable",
153
+ "not found by title in any database (no resolvable id to disprove) — could be a legit unindexed/regional/pre-digital work, NOT fabrication; order differs (DBLP: ICSE 2024)",
154
+ ],
155
+ commented: [
156
+ "unresolvable",
157
+ "missing hopper; extra lovelace (DBLP: ICSE 2024)",
158
+ ],
159
+ });
160
+ });
161
+
162
+ // ── #107: the per-citation lookups run concurrently ──────────────────────────────────────
163
+
164
+ /** A fetch where every request takes `ms` of real time; `down` DOIs fail at the transport. */
165
+ function slowFetch(ms: number, down: readonly string[] = []) {
166
+ vi.stubGlobal("fetch", async (url: string) => {
167
+ await new Promise((r) => {
168
+ setTimeout(r, ms);
169
+ });
170
+ if (down.some((d) => url.includes(encodeURIComponent(d))))
171
+ throw Object.assign(new Error("aborted"), { name: "AbortError" });
172
+ if (url.startsWith("https://api.crossref.org/works/"))
173
+ return json(200, {
174
+ message: { title: ["Paper"], issued: { "date-parts": [[2024]] } },
175
+ });
176
+ if (url.startsWith("https://doi.org/api/handles/"))
177
+ return json(200, { responseCode: 1 });
178
+ return url === "https://api.crossref.org/" ? json(200) : json(404);
179
+ });
180
+ }
181
+ /** N preprint-style entries (no venue, so the DBLP pass and its pauses do not apply). */
182
+ const manyBib = (n: number) =>
183
+ Array.from(
184
+ { length: n },
185
+ (_, i) =>
186
+ `@misc{k${String(i)}, author={Ada Lovelace}, title={Paper}, doi={10.1234/p${String(i)}}}`,
187
+ ).join("\n");
188
+
189
+ // The serial baseline alone takes ~4 s, past vitest's 5 s default with the concurrent run added.
190
+ test(
191
+ "#107: twenty lookups of ~200 ms each finish in far less than twenty times 200 ms, with the serial verdicts",
192
+ { timeout: 20_000 },
193
+ async () => {
194
+ // Five requests per citation (four resolvers and doi.org), 40 ms each: ~200 ms per lookup.
195
+ slowFetch(40);
196
+ const bib = manyBib(20);
197
+ const serial: string[] = [];
198
+ for (const c of cites.parseBib(bib) as { id: string }[])
199
+ serial.push(
200
+ (
201
+ (await cites.verifyCitationLive(c, { cache: {} })) as {
202
+ verdict: string;
203
+ }
204
+ ).verdict,
205
+ );
206
+ const t0 = Date.now();
207
+ const r = await onlineReferences(bib);
208
+ const ms = Date.now() - t0;
209
+ assert.equal(r.kind, "checked");
210
+ assert.deepEqual(
211
+ r.kind === "checked" ? r.entries.map((e) => e.exists) : [],
212
+ serial,
213
+ );
214
+ assert.ok(ms < (20 * 200) / 2, `${String(ms)} ms for 20 lookups`);
215
+ },
216
+ );
217
+
218
+ test("#107: a lookup whose requests time out is that entry's `unresolvable`, not the others' failure", async () => {
219
+ slowFetch(5, ["10.1234/p3"]);
220
+ const r = await onlineReferences(manyBib(8));
221
+ assert.deepEqual(
222
+ r.kind === "checked" ? r.entries.map((e) => [e.key, e.exists]) : [],
223
+ Array.from({ length: 8 }, (_, i) => [
224
+ `k${String(i)}`,
225
+ i === 3 ? "unresolvable" : "true",
226
+ ]),
227
+ );
228
+ });
@@ -12,7 +12,11 @@ import type {
12
12
  CheckReferences,
13
13
  EntryVerdict,
14
14
  } from "../../ports/check-references.ts";
15
+ import { mapLimit } from "./pool.ts";
15
16
  import { unreachable } from "./reach.io.ts";
17
+
18
+ /** Citations looked up at once (#107). */
19
+ const LOOKUPS_IN_FLIGHT = 6;
16
20
  // @ts-expect-error — a skill script in .mjs, it has no types
17
21
  import * as cites from "../../../skills/verify-citations/scripts/verify-cites.mjs";
18
22
  // @ts-expect-error — a skill script in .mjs, it has no types
@@ -86,13 +90,24 @@ function entryVerdict(
86
90
  export const onlineReferences: CheckReferences = async (bib) => {
87
91
  const why = await unreachable();
88
92
  if (why !== null) return { kind: "not-checked", why };
93
+ // 🔴 #107: the lookups were one at a time — 27 references took 217 s. They now run
94
+ // LOOKUPS_IN_FLIGHT at a time, and the DBLP author pass runs beside them (it keeps its own
95
+ // serial pace and 900 ms pause, which DBLP asks for). Each citation's own requests go to four
96
+ // different services one after another, so a pool of 6 is at most 6 requests to any one of
97
+ // them. The cache is shared: two citations with the same identifier may both miss it and ask
98
+ // twice — an extra request, never a different answer.
89
99
  const cache = {};
90
- const found: CiteResult[] = [];
91
- for (const c of cites.parseBib(bib) as { id?: string }[])
92
- if (c.id) found.push(await cites.verifyCitationLive(c, { cache }));
93
- const a = (await authors.checkAuthors(
94
- authors.parseBib(bib),
95
- )) as AuthorBuckets;
100
+ const citations = (cites.parseBib(bib) as { id?: string }[]).filter(
101
+ (c) => c.id,
102
+ );
103
+ const [found, a] = await Promise.all([
104
+ mapLimit(
105
+ citations,
106
+ LOOKUPS_IN_FLIGHT,
107
+ (c) => cites.verifyCitationLive(c, { cache }) as Promise<CiteResult>,
108
+ ),
109
+ authors.checkAuthors(authors.parseBib(bib)) as Promise<AuthorBuckets>,
110
+ ]);
96
111
  const keys = new Set([
97
112
  ...found.map((c) => c.id),
98
113
  ...a.findings.map((f) => f.key),
@@ -0,0 +1,31 @@
1
+ /** mapLimit: results in input order, never more than `limit` in flight, an empty list at once. */
2
+ import assert from "node:assert/strict";
3
+ import { test } from "vitest";
4
+ import { mapLimit } from "./pool.ts";
5
+
6
+ test("results keep the input order although they finish out of order", async () => {
7
+ const out = await mapLimit([30, 5, 20, 1], 2, async (ms) => {
8
+ await new Promise((r) => {
9
+ setTimeout(r, ms);
10
+ });
11
+ return ms * 10;
12
+ });
13
+ assert.deepEqual(out, [300, 50, 200, 10]);
14
+ });
15
+
16
+ test("never more than `limit` calls in flight; fewer items than the limit start fewer workers", async () => {
17
+ let now = 0;
18
+ let peak = 0;
19
+ const track = async () => {
20
+ peak = Math.max(peak, ++now);
21
+ await new Promise((r) => {
22
+ setTimeout(r, 2);
23
+ });
24
+ now--;
25
+ };
26
+ await mapLimit(Array.from({ length: 10 }), 3, track);
27
+ const three = peak;
28
+ peak = 0;
29
+ await mapLimit([1, 2], 6, track);
30
+ assert.deepEqual([three, peak, await mapLimit([], 6, track)], [3, 2, []]);
31
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `fn` over `items` with at most `limit` calls in flight, results in `items`' order — a promise
3
+ * pool, the one piece of concurrency this adapter needs, so it is written here rather than
4
+ * depended on. A rejection rejects the whole map, as `Promise.all` would: callers that must not
5
+ * lose the other results catch inside `fn`.
6
+ */
7
+ export async function mapLimit<T, R>(
8
+ items: readonly T[],
9
+ limit: number,
10
+ fn: (item: T) => Promise<R>,
11
+ ): Promise<R[]> {
12
+ const out: R[] = new Array<R>(items.length);
13
+ let next = 0;
14
+ const worker = async (): Promise<void> => {
15
+ while (next < items.length) {
16
+ const i = next++;
17
+ out[i] = await fn(items[i] as T);
18
+ }
19
+ };
20
+ await Promise.all(
21
+ Array.from({ length: Math.min(limit, items.length) }, worker),
22
+ );
23
+ return out;
24
+ }
@@ -8,7 +8,6 @@
8
8
  * 2. prepareEngine: use the cache · use the system · refuse without a human · ask, decline,
9
9
  * accept and install · dry-run never asks or installs.
10
10
  */
11
- import assert from "node:assert/strict";
12
11
  import {
13
12
  chmodSync,
14
13
  mkdirSync,
@@ -20,15 +19,12 @@ import {
20
19
  import { tmpdir } from "node:os";
21
20
  import { basename, delimiter, dirname, join } from "node:path";
22
21
  import { fileURLToPath } from "node:url";
22
+ import { createChecker } from "../lib/check.mjs";
23
23
 
24
24
  const HERE = dirname(fileURLToPath(import.meta.url));
25
25
  const E = await import(join(HERE, "build-engine.ts"));
26
26
 
27
- let n = 0;
28
- const check = (label, cond, detail = "") => {
29
- assert.ok(cond, `${label}${detail ? ` — ${detail}` : ""}`);
30
- n++;
31
- };
27
+ const check = createChecker();
32
28
 
33
29
  // ── 1. texts ────────────────────────────────────────────────────────────────────────────
34
30
  const noCache = {
@@ -311,4 +307,4 @@ const x_run = { found: {} };
311
307
  }
312
308
 
313
309
  rmSync(work, { recursive: true, force: true });
314
- console.log(`build-engine: ${n} checks passed`);
310
+ console.log(`build-engine: ${check.count} checks passed`);