paperlint 2.1.0 → 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 (191) hide show
  1. package/CLAUDE.md +6 -1
  2. package/README.md +233 -224
  3. package/action.yml +4 -6
  4. package/dist/adapters/references/index.d.ts +13 -0
  5. package/dist/adapters/references/index.d.ts.map +1 -0
  6. package/dist/adapters/references/index.js +55 -0
  7. package/dist/adapters/references/index.js.map +1 -0
  8. package/dist/adapters/references/reach.io.d.ts +3 -0
  9. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  10. package/dist/adapters/references/reach.io.js +14 -0
  11. package/dist/adapters/references/reach.io.js.map +1 -0
  12. package/dist/build.d.ts +15 -0
  13. package/dist/build.d.ts.map +1 -1
  14. package/dist/build.js +38 -4
  15. package/dist/build.js.map +1 -1
  16. package/dist/cli.d.ts +28 -25
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +277 -265
  19. package/dist/cli.js.map +1 -1
  20. package/dist/doctor.d.ts.map +1 -1
  21. package/dist/doctor.js +40 -72
  22. package/dist/doctor.js.map +1 -1
  23. package/dist/facts-file.d.ts +2 -2
  24. package/dist/facts-file.js +2 -2
  25. package/dist/hooks-settings.d.ts +0 -21
  26. package/dist/hooks-settings.d.ts.map +1 -1
  27. package/dist/hooks-settings.js +17 -135
  28. package/dist/hooks-settings.js.map +1 -1
  29. package/dist/init.d.ts +39 -29
  30. package/dist/init.d.ts.map +1 -1
  31. package/dist/init.js +143 -172
  32. package/dist/init.js.map +1 -1
  33. package/dist/link-skills.d.ts +1 -1
  34. package/dist/link-skills.d.ts.map +1 -1
  35. package/dist/link-skills.js +11 -29
  36. package/dist/link-skills.js.map +1 -1
  37. package/dist/new-paper.d.ts.map +1 -1
  38. package/dist/new-paper.js +2 -13
  39. package/dist/new-paper.js.map +1 -1
  40. package/dist/paper-files.d.ts +63 -0
  41. package/dist/paper-files.d.ts.map +1 -0
  42. package/dist/paper-files.js +69 -0
  43. package/dist/paper-files.js.map +1 -0
  44. package/dist/paper-settings.d.ts +13 -41
  45. package/dist/paper-settings.d.ts.map +1 -1
  46. package/dist/paper-settings.js +71 -71
  47. package/dist/paper-settings.js.map +1 -1
  48. package/dist/ports/check-references.d.ts +31 -0
  49. package/dist/ports/check-references.d.ts.map +1 -0
  50. package/dist/ports/check-references.js +7 -0
  51. package/dist/ports/check-references.js.map +1 -0
  52. package/dist/presets.d.ts +1 -1
  53. package/dist/presets.d.ts.map +1 -1
  54. package/dist/presets.js +4 -6
  55. package/dist/presets.js.map +1 -1
  56. package/dist/reference-rules.d.ts +47 -0
  57. package/dist/reference-rules.d.ts.map +1 -0
  58. package/dist/reference-rules.js +156 -0
  59. package/dist/reference-rules.js.map +1 -0
  60. package/dist/references.d.ts +36 -0
  61. package/dist/references.d.ts.map +1 -0
  62. package/dist/references.js +91 -0
  63. package/dist/references.js.map +1 -0
  64. package/dist/rules-config.d.ts +5 -3
  65. package/dist/rules-config.d.ts.map +1 -1
  66. package/dist/rules-config.js +15 -6
  67. package/dist/rules-config.js.map +1 -1
  68. package/dist/structure.js +3 -3
  69. package/dist/structure.js.map +1 -1
  70. package/dist/tex-requirements.js +2 -2
  71. package/dist/tex-requirements.js.map +1 -1
  72. package/dist/types.d.ts +20 -18
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/venue-rules.d.ts.map +1 -1
  75. package/dist/venue-rules.js +11 -9
  76. package/dist/venue-rules.js.map +1 -1
  77. package/docs/configuration.md +119 -104
  78. package/docs/e2e.md +1 -1
  79. package/docs/install.md +25 -40
  80. package/docs/optional-rules.md +5 -4
  81. package/docs/rules.md +60 -23
  82. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  83. package/eslint-rules/latex-language.mjs +29 -1
  84. package/eslint-rules/paper-stages.harness.mjs +1 -117
  85. package/eslint-rules/paper-stages.mjs +0 -120
  86. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  87. package/eslint-rules/paper-typography.mjs +267 -192
  88. package/eslint-rules/paper-typography.test.mjs +297 -0
  89. package/eslint-rules/papers.harness.mjs +8 -12
  90. package/eslint-rules/papers.mjs +17 -31
  91. package/eslint-rules/pdf-last-page-balance.mjs +4 -7
  92. package/eslint-rules/review-frontmatter.mjs +118 -0
  93. package/eslint-rules/review-frontmatter.schema.json +36 -0
  94. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  95. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  96. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  97. package/eslint.config.mjs +0 -15
  98. package/fixtures/real-markdown-paper/baseline.json +6 -7
  99. package/hooks/hooks.harness.mjs +39 -98
  100. package/hooks/hooks.mutations.mjs +6 -6
  101. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  102. package/hooks/paper-edit-guard.hook.mjs +31 -42
  103. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  104. package/hooks/paper-status-gates.hook.mjs +21 -19
  105. package/hooks/paper-status-gates.sh +8 -13
  106. package/lib/paper-config.d.mts +15 -25
  107. package/lib/paper-config.harness.mjs +18 -35
  108. package/lib/paper-config.mjs +83 -105
  109. package/lib/paper-config.mutations.mjs +2 -2
  110. package/lib/skill-checks.mjs +1 -1
  111. package/lib/skill-trigger-cases.harness.mjs +9 -8
  112. package/lib/skill-trigger-cases.mjs +7 -15
  113. package/package.json +1 -1
  114. package/scripts/eslint-report-guard.mjs +1 -1
  115. package/scripts/harness-api.frozen.json +0 -3
  116. package/scripts/mutation-batteries.frozen.json +4 -13
  117. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  118. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  119. package/skills/cold-read-diff/SKILL.md +20 -1
  120. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  121. package/skills/grade-paper-writing/SKILL.md +20 -1
  122. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  123. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  124. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  125. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  126. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  127. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  128. package/skills/plan-paper-timeline/SKILL.md +2 -2
  129. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  130. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  131. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  132. package/skills/render-paper/check-render.sh +2 -3
  133. package/skills/render-paper/render-paper.harness.mjs +2 -2
  134. package/skills/verify-citations/SKILL.md +10 -1
  135. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  136. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  137. package/src/adapters/references/index.ts +102 -0
  138. package/src/adapters/references/reach.io.ts +12 -0
  139. package/src/build.harness.mjs +8 -4
  140. package/src/build.ts +62 -6
  141. package/src/cli.harness.mjs +147 -292
  142. package/src/cli.mutations.mjs +35 -44
  143. package/src/cli.ts +344 -304
  144. package/src/doctor.harness.mjs +41 -84
  145. package/src/doctor.mutations.mjs +9 -9
  146. package/src/doctor.ts +44 -89
  147. package/src/facts-file.test.ts +0 -7
  148. package/src/facts-file.ts +2 -2
  149. package/src/hooks-settings.harness.mjs +7 -92
  150. package/src/hooks-settings.mutations.mjs +2 -17
  151. package/src/hooks-settings.ts +18 -162
  152. package/src/init.test.ts +206 -0
  153. package/src/init.ts +186 -230
  154. package/src/link-skills.harness.mjs +1 -38
  155. package/src/link-skills.mutations.mjs +9 -5
  156. package/src/link-skills.ts +10 -34
  157. package/src/lint-fix.test.ts +120 -0
  158. package/src/new-paper.test.ts +1 -13
  159. package/src/new-paper.ts +2 -17
  160. package/src/paper-files.test.ts +290 -0
  161. package/src/paper-files.ts +144 -0
  162. package/src/paper-settings-commands.test.ts +84 -121
  163. package/src/paper-settings.test.ts +93 -64
  164. package/src/paper-settings.ts +99 -97
  165. package/src/ports/check-references.ts +28 -0
  166. package/src/presets.ts +5 -15
  167. package/src/reference-rules.ts +229 -0
  168. package/src/references.test.ts +209 -0
  169. package/src/references.ts +138 -0
  170. package/src/rules-config.harness.mjs +24 -8
  171. package/src/rules-config.ts +19 -7
  172. package/src/structure.harness.mjs +3 -1
  173. package/src/structure.ts +3 -3
  174. package/src/tex-requirements.ts +2 -2
  175. package/src/types.ts +30 -18
  176. package/src/venue-rules.test.ts +20 -11
  177. package/src/venue-rules.ts +11 -13
  178. package/eslint-rules/doc-fields.harness.mjs +0 -336
  179. package/eslint-rules/doc-fields.mjs +0 -186
  180. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  181. package/eslint-rules/paper-typography.harness.mjs +0 -291
  182. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  183. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  184. package/eslint-rules/review-findings-cause.mjs +0 -135
  185. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  186. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  187. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  188. package/fixtures/review-findings-cause/clean.md +0 -17
  189. package/fixtures/review-findings-cause/defect.md +0 -14
  190. package/fixtures/review-findings-cause/old-debt.md +0 -14
  191. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -4,7 +4,7 @@ description: Use when asking "does this paper read well?" / "is the writing any
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Agent, Skill, Bash(node .claude/skills/paper-pipeline/scripts/announce.mjs:*), Bash(node .claude/skills/paper-pipeline/scripts/ledger.mjs:*)]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:374531c7f64e998e compiled from skills/grade-paper-writing/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:c0907d5c98e2c831 compiled from skills/grade-paper-writing/SKILL.md.spec.ts -->
8
8
 
9
9
  # grade-paper-writing — grade how the paper READS, then fix it sentence by sentence
10
10
 
@@ -255,6 +255,25 @@ is what happened.
255
255
  `PIPELINE-STATUS.md` (Wc row). Same rule as `pc-panel-review`'s ledger and `analyze-sibling-paper`'s
256
256
  `siblings/` — review artifacts live colocated and ride with the paper to camera-ready/extension.
257
257
 
258
+ ## The report's frontmatter — the findings, as records
259
+
260
+ The report opens with YAML frontmatter that lists what it found, one record per finding.
261
+ `paperlint lint` validates it (`review/frontmatter`): an **open** finding must name the
262
+ pipeline `cause` that let it through — fix that, and the text edit falls out of running the
263
+ fixed tool.
264
+
265
+ ```yaml
266
+ ---
267
+ findings:
268
+ - id: 1
269
+ status: open # open | fixed | wontfix
270
+ cause: missing-skill # skill-defect | missing-skill | hook | rule — required when open
271
+ title: "§3 opens on a term the reader has not met"
272
+ - id: 2
273
+ status: fixed
274
+ ---
275
+ ```
276
+
258
277
  ## Record the verdict
259
278
 
260
279
  🔴 LAST step, once the deliverable exists:
@@ -271,6 +271,25 @@ is what happened.
271
271
  \`PIPELINE-STATUS.md\` (Wc row). Same rule as \`pc-panel-review\`'s ledger and \`analyze-sibling-paper\`'s
272
272
  \`siblings/\` — review artifacts live colocated and ride with the paper to camera-ready/extension.
273
273
 
274
+ ## The report's frontmatter — the findings, as records
275
+
276
+ The report opens with YAML frontmatter that lists what it found, one record per finding.
277
+ \`paperlint lint\` validates it (\`review/frontmatter\`): an **open** finding must name the
278
+ pipeline \`cause\` that let it through — fix that, and the text edit falls out of running the
279
+ fixed tool.
280
+
281
+ \`\`\`yaml
282
+ ---
283
+ findings:
284
+ - id: 1
285
+ status: open # open | fixed | wontfix
286
+ cause: missing-skill # skill-defect | missing-skill | hook | rule — required when open
287
+ title: "§3 opens on a term the reader has not met"
288
+ - id: 2
289
+ status: fixed
290
+ ---
291
+ \`\`\`
292
+
274
293
  ## Record the verdict
275
294
 
276
295
  🔴 LAST step, once the deliverable exists:
@@ -29,5 +29,4 @@ export declare function installedSkills(dir: string): string[];
29
29
  export declare function packageVenuesDir(): string;
30
30
  export declare const SHIPPED_SKILLS_DIR: string;
31
31
  export declare const PACKAGE_NAME: "paperlint";
32
- export declare const LEGACY_PACKAGE_NAME: "research-paper-pipeline";
33
32
  export declare const BIN_FILE: "bin/paperlint.mjs";
@@ -32,7 +32,6 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
32
32
  import { fileURLToPath } from "node:url";
33
33
  import { spawnSync } from "node:child_process";
34
34
  import {
35
- CONFIG_KEY,
36
35
  DEFAULT_SCRIPTS_ROOT,
37
36
  consumerRoot,
38
37
  consumerSkillsDir,
@@ -87,15 +86,12 @@ const HERE = dirname(fileURLToPath(import.meta.url));
87
86
  const TMP = realpathSync(mkdtempSync(join(tmpdir(), "consumer-harness-")));
88
87
  process.on("exit", () => rmSync(TMP, { recursive: true, force: true }));
89
88
 
90
- /** A throwaway consumer repository with the given `paperlint` block (or none). */
89
+ /** A throwaway consumer repository with the given root `paperlint.json` settings (or none). */
91
90
  function fakeConsumer(block) {
92
91
  const root = mkdtempSync(join(TMP, "repo-"));
93
- writeFileSync(
94
- join(root, "package.json"),
95
- JSON.stringify(
96
- block === undefined ? { name: "x" } : { name: "x", [CONFIG_KEY]: block },
97
- ),
98
- );
92
+ writeFileSync(join(root, "package.json"), JSON.stringify({ name: "x" }));
93
+ if (block !== undefined)
94
+ writeFileSync(join(root, "paperlint.json"), JSON.stringify(block));
99
95
  return root;
100
96
  }
101
97
  /** A directory that looks like an installed copy of this package. */
@@ -188,7 +184,7 @@ function installedDir(root) {
188
184
  );
189
185
  assert.match(
190
186
  err.message,
191
- /package\.json/,
187
+ /paperlint\.json/,
192
188
  "the refusal must name the file the key goes in",
193
189
  );
194
190
  }
@@ -409,12 +405,12 @@ assert.equal(
409
405
  );
410
406
  assert.match(
411
407
  missing.message,
412
- /package\.json/,
408
+ /paperlint\.json/,
413
409
  "the refusal must name the file the key goes in",
414
410
  );
415
411
 
416
412
  // And the default's version of that message must say the default was used — otherwise someone
417
- // who declared nothing goes looking in package.json for a line that is not there.
413
+ // who declared nothing goes looking in paperlint.json for a line that is not there.
418
414
  assert.throws(
419
415
  () => scriptsRoot({ env: {}, cwd: fakeConsumer(undefined) }),
420
416
  /Nothing was declared/,
@@ -462,7 +458,7 @@ assert.equal(
462
458
  assert.equal(s.ledger, "a/b/ledger.mjs");
463
459
 
464
460
  // EXACTLY ONE slash, whatever the consumer typed. `scriptsRoot()` returns the declaration
465
- // verbatim, so a trailing slash in package.json arrives here intact; `${root}/` would then give
461
+ // verbatim, so a trailing slash in paperlint.json arrives here intact; `${root}/` would then give
466
462
  // `a/b//`, which matches nothing — the silent direction again.
467
463
  const typedSlash = pipelineScripts("a/b/");
468
464
  assert.equal(
@@ -56,7 +56,7 @@
56
56
  * ── THE LEDGER, THREE RUNGS ─────────────────────────────────────────────────
57
57
  * 1. `PIPELINE_LEDGER` in the environment — always wins. This is what test harnesses set to
58
58
  * keep fixture rows out of real history, so it must outrank a declaration on disk.
59
- * 2. `"paperlint": { "ledger": "…" }` in the CONSUMER's `package.json`, read
59
+ * 2. `{ "ledger": "…" }` in the CONSUMER's root `paperlint.json`, read
60
60
  * from `process.cwd()`, resolved relative to it.
61
61
  * 3. `runs.jsonl` beside this file — ONLY when this file is not inside `node_modules`, i.e.
62
62
  * when the package is being developed in its own checkout. Inside `node_modules` with
@@ -75,7 +75,6 @@
75
75
  */
76
76
  import {
77
77
  existsSync,
78
- readFileSync,
79
78
  readdirSync,
80
79
  readlinkSync,
81
80
  realpathSync,
@@ -86,7 +85,11 @@ import { fileURLToPath, pathToFileURL } from "node:url";
86
85
 
87
86
  // Re-exported, not re-declared — see the note in `lib/paper-config.mjs`. This file is not a hook
88
87
  // and never needed its own copy.
89
- import { CONFIG_KEY, settingsOf } from "../../../lib/paper-config.mjs";
88
+ import {
89
+ CONFIG_FILE,
90
+ CONFIG_KEY,
91
+ settingsOf,
92
+ } from "../../../lib/paper-config.mjs";
90
93
  export { CONFIG_KEY, settingsOf };
91
94
 
92
95
  /**
@@ -144,13 +147,6 @@ export function packageVenuesDir() {
144
147
  */
145
148
  export const PACKAGE_NAME = "paperlint";
146
149
 
147
- /**
148
- * The name the package was published under before 2.0.0. Only for recognising what an older
149
- * install left behind (hook commands, skill links, the config key) so `init` can replace it and
150
- * `doctor` can name it.
151
- */
152
- export const LEGACY_PACKAGE_NAME = "research-paper-pipeline";
153
-
154
150
  /**
155
151
  * The package's one executable, relative to the package root — what `bin` in package.json names
156
152
  * and what every hook command runs. Named like the package; `consumer.harness.mjs` checks it
@@ -245,16 +241,6 @@ function readlinkOr(entry) {
245
241
  }
246
242
  }
247
243
 
248
- /** The consumer's parsed `package.json`, or `null` when there is none or it does not parse. */
249
- export function consumerPkg(opts) {
250
- const file = join(consumerRoot(opts), "package.json");
251
- try {
252
- return JSON.parse(readFileSync(file, "utf8"));
253
- } catch {
254
- return null;
255
- }
256
- }
257
-
258
244
  /**
259
245
  * True when `dir` lies inside a `node_modules` directory.
260
246
  *
@@ -282,7 +268,7 @@ export function ledgerPath(
282
268
 
283
269
  // Rung 2 — the consumer's declaration.
284
270
  const root = consumerRoot({ env, cwd });
285
- const declared = settingsOf(consumerPkg({ env, cwd }))?.ledger;
271
+ const declared = settingsOf(consumerRoot({ env, cwd }))?.ledger;
286
272
  // 🔴 `declared === undefined`, NOT `declared ?? default` — the same distinction `papersRoot()`
287
273
  // makes and for the same reason: `"ledger": null` is a keystroke, not an absence, and reading
288
274
  // it as "nothing was declared" would silently pick a different file than the one asked for.
@@ -299,8 +285,8 @@ export function ledgerPath(
299
285
  throw new Error(
300
286
  `${CONFIG_KEY}: no ledger location is declared, and the default (a file beside this ` +
301
287
  `module) is inside node_modules, which \`npm ci\` deletes.\n` +
302
- `Declare where the ledger lives, in ${join(root, "package.json")}:\n` +
303
- ` "${CONFIG_KEY}": { "ledger": "docs/pipeline-runs.jsonl" }\n` +
288
+ `Declare where the ledger lives, in ${join(root, CONFIG_FILE)}:\n` +
289
+ ` { "ledger": "docs/pipeline-runs.jsonl" }\n` +
304
290
  `or set PIPELINE_LEDGER for a single run.\n` +
305
291
  `This is thrown rather than defaulted on purpose: appending to a path under ` +
306
292
  `node_modules succeeds, so the rows would look recorded right up until the next ` +
@@ -321,7 +307,7 @@ export function ledgerPathExisting(hereDir, opts) {
321
307
  if (!existsSync(p))
322
308
  throw new Error(
323
309
  `${CONFIG_KEY}: the ledger "${p}" does not exist. Create it (an empty file is a valid ` +
324
- `empty ledger) or fix the "ledger" declaration in package.json.`,
310
+ `empty ledger) or fix the "ledger" declaration in ${CONFIG_FILE}.`,
325
311
  );
326
312
  return p;
327
313
  }
@@ -374,7 +360,7 @@ export const DEFAULT_SCRIPTS_ROOT = ".claude/skills/paper-pipeline/scripts";
374
360
  */
375
361
  export function scriptsRoot({ env = process.env, cwd = process.cwd() } = {}) {
376
362
  const root = consumerRoot({ env, cwd });
377
- const declared = settingsOf(consumerPkg({ env, cwd }))?.scripts;
363
+ const declared = settingsOf(consumerRoot({ env, cwd }))?.scripts;
378
364
  // 🔴 `declared === undefined`, NOT `declared ?? DEFAULT` — the same distinction `papersRoot()`
379
365
  // and `ledgerPath()` make, for the same reason: `"scripts": null` is a keystroke, not an
380
366
  // absence, and silently substituting the default for it hides a typo behind a working run.
@@ -393,8 +379,8 @@ export function scriptsRoot({ env = process.env, cwd = process.cwd() } = {}) {
393
379
  `instruction would point at a directory the repository does not track.\n` +
394
380
  `Keep a symlink where the prose already looks —\n` +
395
381
  ` ${join(root, DEFAULT_SCRIPTS_ROOT)} -> node_modules/${CONFIG_KEY}/skills/paper-pipeline/scripts\n` +
396
- `— or declare the real, repository-relative location in ${join(root, "package.json")}:\n` +
397
- ` "${CONFIG_KEY}": { "scripts": "path/to/pipeline/scripts" }\n` +
382
+ `— or declare the real, repository-relative location in ${join(root, CONFIG_FILE)}:\n` +
383
+ ` { "scripts": "path/to/pipeline/scripts" }\n` +
398
384
  `(If nothing was declared, the root itself is under node_modules: run the command from ` +
399
385
  `the consumer repository, or export CLAUDE_PROJECT_DIR.)`,
400
386
  );
@@ -403,9 +389,9 @@ export function scriptsRoot({ env = process.env, cwd = process.cwd() } = {}) {
403
389
  `${CONFIG_KEY}: the pipeline scripts path "${rel}" does not exist under ${root}.\n` +
404
390
  (declared === undefined
405
391
  ? `Nothing was declared, so the default "${DEFAULT_SCRIPTS_ROOT}" was used. Create the ` +
406
- `symlink there, or declare the real location in package.json:\n` +
407
- ` "${CONFIG_KEY}": { "scripts": "path/to/pipeline/scripts" }`
408
- : `It is declared in package.json under "${CONFIG_KEY}" as ` +
392
+ `symlink there, or declare the real location in ${CONFIG_FILE}:\n` +
393
+ ` { "scripts": "path/to/pipeline/scripts" }`
394
+ : `It is declared in ${CONFIG_FILE} as ` +
409
395
  `"scripts": ${JSON.stringify(declared)}. Fix it there, or create the directory.`) +
410
396
  `\nThis is thrown rather than ignored on purpose: this value is a PREFIX that callers ` +
411
397
  `filter prose with, so a wrong one matches no instruction at all and every check built ` +
@@ -453,7 +439,7 @@ export function consumerTimezone({
453
439
  env = process.env,
454
440
  cwd = process.cwd(),
455
441
  } = {}) {
456
- const declared = settingsOf(consumerPkg({ env, cwd }))?.timezone;
442
+ const declared = settingsOf(consumerRoot({ env, cwd }))?.timezone;
457
443
  // 🔴 `declared === undefined`, NOT `declared ?? DEFAULT` — the same distinction every carrier
458
444
  // above makes: `"timezone": null` is a keystroke, not an absence.
459
445
  const tz = declared === undefined ? DEFAULT_TIMEZONE : declared;
@@ -467,7 +453,7 @@ export function consumerTimezone({
467
453
  throw new RangeError(
468
454
  `${CONFIG_KEY}: "timezone" is ${JSON.stringify(tz)}, which is not an IANA time zone this ` +
469
455
  `runtime knows (e.g. "Europe/Berlin", "America/New_York", "UTC").\n` +
470
- `Fix it in package.json under "${CONFIG_KEY}".\n` +
456
+ `Fix it in ${CONFIG_FILE}.\n` +
471
457
  `This throws rather than falling back to ${DEFAULT_TIMEZONE} on purpose: a silent fallback ` +
472
458
  `would put every deadline anchor at the wrong hour while looking like it worked.`,
473
459
  );
@@ -505,7 +491,7 @@ export function consumerContactEmail({
505
491
  env = process.env,
506
492
  cwd = process.cwd(),
507
493
  } = {}) {
508
- const declared = settingsOf(consumerPkg({ env, cwd }))?.contactEmail;
494
+ const declared = settingsOf(consumerRoot({ env, cwd }))?.contactEmail;
509
495
  if (declared === undefined || declared === null) return null;
510
496
  if (typeof declared !== "string" || !declared.includes("@"))
511
497
  throw new TypeError(
@@ -21,7 +21,11 @@
21
21
  * as success, so an exported test object would report ✓ without running anything.
22
22
  */
23
23
  import assert from "node:assert/strict";
24
- import { PAPERS_DIR_FIELD } from "../../../lib/paper-config.mjs";
24
+ import {
25
+ DEFAULT_PAPERS_ROOT,
26
+ PAPERS_DIR_FIELD,
27
+ settingsOf,
28
+ } from "../../../lib/paper-config.mjs";
25
29
  import {
26
30
  existsSync,
27
31
  mkdirSync,
@@ -57,9 +61,7 @@ const X = await import(join(HERE, "extract-ref-facts.mjs"));
57
61
  // A directory on disk answers the same question without knowing any name in advance.
58
62
  const PAPERS_ROOT = join(
59
63
  ROOT,
60
- JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"))["paperlint"]?.[
61
- PAPERS_DIR_FIELD
62
- ] ?? "papers",
64
+ settingsOf(ROOT)?.[PAPERS_DIR_FIELD] ?? DEFAULT_PAPERS_ROOT,
63
65
  );
64
66
  const REAL_PAPERS = existsSync(PAPERS_ROOT)
65
67
  ? readdirSync(PAPERS_ROOT, { withFileTypes: true })
@@ -33,13 +33,7 @@
33
33
 
34
34
  import { record, ABSTENTIONS } from "./ledger.mjs";
35
35
  import { execFileSync } from "node:child_process";
36
- import {
37
- existsSync,
38
- mkdirSync,
39
- readFileSync,
40
- readdirSync,
41
- writeFileSync,
42
- } from "node:fs";
36
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
43
37
  import { resolve, join, dirname } from "node:path";
44
38
  import { fileURLToPath, pathToFileURL } from "node:url";
45
39
  import { isMain, settingsOf } from "./consumer.mjs";
@@ -83,10 +77,7 @@ const ROOT = consumerRoot();
83
77
  // simply do not run: `needs()` below checks the directory on disk, so the absence reads as
84
78
  // ABSTAINED `input-missing`, not as a crash and not as a clean run.
85
79
  const CITE_CHECKS =
86
- process.env.PIPELINE_CITE_CHECKS ||
87
- settingsOf(JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")))
88
- ?.citeChecks ||
89
- null;
80
+ process.env.PIPELINE_CITE_CHECKS || settingsOf(ROOT)?.citeChecks || null;
90
81
  // The citation-checker directory exists and is declared. It sits in the `needs()` of every row
91
82
  // that calls it: without that a wrong or undeclared path would fail with a python stack trace, and
92
83
  // in `flags` mode a stack trace is counted line by line and goes into the ledger as a FINDING whose
@@ -4,7 +4,7 @@ description: Turn a venue's CFP dates into a scheduled, buffered plan on the Goo
4
4
  allowed-tools: [Read, Write, Grep, Glob, mcp__Google_Calendar__list_events, mcp__Google_Calendar__search_events, mcp__Google_Calendar__create_event, mcp__Google_Calendar__update_event, Bash(node .claude/skills/paper-pipeline/scripts/announce.mjs:*), Bash(node .claude/skills/paper-pipeline/scripts/ledger.mjs:*)]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:9009b62e211cf421 compiled from skills/plan-paper-timeline/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:faf0c175f1a0c5e5 compiled from skills/plan-paper-timeline/SKILL.md.spec.ts -->
8
8
 
9
9
  # plan-paper-timeline — CFP dates → a buffered, filing-aware schedule
10
10
 
@@ -96,7 +96,7 @@ Use `mcp__Google_Calendar__create_event` for three anchors per paper. 🔴 **Rea
96
96
  assume it** — it is the `timezone` carrier, and it is the author's, not the pipeline's:
97
97
 
98
98
  ```
99
- node -p "((p) => p.paperlint ?? p['research-paper-pipeline'])(require('./package.json'))?.timezone ?? 'UTC'"
99
+ node -p "(() => { try { return require('./paperlint.json')?.timezone } catch {} })() ?? 'UTC'"
100
100
  ```
101
101
 
102
102
  The example below shows the default, `UTC`; substitute whatever that command prints.
@@ -113,7 +113,7 @@ Use \`mcp__Google_Calendar__create_event\` for three anchors per paper. 🔴 **R
113
113
  assume it** — it is the \`timezone\` carrier, and it is the author's, not the pipeline's:
114
114
 
115
115
  \`\`\`
116
- node -p "((p) => p.paperlint ?? p['research-paper-pipeline'])(require('./package.json'))?.timezone ?? 'UTC'"
116
+ node -p "(() => { try { return require('./paperlint.json')?.timezone } catch {} })() ?? 'UTC'"
117
117
  \`\`\`
118
118
 
119
119
  The example below shows the default, \`UTC\`; substitute whatever that command prints.
@@ -383,7 +383,7 @@ try {
383
383
  //
384
384
  // 🔴 THIS USED TO COMPARE AGAINST ONE HARD-CODED ZONE — the first consumer's. That is exactly
385
385
  // the thing a package may not know: the same CFP date is a different wall-clock hour for every
386
- // author, so the zone is the `timezone` CARRIER, read from the consumer's package.json. What
386
+ // author, so the zone is the `timezone` CARRIER, read from the project's paperlint.json. What
387
387
  // the PRESCRIPTION in §3 must show is therefore not anybody's real zone but the documented
388
388
  // DEFAULT, next to the instruction telling the model to substitute the declared one.
389
389
  //
@@ -53,8 +53,8 @@ process.exit(
53
53
  edits: [
54
54
  [
55
55
  SKILL,
56
- "(require('./package.json'))?.timezone ?? 'UTC'",
57
- "(require('./package.json'))?.papersDir ?? 'papers'",
56
+ "require('./paperlint.json')?.timezone } catch {} })() ?? 'UTC'",
57
+ "require('./paperlint.json')?.papersDir } catch {} })() ?? 'papers'",
58
58
  ],
59
59
  ],
60
60
  harness: HARNESS,
@@ -348,9 +348,8 @@ fi
348
348
  # and it does it WIDER: the block here skipped review mode, the rule judges the artifact always. Two
349
349
  # sources of truth about one fact drift apart — so the duplicate was deleted, not kept "just in case".
350
350
  #
351
- # 🔴 The condition under which this becomes a loss: the rule looks at papers that declared their venue
352
- # in `venue.json`. Should a paper appear with a build but without `venue.json`, nobody will check its
353
- # fonts. Today there are none (all three real papers are declared).
351
+ # 🔴 The condition under which this becomes a loss: the rule looks at papers whose `paperlint.json`
352
+ # names a venue preset. A paper with a build but no preset gets its fonts checked by nobody.
354
353
 
355
354
  # --- chktex, if installed: LaTeX-source typography the log cannot see ---
356
355
  # Adopted 2026-08-24 instead of writing our own. Measured first: out of the box it produced 28
@@ -36,8 +36,8 @@ import { dirname, join } from "node:path";
36
36
  import { checkSkill } from "../../lib/skill-checks.mjs";
37
37
  import { papersRoot } from "../../eslint-rules/papers.mjs";
38
38
  import {
39
- consumerPkg,
40
39
  consumerRoot,
40
+ settingsOf,
41
41
  } from "../paper-pipeline/scripts/consumer.mjs";
42
42
 
43
43
  await checkSkill("render-paper");
@@ -308,7 +308,7 @@ await checkSkill("render-paper");
308
308
  // and say so out loud.
309
309
  const root = (() => {
310
310
  try {
311
- return papersRoot(consumerPkg(), consumerRoot());
311
+ return papersRoot(settingsOf(consumerRoot()), consumerRoot());
312
312
  } catch {
313
313
  return null;
314
314
  }
@@ -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(