paperlint 2.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/CLAUDE.md +6 -1
  2. package/CONTRIBUTING.md +21 -0
  3. package/README.md +231 -180
  4. package/action.yml +4 -6
  5. package/dist/adapters/references/index.d.ts +13 -0
  6. package/dist/adapters/references/index.d.ts.map +1 -0
  7. package/dist/adapters/references/index.js +55 -0
  8. package/dist/adapters/references/index.js.map +1 -0
  9. package/dist/adapters/references/reach.io.d.ts +3 -0
  10. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  11. package/dist/adapters/references/reach.io.js +14 -0
  12. package/dist/adapters/references/reach.io.js.map +1 -0
  13. package/dist/build-engine.d.ts.map +1 -1
  14. package/dist/build-engine.js +5 -2
  15. package/dist/build-engine.js.map +1 -1
  16. package/dist/build.d.ts +16 -1
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/build.js +40 -5
  19. package/dist/build.js.map +1 -1
  20. package/dist/cli.d.ts +37 -20
  21. package/dist/cli.d.ts.map +1 -1
  22. package/dist/cli.js +377 -251
  23. package/dist/cli.js.map +1 -1
  24. package/dist/doctor.d.ts.map +1 -1
  25. package/dist/doctor.js +37 -52
  26. package/dist/doctor.js.map +1 -1
  27. package/dist/facts-file.d.ts +33 -9
  28. package/dist/facts-file.d.ts.map +1 -1
  29. package/dist/facts-file.js +117 -17
  30. package/dist/facts-file.js.map +1 -1
  31. package/dist/hooks-settings.d.ts +0 -21
  32. package/dist/hooks-settings.d.ts.map +1 -1
  33. package/dist/hooks-settings.js +17 -135
  34. package/dist/hooks-settings.js.map +1 -1
  35. package/dist/init.d.ts +40 -19
  36. package/dist/init.d.ts.map +1 -1
  37. package/dist/init.js +144 -109
  38. package/dist/init.js.map +1 -1
  39. package/dist/link-skills.d.ts +1 -1
  40. package/dist/link-skills.d.ts.map +1 -1
  41. package/dist/link-skills.js +11 -29
  42. package/dist/link-skills.js.map +1 -1
  43. package/dist/new-paper.d.ts +2 -2
  44. package/dist/new-paper.d.ts.map +1 -1
  45. package/dist/new-paper.js +6 -2
  46. package/dist/new-paper.js.map +1 -1
  47. package/dist/paper-files.d.ts +63 -0
  48. package/dist/paper-files.d.ts.map +1 -0
  49. package/dist/paper-files.js +69 -0
  50. package/dist/paper-files.js.map +1 -0
  51. package/dist/paper-settings.d.ts +40 -0
  52. package/dist/paper-settings.d.ts.map +1 -0
  53. package/dist/paper-settings.js +144 -0
  54. package/dist/paper-settings.js.map +1 -0
  55. package/dist/ports/check-references.d.ts +31 -0
  56. package/dist/ports/check-references.d.ts.map +1 -0
  57. package/dist/ports/check-references.js +7 -0
  58. package/dist/ports/check-references.js.map +1 -0
  59. package/dist/presets.d.ts +84 -0
  60. package/dist/presets.d.ts.map +1 -0
  61. package/dist/presets.js +184 -0
  62. package/dist/presets.js.map +1 -0
  63. package/dist/reference-rules.d.ts +47 -0
  64. package/dist/reference-rules.d.ts.map +1 -0
  65. package/dist/reference-rules.js +156 -0
  66. package/dist/reference-rules.js.map +1 -0
  67. package/dist/references.d.ts +36 -0
  68. package/dist/references.d.ts.map +1 -0
  69. package/dist/references.js +91 -0
  70. package/dist/references.js.map +1 -0
  71. package/dist/rules-config.d.ts +11 -2
  72. package/dist/rules-config.d.ts.map +1 -1
  73. package/dist/rules-config.js +25 -11
  74. package/dist/rules-config.js.map +1 -1
  75. package/dist/structure.d.ts.map +1 -1
  76. package/dist/structure.js +3 -2
  77. package/dist/structure.js.map +1 -1
  78. package/dist/tex-requirements.d.ts +63 -7
  79. package/dist/tex-requirements.d.ts.map +1 -1
  80. package/dist/tex-requirements.js +91 -24
  81. package/dist/tex-requirements.js.map +1 -1
  82. package/dist/types.d.ts +20 -18
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/venue-rules.d.ts +109 -0
  85. package/dist/venue-rules.d.ts.map +1 -0
  86. package/dist/venue-rules.js +389 -0
  87. package/dist/venue-rules.js.map +1 -0
  88. package/docs/configuration.md +152 -77
  89. package/docs/e2e.md +1 -1
  90. package/docs/install.md +25 -40
  91. package/docs/optional-rules.md +26 -24
  92. package/docs/rules.md +181 -15
  93. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  94. package/eslint-rules/latex-language.mjs +29 -1
  95. package/eslint-rules/paper-stages.harness.mjs +1 -117
  96. package/eslint-rules/paper-stages.mjs +0 -120
  97. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  98. package/eslint-rules/paper-typography.mjs +267 -192
  99. package/eslint-rules/paper-typography.test.mjs +297 -0
  100. package/eslint-rules/papers.harness.mjs +8 -12
  101. package/eslint-rules/papers.mjs +17 -27
  102. package/eslint-rules/pdf-last-page-balance.mjs +23 -5
  103. package/eslint-rules/review-frontmatter.mjs +118 -0
  104. package/eslint-rules/review-frontmatter.schema.json +36 -0
  105. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  106. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  107. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  108. package/eslint.config.mjs +0 -15
  109. package/fixtures/build-e2e/acmart/paperlint.json +1 -0
  110. package/fixtures/real-markdown-paper/baseline.json +6 -7
  111. package/hooks/hooks.harness.mjs +39 -98
  112. package/hooks/hooks.mutations.mjs +6 -6
  113. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  114. package/hooks/paper-edit-guard.hook.mjs +31 -42
  115. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  116. package/hooks/paper-status-gates.hook.mjs +21 -19
  117. package/hooks/paper-status-gates.sh +8 -13
  118. package/lib/paper-config.d.mts +15 -18
  119. package/lib/paper-config.harness.mjs +18 -35
  120. package/lib/paper-config.mjs +90 -81
  121. package/lib/paper-config.mutations.mjs +2 -2
  122. package/lib/skill-checks.mjs +1 -1
  123. package/lib/skill-trigger-cases.harness.mjs +9 -8
  124. package/lib/skill-trigger-cases.mjs +7 -15
  125. package/package.json +1 -1
  126. package/scripts/eslint-report-guard.mjs +1 -1
  127. package/scripts/harness-api.frozen.json +0 -3
  128. package/scripts/mutation-batteries.frozen.json +4 -13
  129. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  130. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  131. package/skills/cold-read-diff/SKILL.md +20 -1
  132. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  133. package/skills/find-venue/SKILL.md +5 -1
  134. package/skills/find-venue/SKILL.md.spec.ts +4 -0
  135. package/skills/grade-paper-writing/SKILL.md +20 -1
  136. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  137. package/skills/paper-pipeline/PIPELINE-MAP.md +14 -4
  138. package/skills/paper-pipeline/SKILL.md +4 -3
  139. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -2
  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/extract-pdf-facts.harness.mjs +5 -5
  151. package/skills/render-paper/extract-pdf-facts.mjs +4 -4
  152. package/skills/render-paper/render-paper.harness.mjs +2 -2
  153. package/skills/study-accepted-papers/SKILL.md +2 -1
  154. package/skills/study-accepted-papers/SKILL.md.spec.ts +1 -0
  155. package/skills/submit-paper/SKILL.md +9 -1
  156. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  157. package/skills/submit-paper/references/venues/acm-sigconf.jsonc +86 -0
  158. package/skills/submit-paper/references/venues/agenticdev.jsonc +34 -102
  159. package/skills/submit-paper/references/venues/aisec.jsonc +21 -82
  160. package/skills/submit-paper/references/venues/realm.jsonc +45 -43
  161. package/skills/submit-paper/references/venues/tex-base.jsonc +1 -1
  162. package/skills/submit-paper/references/venues/venue-profile.schema.json +119 -29
  163. package/skills/verify-citations/SKILL.md +10 -1
  164. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  165. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  166. package/src/adapters/references/index.ts +102 -0
  167. package/src/adapters/references/reach.io.ts +12 -0
  168. package/src/build-engine.ts +10 -2
  169. package/src/build.harness.mjs +31 -22
  170. package/src/build.ts +65 -8
  171. package/src/cli.harness.mjs +151 -294
  172. package/src/cli.mutations.mjs +35 -44
  173. package/src/cli.ts +457 -288
  174. package/src/doctor.harness.mjs +41 -84
  175. package/src/doctor.mutations.mjs +9 -9
  176. package/src/doctor.ts +41 -61
  177. package/src/facts-file.test.ts +47 -7
  178. package/src/facts-file.ts +145 -24
  179. package/src/hooks-settings.harness.mjs +7 -92
  180. package/src/hooks-settings.mutations.mjs +2 -17
  181. package/src/hooks-settings.ts +18 -162
  182. package/src/init.test.ts +206 -0
  183. package/src/init.ts +193 -159
  184. package/src/link-skills.harness.mjs +1 -38
  185. package/src/link-skills.mutations.mjs +9 -5
  186. package/src/link-skills.ts +10 -34
  187. package/src/lint-fix.test.ts +120 -0
  188. package/src/new-paper.harness.mjs +9 -6
  189. package/src/new-paper.test.ts +133 -0
  190. package/src/new-paper.ts +7 -2
  191. package/src/paper-files.test.ts +290 -0
  192. package/src/paper-files.ts +144 -0
  193. package/src/paper-settings-commands.test.ts +274 -0
  194. package/src/paper-settings.test.ts +249 -0
  195. package/src/paper-settings.ts +208 -0
  196. package/src/ports/check-references.ts +28 -0
  197. package/src/presets.test.ts +236 -0
  198. package/src/presets.ts +297 -0
  199. package/src/reference-rules.ts +229 -0
  200. package/src/references.test.ts +209 -0
  201. package/src/references.ts +138 -0
  202. package/src/rules-config.harness.mjs +24 -8
  203. package/src/rules-config.ts +30 -13
  204. package/src/structure.harness.mjs +6 -4
  205. package/src/structure.ts +3 -2
  206. package/src/tex-requirements.harness.mjs +11 -17
  207. package/src/tex-requirements.ts +179 -29
  208. package/src/types.ts +30 -18
  209. package/src/venue-rules.test.ts +554 -0
  210. package/src/venue-rules.ts +571 -0
  211. package/templates/paper/paperlint.json +4 -0
  212. package/eslint-rules/doc-fields.harness.mjs +0 -336
  213. package/eslint-rules/doc-fields.mjs +0 -186
  214. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  215. package/eslint-rules/paper-typography.harness.mjs +0 -291
  216. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  217. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  218. package/eslint-rules/review-findings-cause.mjs +0 -135
  219. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  220. package/fixtures/build-e2e/acmart/venue.json +0 -1
  221. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  222. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  223. package/fixtures/review-findings-cause/clean.md +0 -17
  224. package/fixtures/review-findings-cause/defect.md +0 -14
  225. package/fixtures/review-findings-cause/old-debt.md +0 -14
  226. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -4,7 +4,7 @@ description: Verify every citation is a real work with correct metadata, and tha
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, WebSearch, WebFetch, Agent]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:f35db5aec55b4691 compiled from skills/verify-citations/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:2bb9f6f1233094cf compiled from skills/verify-citations/SKILL.md.spec.ts -->
8
8
 
9
9
  # verify-citations — every cite real, the delta explicit
10
10
 
@@ -136,6 +136,15 @@ any `false` (fabrication)** — `unresolvable` alone is advisory and does NOT fa
136
136
  narrowed-false philosophy. API responses are cached to `scripts/.cite-cache.json` (gitignored) so re-runs are
137
137
  cheap and deterministic.
138
138
 
139
+ ## 5a. Both checks run on every `paperlint build`
140
+
141
+ `npx paperlint build` runs `verify-cites` and `bib-authors` over the paper's bibliography after the PDF is
142
+ built and records the verdicts, with the SHA-256 of the bibliography it checked, in
143
+ `<paper>/_build/references.json`. `paperlint lint` reads that record offline: `paper/cite-exists` and
144
+ `paper/author-list` report a failing entry on its own line, `paper/refs-fresh` says when the bibliography
145
+ changed since, and `paper/refs-checked` warns when nothing was recorded or the build had no network. The
146
+ step never fails the build. Run the scripts by hand (below) to read a single verdict in full.
147
+
139
148
  ## 5b. The author-list gate — `scripts/bib-authors.mjs`
140
149
 
141
150
  `verify-cites.mjs` answers *"does this citation exist, and does the id point at it?"*. There is a second
@@ -152,6 +152,15 @@ any \`false\` (fabrication)** — \`unresolvable\` alone is advisory and does NO
152
152
  narrowed-false philosophy. API responses are cached to \`scripts/.cite-cache.json\` (gitignored) so re-runs are
153
153
  cheap and deterministic.
154
154
 
155
+ ## 5a. Both checks run on every \`paperlint build\`
156
+
157
+ \`npx paperlint build\` runs \`verify-cites\` and \`bib-authors\` over the paper's bibliography after the PDF is
158
+ built and records the verdicts, with the SHA-256 of the bibliography it checked, in
159
+ \`<paper>/_build/references.json\`. \`paperlint lint\` reads that record offline: \`paper/cite-exists\` and
160
+ \`paper/author-list\` report a failing entry on its own line, \`paper/refs-fresh\` says when the bibliography
161
+ changed since, and \`paper/refs-checked\` warns when nothing was recorded or the build had no network. The
162
+ step never fails the build. Run the scripts by hand (below) to read a single verdict in full.
163
+
155
164
  ## 5b. The author-list gate — \`scripts/bib-authors.mjs\`
156
165
 
157
166
  \`verify-cites.mjs\` answers *"does this citation exist, and does the id point at it?"*. There is a second
@@ -192,7 +192,7 @@ function bibTextFrom(target) {
192
192
  * Only three fields are needed (type, key, author, title, booktitle/journal), and a full
193
193
  * BibTeX grammar would be a second thing to maintain. Brace-depth counting is enough and
194
194
  * is exercised by the colocated test. */
195
- function parseBib(text) {
195
+ export function parseBib(text) {
196
196
  const out = [];
197
197
  const re = /@(\w+)\s*\{\s*([^,\s]+)\s*,/g;
198
198
  let m;
@@ -289,7 +289,7 @@ export function claimsPublished(e) {
289
289
 
290
290
  /* ---------- DBLP ---------- */
291
291
 
292
- async function dblpHits(title) {
292
+ export async function dblpHits(title) {
293
293
  const url = `${DBLP}/?q=${encodeURIComponent(title)}&format=json&h=6`;
294
294
  // Per-request timeout: a check that hangs is indistinguishable from a check that is dead,
295
295
  // and this one runs 25+ requests. Measured 2026-08-24: a single unbounded query stalled the
@@ -335,23 +335,31 @@ export function compare(ourSurnames, theirSurnames) {
335
335
  return { missing, extra, orderDiffers };
336
336
  }
337
337
 
338
- function die(msg) {
339
- console.error(`bib-authors: ${msg}`);
340
- process.exit(2);
341
- }
342
-
343
- async function main() {
344
- const args = process.argv.slice(2).filter((a) => a !== "--json");
345
- const asJson = process.argv.includes("--json");
346
- if (!args[0])
347
- die("usage: bib-authors.mjs <paper-dir|file.bib|file.tex> [--json]");
348
-
349
- const { text, file, markdown } = bibTextFrom(args[0]);
350
- const parsed = markdown ? parseMarkdownRefs(text) : parseBib(text);
351
- const entries = parsed.filter((e) => e.title && e.author);
338
+ /**
339
+ * The comparison over parsed entries, the network behind `lookup` (DBLP by default). Returns the
340
+ * buckets main() prints and `paperlint build` records: `findings` (the author list differs from
341
+ * the published version's), `matched` (compared, equal), `skipped` (not applicable), `unchecked`
342
+ * (the lookup FAILED — never a pass). An entry in none of them was a preprint entry, which may
343
+ * carry preprint metadata.
344
+ *
345
+ * @param lookup title → DBLP-shaped hits; throws on a failed request (`retryable` for a 429)
346
+ * @param pause ms → a promise; DBLP asks for gentle clients. A test passes `() => {}`.
347
+ */
348
+ export async function checkAuthors(
349
+ parsed,
350
+ {
351
+ lookup = dblpHits,
352
+ pause = (ms) =>
353
+ new Promise((r) => {
354
+ setTimeout(r, ms);
355
+ }),
356
+ } = {},
357
+ ) {
352
358
  const findings = [];
353
359
  const skipped = []; // legitimately not applicable
354
360
  const unchecked = []; // we FAILED to check — must never be reported as a pass
361
+ const matched = []; // compared against the published record, and equal
362
+ const entries = parsed.filter((e) => e.title && e.author);
355
363
 
356
364
  for (const e of parsed.filter((x) => x.unparsed)) {
357
365
  skipped.push({
@@ -377,7 +385,7 @@ async function main() {
377
385
  let hits = null;
378
386
  for (let attempt = 0; attempt < 3 && hits === null; attempt++) {
379
387
  try {
380
- hits = await dblpHits(e.title.replace(/[{}]/g, ""));
388
+ hits = await lookup(e.title.replace(/[{}]/g, ""));
381
389
  } catch (err) {
382
390
  const last = attempt === 2;
383
391
  if (last)
@@ -385,10 +393,7 @@ async function main() {
385
393
  key: e.key,
386
394
  why: `DBLP lookup failed: ${err.message}`,
387
395
  });
388
- else
389
- await new Promise((r) => {
390
- setTimeout(r, 1500 * (attempt + 1));
391
- });
396
+ else await pause(1500 * (attempt + 1));
392
397
  }
393
398
  }
394
399
  if (hits === null) continue;
@@ -408,7 +413,9 @@ async function main() {
408
413
  const ours = surnames(e.author);
409
414
  const theirs = surnames(rec.authors.join(" and "));
410
415
  const d = compare(ours, theirs);
411
- if (d.missing.length || d.extra.length || d.orderDiffers) {
416
+ if (!(d.missing.length || d.extra.length || d.orderDiffers))
417
+ matched.push(e.key);
418
+ else {
412
419
  findings.push({
413
420
  key: e.key,
414
421
  venue: `${rec.venue} ${rec.year}`.trim(),
@@ -417,12 +424,27 @@ async function main() {
417
424
  ...d,
418
425
  });
419
426
  }
420
- // A braced body rather than a concise arrow — see `no-promise-executor-return` (2026-08-28).
421
- await new Promise((r) => {
422
- setTimeout(r, 900);
423
- }); // DBLP asks for gentle clients; 350 ms drew 429s
427
+ await pause(900); // DBLP asks for gentle clients; 350 ms drew 429s
424
428
  }
425
429
 
430
+ return { findings, skipped, unchecked, matched };
431
+ }
432
+
433
+ function die(msg) {
434
+ console.error(`bib-authors: ${msg}`);
435
+ process.exit(2);
436
+ }
437
+
438
+ async function main() {
439
+ const args = process.argv.slice(2).filter((a) => a !== "--json");
440
+ const asJson = process.argv.includes("--json");
441
+ if (!args[0])
442
+ die("usage: bib-authors.mjs <paper-dir|file.bib|file.tex> [--json]");
443
+
444
+ const { text, file, markdown } = bibTextFrom(args[0]);
445
+ const parsed = markdown ? parseMarkdownRefs(text) : parseBib(text);
446
+ const entries = parsed.filter((e) => e.title && e.author);
447
+ const { findings, skipped, unchecked } = await checkAuthors(parsed);
426
448
  if (asJson) {
427
449
  console.log(
428
450
  JSON.stringify(
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The online reference checks, as the `CheckReferences` port: `verify-cites` (the cited work
3
+ * exists, the title matches — Crossref, OpenAlex, Semantic Scholar, arXiv) and `bib-authors` (the
4
+ * authors are the published version's — DBLP). Both ship in this package's verify-citations skill;
5
+ * this adapter only runs them and shapes their answers.
6
+ *
7
+ * "Not checked" is decided up front, by one request: when Crossref cannot be reached at all, the
8
+ * checkers would degrade every entry to `unresolvable` / `unchecked`, which reads like a result.
9
+ * Saying `not-checked` with the reason is the honest record.
10
+ */
11
+ import type {
12
+ CheckReferences,
13
+ EntryVerdict,
14
+ } from "../../ports/check-references.ts";
15
+ import { unreachable } from "./reach.io.ts";
16
+ // @ts-expect-error — a skill script in .mjs, it has no types
17
+ import * as cites from "../../../skills/verify-citations/scripts/verify-cites.mjs";
18
+ // @ts-expect-error — a skill script in .mjs, it has no types
19
+ import * as authors from "../../../skills/verify-citations/scripts/bib-authors.mjs";
20
+
21
+ interface CiteResult {
22
+ readonly id: string;
23
+ readonly verdict: "true" | "false" | "unresolvable";
24
+ readonly reason?: string;
25
+ }
26
+ interface AuthorFinding {
27
+ readonly key: string;
28
+ readonly venue: string;
29
+ readonly missing: readonly string[];
30
+ readonly extra: readonly string[];
31
+ readonly orderDiffers: boolean;
32
+ }
33
+ interface KeyWhy {
34
+ readonly key: string;
35
+ readonly why: string;
36
+ }
37
+
38
+ const describeAuthors = (f: AuthorFinding): string =>
39
+ [
40
+ f.missing.length ? `missing ${f.missing.join(", ")}` : "",
41
+ f.extra.length ? `extra ${f.extra.join(", ")}` : "",
42
+ f.orderDiffers ? "order differs" : "",
43
+ ]
44
+ .filter(Boolean)
45
+ .join("; ") + ` (DBLP: ${f.venue})`;
46
+
47
+ interface AuthorBuckets {
48
+ readonly findings: readonly AuthorFinding[];
49
+ readonly skipped: readonly KeyWhy[];
50
+ readonly unchecked: readonly KeyWhy[];
51
+ readonly matched: readonly string[];
52
+ }
53
+
54
+ const authorsOf = (key: string, a: AuthorBuckets): EntryVerdict["authors"] =>
55
+ a.findings.some((f) => f.key === key)
56
+ ? "mismatch"
57
+ : a.unchecked.some((u) => u.key === key)
58
+ ? "unchecked"
59
+ : a.matched.includes(key)
60
+ ? "match"
61
+ : "skipped";
62
+
63
+ /** One entry's verdict from the two checkers' answers. */
64
+ function entryVerdict(
65
+ key: string,
66
+ found: readonly CiteResult[],
67
+ a: AuthorBuckets,
68
+ ): EntryVerdict {
69
+ const c = found.find((x) => x.id === key);
70
+ const mismatch = a.findings.find((f) => f.key === key);
71
+ const why = [
72
+ c && c.verdict !== "true" ? c.reason : undefined,
73
+ mismatch ? describeAuthors(mismatch) : undefined,
74
+ a.unchecked.find((u) => u.key === key)?.why,
75
+ ]
76
+ .filter(Boolean)
77
+ .join("; ");
78
+ return {
79
+ key,
80
+ exists: c?.verdict ?? "unresolvable",
81
+ authors: authorsOf(key, a),
82
+ ...(why ? { why } : {}),
83
+ };
84
+ }
85
+
86
+ export const onlineReferences: CheckReferences = async (bib) => {
87
+ const why = await unreachable();
88
+ if (why !== null) return { kind: "not-checked", why };
89
+ 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;
96
+ const keys = new Set([
97
+ ...found.map((c) => c.id),
98
+ ...a.findings.map((f) => f.key),
99
+ ]);
100
+ const entries = [...keys].map((key) => entryVerdict(key, found, a));
101
+ return { kind: "checked", entries };
102
+ };
@@ -0,0 +1,12 @@
1
+ /** Whether the citation services answer at all — the one request that decides "not checked". */
2
+ export async function unreachable(): Promise<string | null> {
3
+ try {
4
+ await fetch("https://api.crossref.org/", {
5
+ method: "HEAD",
6
+ signal: AbortSignal.timeout(10_000),
7
+ });
8
+ return null;
9
+ } catch (e) {
10
+ return `the citation services cannot be reached (${(e as Error).message})`;
11
+ }
12
+ }
@@ -29,7 +29,11 @@ import {
29
29
  usableTree,
30
30
  type InstallResult,
31
31
  } from "./toolchain.ts";
32
- import { declaredUnion, type TexRequirements } from "./tex-requirements.ts";
32
+ import {
33
+ declaredUnion,
34
+ mergeRequirements,
35
+ type TexRequirements,
36
+ } from "./tex-requirements.ts";
33
37
 
34
38
  export interface EngineOptions {
35
39
  /** What the targeted papers need: the base set plus each paper's venue. */
@@ -81,7 +85,11 @@ function withDefaults(o: EngineOptions): Resolved {
81
85
  };
82
86
  return {
83
87
  ...r,
84
- install: o.install ?? (() => ensureTexLive(declaredUnion().tex, r)),
88
+ // The shipped union AND what the targeted papers need: a project's own preset lives outside
89
+ // the package, and its packages are only in `r.tex`.
90
+ install:
91
+ o.install ??
92
+ (() => ensureTexLive(mergeRequirements(declaredUnion().tex, r.tex), r)),
85
93
  };
86
94
  }
87
95
 
@@ -137,6 +137,24 @@ const fakeRead = async (pdf) => {
137
137
  const CLEAN_TEX = "\\documentclass{article}\\begin{document}x\\end{document}";
138
138
 
139
139
  try {
140
+ // ── inputs: paperlint's own venues directory, no configuration ───────────────────────────────
141
+ // First: the facts below resolve a paper's venue preset in this same directory, so a wrong one
142
+ // must be named HERE, not as a missing venue label two checks later.
143
+ const venues = packageVenuesDir();
144
+ check(
145
+ "the venues directory is paperlint's own and holds paper-guards.tex",
146
+ existsSync(join(venues, "paper-guards.tex")),
147
+ );
148
+ check(
149
+ "🔴 with no TEXINPUTS set, the value ENDS in the separator — otherwise the system tree stops resolving",
150
+ withTexInputs({}, [venues]).TEXINPUTS === `${venues}${delimiter}`,
151
+ );
152
+ check(
153
+ "an existing TEXINPUTS is kept, after ours",
154
+ withTexInputs({ TEXINPUTS: `/mine${delimiter}` }, [venues]).TEXINPUTS ===
155
+ `${venues}${delimiter}/mine${delimiter}`,
156
+ );
157
+
140
158
  // ── facts: parsed from the paper, not configured ──────────────────────────────────────
141
159
  check(
142
160
  "documentclass and options come from the parser",
@@ -157,33 +175,20 @@ try {
157
175
  );
158
176
  const venued = paper("venued", {
159
177
  "paper.tex": CLEAN_TEX,
160
- "venue.json": JSON.stringify({ venue: "agenticdev", kind: "short" }),
178
+ "paperlint.json": JSON.stringify({
179
+ extends: "paperlint:agenticdev",
180
+ kind: "short",
181
+ }),
161
182
  "build.sh": "exit 0\n",
162
183
  });
163
184
  const facts = readFacts(venued);
164
- check("the venue comes from venue.json", facts.venue === "agenticdev");
185
+ check("the venue comes from paperlint.json", facts.venue === "agenticdev");
165
186
  check(
166
187
  "a leftover build.sh is a FACT (reported), not a step",
167
188
  JSON.stringify(facts.ignoredScripts) === JSON.stringify(["build.sh"]) &&
168
189
  IGNORED_SCRIPTS.includes("repro/build-submission.sh"),
169
190
  );
170
191
 
171
- // ── inputs: paperlint's own venues directory, no configuration ───────────────────────────────
172
- const venues = packageVenuesDir();
173
- check(
174
- "the venues directory is paperlint's own and holds paper-guards.tex",
175
- existsSync(join(venues, "paper-guards.tex")),
176
- );
177
- check(
178
- "🔴 with no TEXINPUTS set, the value ENDS in the separator — otherwise the system tree stops resolving",
179
- withTexInputs({}, [venues]).TEXINPUTS === `${venues}${delimiter}`,
180
- );
181
- check(
182
- "an existing TEXINPUTS is kept, after ours",
183
- withTexInputs({ TEXINPUTS: `/mine${delimiter}` }, [venues]).TEXINPUTS ===
184
- `${venues}${delimiter}/mine${delimiter}`,
185
- );
186
-
187
192
  // ── the plan ──────────────────────────────────────────────────────────────────────────
188
193
  const plan = planFor(facts);
189
194
  check(
@@ -268,10 +273,14 @@ try {
268
273
  // ── the measure step: facts for the lint rules ────────────────────────────────────────
269
274
  const factsFile = join(clean, "_build", "paper.facts.json");
270
275
  check(
271
- "measure: the plan's last step writes the facts, and says it judges nothing",
272
- r.plan.at(-1)?.step === "measure" &&
273
- /pdf\.js → _build\/paper\.facts\.json/.test(r.plan.at(-1)?.why ?? "") &&
274
- /nothing is judged/.test(r.plan.at(-1)?.why ?? ""),
276
+ "measure: the plan's measure step writes the facts, and says it judges nothing",
277
+ r.plan.find((p) => p.step === "measure") !== undefined &&
278
+ /pdf\.js → _build\/paper\.facts\.json/.test(
279
+ r.plan.find((p) => p.step === "measure")?.why ?? "",
280
+ ) &&
281
+ /nothing is judged/.test(
282
+ r.plan.find((p) => p.step === "measure")?.why ?? "",
283
+ ),
275
284
  );
276
285
  check(
277
286
  "🔴 measure: a green build wrote _build/paper.facts.json through the readPdf port, on paper.pdf",
package/src/build.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * ── THE SHAPE: an ordered list of STEPS, composed, not configured ────────────
14
14
  * Each step says whether it applies to THIS paper and why — decided from FACTS parsed out of the
15
- * paper (its `\documentclass` and options, the venue named in `venue.json`), never from a config
15
+ * paper (its `\documentclass` and options, the venue named in `paperlint.json`), never from a config
16
16
  * flag. `paperlint build` prints that plan before running anything, and `--dry-run` prints only the
17
17
  * plan. Adding a step is one entry in `STEPS`.
18
18
  *
@@ -45,6 +45,7 @@ import { delimiter, join, relative } from "node:path";
45
45
  // eslint-disable-next-line boundaries/dependencies -- legacy layer, moves behind a port in #76
46
46
  import { getParser } from "@unified-latex/unified-latex-util-parse";
47
47
  import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
48
+ import { CONFIG_FILE } from "../lib/paper-config.mjs";
48
49
  import {
49
50
  declaredVenue,
50
51
  factsPath,
@@ -63,6 +64,8 @@ import { whyNoGeometry } from "./domain/geometry.ts";
63
64
  import type { AbsolutePath } from "./domain/paths.ts";
64
65
  import type { Files } from "./ports/files.ts";
65
66
  import type { MeasureGeometry } from "./ports/measure-geometry.ts";
67
+ import type { CheckReferences } from "./ports/check-references.ts";
68
+ import { recordReferences, REFERENCES_FILE } from "./references.ts";
66
69
  import {
67
70
  auxBib,
68
71
  bibtexExcerpt,
@@ -86,7 +89,7 @@ export const PAPER_MARKERS = [
86
89
  "PIPELINE-STATUS.md",
87
90
  "paper.tex",
88
91
  "paper.md",
89
- "venue.json",
92
+ CONFIG_FILE,
90
93
  ];
91
94
 
92
95
  /** The source paperlint compiles, and the job name every output file carries. */
@@ -114,7 +117,7 @@ export interface PaperFacts {
114
117
  readonly name: string;
115
118
  readonly options: readonly string[];
116
119
  } | null;
117
- /** The `venue` field of `venue.json`, or null. */
120
+ /** The label of the venue preset `paperlint.json` extends, or null. */
118
121
  readonly venue: string | null;
119
122
  /** Paper-supplied build scripts found on disk — reported as ignored. */
120
123
  readonly ignoredScripts: readonly string[];
@@ -134,6 +137,8 @@ export interface BuildContext {
134
137
  readonly measure: MeasureGeometry;
135
138
  /** Where the facts file is written. */
136
139
  readonly files: Files;
140
+ /** The online reference checks; the CLI wires the real ones, a test passes a function. */
141
+ readonly checkReferences: CheckReferences;
137
142
  }
138
143
 
139
144
  export type StepOutcome =
@@ -222,7 +227,7 @@ export function readFacts(paperDir: string): PaperFacts {
222
227
  return {
223
228
  main,
224
229
  documentclass: documentclassOf(ast),
225
- venue: venue?.venue ?? null,
230
+ venue: venue?.label ?? null,
226
231
  ignoredScripts: IGNORED_SCRIPTS.filter((s) =>
227
232
  existsSync(join(paperDir, s)),
228
233
  ),
@@ -520,11 +525,36 @@ export const measureStep: BuildStep = {
520
525
  },
521
526
  };
522
527
 
528
+ // ── step: references ────────────────────────────────────────────────────────────────────
529
+
530
+ /**
531
+ * Check the bibliography online — the cited work exists and its title matches (verify-cites), its
532
+ * authors are the published version's (bib-authors) — and record the verdicts in
533
+ * `_build/references.json` for the offline lint rules (`reference-rules.ts`). OPTIONAL and NEVER
534
+ * FAILING: without network the PDF is still built, and the record says `not-checked`.
535
+ */
536
+ export const referencesStep: BuildStep = {
537
+ name: "references",
538
+ required: false,
539
+ applies: (facts) =>
540
+ facts.main
541
+ ? {
542
+ yes: true,
543
+ why: `online: citations exist, titles and authors match → ${FACTS_DIR}/${REFERENCES_FILE} (never fails the build)`,
544
+ }
545
+ : { yes: false, why: "nothing is compiled" },
546
+ run: async (ctx) => ({
547
+ ok: true,
548
+ note: await recordReferences(ctx.files, ctx.paperDir, ctx.checkReferences),
549
+ }),
550
+ };
551
+
523
552
  /** The build, in order. A new step is one entry here. */
524
553
  export const STEPS: readonly BuildStep[] = [
525
554
  inputsStep,
526
555
  compileStep,
527
556
  measureStep,
557
+ referencesStep,
528
558
  ];
529
559
 
530
560
  // ── the command ─────────────────────────────────────────────────────────────────────────
@@ -573,6 +603,11 @@ export interface BuildOptions {
573
603
  measure?: MeasureGeometry;
574
604
  /** Where the facts file is written. Default: the disk. */
575
605
  files?: Files;
606
+ /**
607
+ * The online reference checks. Default: none — the record then says `not-checked`, and lint
608
+ * warns. The CLI passes the real ones (`adapters/references`).
609
+ */
610
+ checkReferences?: CheckReferences;
576
611
  }
577
612
 
578
613
  /**
@@ -593,13 +628,21 @@ function baseDefaults({
593
628
  dryRun = false,
594
629
  readPdf = pdfjsReader,
595
630
  projectRoot = env["CLAUDE_PROJECT_DIR"] || cwd,
596
- }: BuildOptions): Required<Omit<BuildOptions, "measure" | "files">> {
631
+ }: BuildOptions): Required<
632
+ Omit<BuildOptions, "measure" | "files" | "checkReferences">
633
+ > {
597
634
  return { run, cwd, env, steps, log, dryRun, readPdf, projectRoot };
598
635
  }
599
636
 
637
+ /** Without a checker the record says so — never a pass. The CLI wires the real one. */
638
+ const notWired: CheckReferences = async () => ({
639
+ kind: "not-checked",
640
+ why: "no reference checker was wired into this build",
641
+ });
642
+
600
643
  /** banal as the measurer, wired from the build's environment: the one piece of root work left here (#76). */
601
644
  function defaultMeasurer(
602
- b: Required<Omit<BuildOptions, "measure" | "files">>,
645
+ b: Required<Omit<BuildOptions, "measure" | "files" | "checkReferences">>,
603
646
  ): MeasureGeometry {
604
647
  const dirs = hostDirs({ cwd: b.cwd });
605
648
  return banalMeasurer(
@@ -613,7 +656,12 @@ function defaultMeasurer(
613
656
  function withDefaults(o: BuildOptions): Required<BuildOptions> {
614
657
  const base = baseDefaults(o);
615
658
  const measure = o.measure ?? defaultMeasurer(base);
616
- return { ...base, measure, files: o.files ?? nodeFiles };
659
+ return {
660
+ ...base,
661
+ measure,
662
+ files: o.files ?? nodeFiles,
663
+ checkReferences: o.checkReferences ?? notWired,
664
+ };
617
665
  }
618
666
 
619
667
  /** Run the applicable steps in order, each on the environment the steps before it left. */
@@ -621,7 +669,15 @@ async function runSteps(
621
669
  paperDir: string,
622
670
  dir: string,
623
671
  plan: PlanLine[],
624
- { run, env, steps, readPdf, measure, files }: Required<BuildOptions>,
672
+ {
673
+ run,
674
+ env,
675
+ steps,
676
+ readPdf,
677
+ measure,
678
+ files,
679
+ checkReferences,
680
+ }: Required<BuildOptions>,
625
681
  ): Promise<BuildResult> {
626
682
  let stepEnv = env;
627
683
  const notes: string[] = [];
@@ -634,6 +690,7 @@ async function runSteps(
634
690
  readPdf,
635
691
  measure,
636
692
  files,
693
+ checkReferences,
637
694
  });
638
695
  if (!out.ok) {
639
696
  // The PDF THIS run wrote and the step then rejected (a partial pass).