paperlint 7.2.0 → 8.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 (156) hide show
  1. package/dist/build.d.ts.map +1 -1
  2. package/dist/build.js +1 -6
  3. package/dist/build.js.map +1 -1
  4. package/dist/cli.d.ts +3 -4
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +13 -42
  7. package/dist/cli.js.map +1 -1
  8. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  9. package/dist/eslint-rules/latex-language.js +10 -13
  10. package/dist/eslint-rules/latex-language.js.map +1 -1
  11. package/dist/eslint-rules/paper-context.js +2 -2
  12. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  13. package/dist/eslint-rules/paper-typography.js +22 -91
  14. package/dist/eslint-rules/paper-typography.js.map +1 -1
  15. package/dist/eslint-rules/papers.d.ts +1 -3
  16. package/dist/eslint-rules/papers.d.ts.map +1 -1
  17. package/dist/eslint-rules/papers.js +2 -4
  18. package/dist/eslint-rules/papers.js.map +1 -1
  19. package/dist/eslint-rules/tex-build.d.ts.map +1 -1
  20. package/dist/eslint-rules/tex-build.js +8 -25
  21. package/dist/eslint-rules/tex-build.js.map +1 -1
  22. package/dist/init.d.ts +1 -4
  23. package/dist/init.d.ts.map +1 -1
  24. package/dist/init.js +3 -9
  25. package/dist/init.js.map +1 -1
  26. package/dist/lib/markdown.d.ts +5 -81
  27. package/dist/lib/markdown.d.ts.map +1 -1
  28. package/dist/lib/markdown.js +5 -116
  29. package/dist/lib/markdown.js.map +1 -1
  30. package/dist/lib/paper-config.d.ts.map +1 -1
  31. package/dist/lib/paper-config.js +0 -2
  32. package/dist/lib/paper-config.js.map +1 -1
  33. package/dist/lib/skill-eval-fixture.d.ts +6 -4
  34. package/dist/lib/skill-eval-fixture.d.ts.map +1 -1
  35. package/dist/lib/skill-eval-fixture.js +25 -18
  36. package/dist/lib/skill-eval-fixture.js.map +1 -1
  37. package/dist/new-paper.d.ts +5 -11
  38. package/dist/new-paper.d.ts.map +1 -1
  39. package/dist/new-paper.js +12 -23
  40. package/dist/new-paper.js.map +1 -1
  41. package/dist/paper-files.d.ts +4 -4
  42. package/dist/paper-prose.d.ts +17 -0
  43. package/dist/paper-prose.d.ts.map +1 -0
  44. package/dist/paper-prose.js +46 -0
  45. package/dist/paper-prose.js.map +1 -0
  46. package/dist/structure.d.ts.map +1 -1
  47. package/dist/structure.js +4 -3
  48. package/dist/structure.js.map +1 -1
  49. package/dist/types.d.ts +0 -2
  50. package/dist/types.d.ts.map +1 -1
  51. package/docs/configuration.md +7 -9
  52. package/docs/e2e.md +4 -6
  53. package/docs/prior-art/paper-folder-scaffolding.md +24 -29
  54. package/docs/rules/tex/claim-provenance.md +0 -1
  55. package/docs/rules.md +2 -6
  56. package/eslint-rules/latex-language.harness.mjs +3 -3
  57. package/eslint-rules/latex-language.ts +10 -13
  58. package/eslint-rules/paper-context.ts +2 -2
  59. package/eslint-rules/paper-typography.test.mjs +17 -85
  60. package/eslint-rules/paper-typography.ts +22 -98
  61. package/eslint-rules/papers.harness.mjs +1 -1
  62. package/eslint-rules/papers.ts +2 -4
  63. package/eslint-rules/tex-build.harness.mjs +2 -2
  64. package/eslint-rules/tex-build.ts +8 -25
  65. package/eslint.config.mjs +0 -1
  66. package/hooks/hooks.harness.ts +15 -15
  67. package/hooks/paper-edit-guard.hook.mjs +5 -8
  68. package/hooks/paper-edit-guard.test.ts +12 -3
  69. package/hooks/paper-skills-nudge.hook.mjs +2 -3
  70. package/hooks/paper-skills-nudge.test.ts +13 -3
  71. package/hooks/paper-status-gates.hook.mjs +2 -2
  72. package/hooks/paper-status-gates.test.ts +14 -2
  73. package/lib/markdown.test.mjs +5 -60
  74. package/lib/markdown.ts +9 -136
  75. package/lib/paper-config.ts +0 -2
  76. package/lib/skill-checks.test.mjs +4 -1
  77. package/lib/skill-eval-fixture.ts +25 -18
  78. package/package.json +1 -3
  79. package/presets/realm.jsonc +1 -1
  80. package/scripts/harness-api.frozen.json +0 -5
  81. package/skills/cold-read-diff/SKILL.md +3 -3
  82. package/skills/cold-read-diff/SKILL.md.spec.ts +2 -2
  83. package/skills/grade-paper-writing/SKILL.md +14 -22
  84. package/skills/grade-paper-writing/SKILL.md.spec.ts +13 -21
  85. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +40 -26
  86. package/skills/grade-paper-writing/prose-lint.mjs +108 -278
  87. package/skills/grade-paper-writing/prose-lint.test.ts +184 -81
  88. package/skills/paper-adversarial-review/SKILL.md +2 -2
  89. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
  90. package/skills/paper-pipeline/SKILL.md +9 -12
  91. package/skills/paper-pipeline/SKILL.md.spec.ts +8 -11
  92. package/skills/paper-pipeline/description-language.eval.mjs +25 -15
  93. package/skills/paper-pipeline/framing-vs-vocabulary.eval.mjs +25 -15
  94. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +25 -15
  95. package/skills/paper-pipeline/pipeline-firing.eval.mjs +27 -16
  96. package/skills/paper-pipeline/pipeline-language.eval.mjs +25 -15
  97. package/skills/paper-pipeline/references/acceptance-gate.md +16 -20
  98. package/skills/paper-pipeline/references/artifact-checklist.md +4 -5
  99. package/skills/paper-pipeline/references/pipeline-status-template.md +1 -1
  100. package/skills/paper-pipeline/scripts/README.md +0 -4
  101. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +8 -72
  102. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +48 -156
  103. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +81 -75
  104. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +3 -3
  105. package/skills/paper-status/SKILL.md +2 -2
  106. package/skills/paper-status/SKILL.md.spec.ts +1 -1
  107. package/skills/render-paper/SKILL.md +2 -2
  108. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  109. package/skills/render-paper/extract-pdf-facts.mjs +2 -2
  110. package/skills/tighten-paper/SKILL.md +29 -42
  111. package/skills/tighten-paper/SKILL.md.spec.ts +28 -41
  112. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +0 -13
  113. package/skills/verify-citations/scripts/bib-authors.cli.test.ts +23 -11
  114. package/skills/verify-citations/scripts/bib-authors.mjs +8 -93
  115. package/skills/verify-citations/scripts/bib-authors.test.mjs +1 -44
  116. package/src/build.harness.mjs +2 -2
  117. package/src/build.test.ts +1 -1
  118. package/src/build.ts +1 -6
  119. package/src/cli.harness.mjs +26 -46
  120. package/src/cli.test.ts +32 -40
  121. package/src/cli.ts +14 -54
  122. package/src/folder-venue-rule.test.ts +1 -1
  123. package/src/init.test.ts +6 -7
  124. package/src/init.ts +3 -22
  125. package/src/lint-fix.test.ts +10 -4
  126. package/src/new-paper.harness.mjs +9 -28
  127. package/src/new-paper.test.ts +6 -6
  128. package/src/new-paper.ts +11 -30
  129. package/src/paper-files.test.ts +5 -4
  130. package/src/paper-files.ts +4 -4
  131. package/src/paper-prose.test.ts +49 -0
  132. package/src/paper-prose.ts +67 -0
  133. package/src/parse-args.test.ts +0 -1
  134. package/src/structure.harness.ts +5 -11
  135. package/src/structure.ts +4 -3
  136. package/src/types.ts +0 -2
  137. package/fixtures/real-markdown-paper/baseline.json +0 -22
  138. package/fixtures/real-markdown-paper/baseline.ts +0 -70
  139. package/skills/paper-pipeline/PIPELINE-MAP.md +0 -381
  140. package/skills/paper-pipeline/pipeline-firing.baseline.json +0 -270
  141. package/skills/paper-pipeline/references/sweep-2026-08-09-ideal-pipeline.md +0 -585
  142. package/skills/paper-pipeline/scripts/artifact-coverage.harness.mjs +0 -492
  143. package/skills/paper-pipeline/scripts/artifact-coverage.mjs +0 -397
  144. package/skills/paper-pipeline/scripts/artifact-coverage.test.ts +0 -86
  145. package/skills/paper-pipeline/scripts/check-provenance.mjs +0 -184
  146. package/skills/paper-pipeline/scripts/population-map.harness.mjs +0 -135
  147. package/skills/paper-pipeline/scripts/population-map.mjs +0 -188
  148. package/skills/paper-pipeline/scripts/population-map.test.mjs +0 -154
  149. package/skills/paper-pipeline/scripts/provenance.harness.mjs +0 -266
  150. package/skills/paper-pipeline/scripts/round-diff.harness.ts +0 -907
  151. package/skills/paper-pipeline/scripts/round-diff.mjs +0 -583
  152. package/skills/paper-pipeline/scripts/round-diff.test.mjs +0 -159
  153. package/skills/tighten-paper/__snapshots__/structure.test.ts.snap +0 -133
  154. package/skills/tighten-paper/structure.mjs +0 -364
  155. package/skills/tighten-paper/structure.test.ts +0 -117
  156. package/templates/paper/paper.md +0 -4
@@ -3,34 +3,32 @@
3
3
  * extract-ref-facts.mjs — parse a paper's bibliography, ask the registries and write FACTS to JSON.
4
4
  * It judges nothing.
5
5
  *
6
- * Usage: node extract-ref-facts.mjs <paper dir|paper.md|refs.bib> [--offline] [--refresh]
6
+ * Usage: node extract-ref-facts.mjs <paper dir|paper.tex|refs.bib> [--offline] [--refresh]
7
7
  * [--cache=PATH] [--out=PATH] [--quiet]
8
- * Exit: 0 — facts written · 1 — nothing to write (no source, no entries).
8
+ * Exit: 0 — facts written · 1 — nothing to write (no source, a source that is neither a .bib nor
9
+ * a .tex, no entries).
9
10
  *
10
11
  * WHY THE SPLIT. Before 2026-08-26 measurement and judgement lived in one script
11
12
  * (`verify-refs.mjs`, 20 emit sites, 8 kinds of findings) — that is, on the fifth rung of the
12
13
  * ladder in `the consumer's papers CLAUDE.md`. The judgement moved into ESLint rules
13
14
  * (`eslint-rules/ref-facts.mjs`): a registry of rules, severity from config, `eslint-disable` with a
14
- * reason, `file:line:col` positions. What stayed here is the plumbing: parsing markup, parsing
15
- * `.bib`, the network, parsing the registries' responses.
15
+ * reason, `file:line:col` positions. What stayed here is the plumbing: parsing `.bib`, the network,
16
+ * parsing the registries' responses.
16
17
  *
17
18
  * The precedent in this same knowledge base is `render-paper/extract-pdf-facts.mjs` +
18
19
  * `eslint-rules/pdf-facts.mjs`. The boundary is exactly the same: the script goes out into the
19
20
  * world, the rule passes the judgement.
20
21
  *
21
- * 🔴 WHY THE RULE CANNOT LIVE OVER THE PAPER ITSELF. Of the three papers in the knowledge base one
22
- * has a `paper.md` (`<paper-a>`); in `<paper-b>` and `<paper-c>` the bibliography is `refs.bib`, and
23
- * no ESLint language plugin parses `.bib`. The common input that can do both is the facts JSON.
24
- * The price is known and written down: a finding is addressed into the facts, not into a line of
25
- * `paper.md`; that is why every entry in the facts carries the `line` of its source, and the rule
26
- * prints it in the message.
22
+ * 🔴 WHY THE RULE CANNOT LIVE OVER THE BIBLIOGRAPHY ITSELF. No ESLint language plugin parses
23
+ * `.bib`, so the rule's input is the facts JSON. The price is known and written down: a finding is
24
+ * addressed into the facts, not into a line of the `.bib`; that is why every entry in the facts
25
+ * carries the `line` of its source, and the rule prints it in the message.
27
26
  *
28
27
  * 🔴 WHAT THIS MOVE FIXED (both legs were DEAD, measured 2026-08-26):
29
28
  *
30
- * 1. `.bib` WAS NEVER OPENED. `resolveSource` walked `paper.md` → `draft.md` →
31
- * `build/custom.bib`; `refs.bib` was not on the list, although the error text promised
32
- * "or .bib". The run: `verify-refs.mjs <papers-root>/<paper-b>` →
33
- * "no paper.md, draft.md or .bib … nothing to check".
29
+ * 1. `.bib` WAS NEVER OPENED. `resolveSource` did not list `refs.bib`, although the error text
30
+ * promised "or .bib". The run: `verify-refs.mjs <papers-root>/<paper-b>` →
31
+ * "… or .bib … nothing to check".
34
32
  * 2. THE `.bib` PARSER DID NOT TAKE THE REAL FILES. The regex `/@\w+\{([^,]+),([\s\S]*?)\n\}/g`
35
33
  * required a `}` on a line of its own, while both of our `refs.bib` close an entry on the line
36
34
  * of the last field (`note={arXiv:2310.06770}}`). A run straight at the file: "parsed 0
@@ -71,14 +69,9 @@ import {
71
69
  } from "node:fs";
72
70
  import { createHash } from "node:crypto";
73
71
  import { join, resolve, dirname, basename } from "node:path";
74
- import { headings as mdHeadings, requireMarkdown } from "#lib/markdown";
72
+ import { bibRange } from "#eslint-rules/paper-typography";
75
73
  import { isMain } from "./consumer.mjs";
76
74
 
77
- // Markup is parsed with a parser (`CLAUDE.md`, 2026-08-11). We fail rather than degrade: without
78
- // the parser the reference list would not be found at all, and the facts would come out empty —
79
- // that is, a clean verdict about a paper that in fact has forty references.
80
- requireMarkdown();
81
-
82
75
  const ROOT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
83
76
  const CROSSREF = "https://api.crossref.org/works/";
84
77
  const ARXIV = "https://export.arxiv.org/api/query?id_list=";
@@ -89,135 +82,12 @@ const UA =
89
82
 
90
83
  export const SCHEMA = 1;
91
84
 
92
- // ── parsing: the reference list in markdown ──────────────────────────────────
93
- //
94
- // The same shape that `repro/md2submission.py` reads, deliberately: that script turns the list into
95
- // the .bib actually uploaded to the venue, so whatever it counts as an entry is an entry.
96
- // `^\d{1,2}. ` opens an entry, an indent continues it, a blank line closes it.
97
-
98
- const REF_OPEN = /^(\d{1,2})\.\s+(.*)$/;
99
- /** Working notes that never reach the PDF; md2submission strips them, so we do too. */
100
- const BRACKET_NOTE = /\[(VERIFY|ANONYMIZ)[^\]]*\]/g;
101
-
102
- export function parseMarkdownRefs(text) {
103
- // The section is cut by the parser. The former `text.split(/^## References\s*$/m)[1]` opened the
104
- // reference list on a `## References` quoted inside a ```-block — and in this repo papers quote
105
- // their own markup in chunks.
106
- const refsHs = mdHeadings(text).filter(
107
- (h) => h.depth === 2 && /^References\s*$/u.test(h.text),
108
- );
109
- if (refsHs.length === 0) return [];
110
- const nl = text.indexOf("\n", refsHs[0].offset);
111
- const body = text.slice(
112
- nl === -1 ? text.length : nl,
113
- refsHs[1] ? refsHs[1].offset : text.length,
114
- );
115
- // The line where the body starts in the source file — so that an entry has an ADDRESS in
116
- // `paper.md` and not just a number in a list. The rule prints it in the message: the facts file
117
- // lives in `_build/`, and without the source line a finding would have to be hunted for by eye.
118
- const bodyLine = text
119
- .slice(0, nl === -1 ? text.length : nl)
120
- .split("\n").length;
121
- // A heading closes the list — appendices come AFTER the bibliography, and without this their
122
- // numbered prose would be parsed as references. The line numbers come from the parser:
123
- // `/^#{1,6}\s/` also counted a hash inside a ```-block as a heading.
124
- const headingLines = new Set(mdHeadings(body).map((h) => h.line));
125
- const entries = [];
126
- let cur = null;
127
- let buf = [];
128
- let curLine = 0;
129
- const flush = () => {
130
- if (cur !== null)
131
- entries.push({
132
- n: cur,
133
- line: curLine,
134
- raw: buf.join(" ").replace(BRACKET_NOTE, "").trim(),
135
- });
136
- cur = null;
137
- buf = [];
138
- };
139
- const lns = body.split("\n");
140
- for (let i = 0; i < lns.length; i++) {
141
- const ln = lns[i];
142
- if (headingLines.has(i)) break;
143
- const m = REF_OPEN.exec(ln);
144
- if (m) {
145
- flush();
146
- cur = Number(m[1]);
147
- curLine = bodyLine + i;
148
- buf = [m[2].trim()];
149
- } else if (cur !== null && ln.trim()) buf.push(ln.trim());
150
- else if (cur !== null) flush();
151
- }
152
- flush();
153
- return entries.map(splitEntry).filter(Boolean);
154
- }
155
-
156
- /** `A, B, C. *Title.* rest` → authors / title / remainder — the shape md2submission produces. */
157
- function splitEntry(e) {
158
- const base = { n: e.n, line: e.line, key: null, raw: e.raw };
159
- const mt = /\*(.+?)\*/.exec(e.raw);
160
- if (!mt)
161
- return {
162
- ...base,
163
- authors: [],
164
- truncated: false,
165
- title: null,
166
- year: yearIn(e.raw),
167
- venue_text: e.raw,
168
- };
169
- const title = mt[1].trim().replace(/\.$/, "");
170
- const rawAuthors = splitAuthors(e.raw.slice(0, mt.index));
171
- const note = e.raw
172
- .slice(mt.index + mt[0].length)
173
- .trim()
174
- .replace(/^,\s*/, "");
175
- const { authors, truncated } = dropEtAl(rawAuthors);
176
- return {
177
- ...base,
178
- authors,
179
- truncated,
180
- title,
181
- year: yearIn(note) ?? yearIn(e.raw),
182
- venue_text: note,
183
- };
184
- }
185
-
186
- function splitAuthors(a) {
187
- const s = a
188
- .trim()
189
- .replace(/\.\s*$/, "")
190
- .trim();
191
- if (!s) return [];
192
- return s
193
- .split(",")
194
- .map((p) => p.trim())
195
- .filter(Boolean);
196
- }
197
-
198
- /**
199
- * `Z. Xiang et al.` — the entry deliberately gives a PREFIX of the author list. That is a fact about
200
- * the entry, not a judgement, so the truncation is removed here and the rule receives an honest
201
- * prefix plus a flag.
202
- */
203
- const ET_AL = /\bet\s+al\.?$/i;
204
- function dropEtAl(authors) {
205
- if (!authors.length || !ET_AL.test(authors[authors.length - 1].trim()))
206
- return { authors, truncated: false };
207
- const out = authors.slice();
208
- out[out.length - 1] = out[out.length - 1].replace(ET_AL, "").trim();
209
- return { authors: out.filter(Boolean), truncated: true };
210
- }
211
-
212
- const yearIn = (s) => (/\b(19|20)\d{2}\b/.exec(s) ?? [null])[0];
213
-
214
85
  // ── parsing: .bib WITH A REAL PARSER ─────────────────────────────────────────
215
86
 
216
87
  /**
217
- * The name order is normalised to "First name Surname" — the same shape in which authors are written
218
- * in markdown. Then the rule has ONE `surname()` function (the last alphabetic token) for both
219
- * sources instead of two branches. Normalising the format is the parser's job; comparing is the
220
- * rule's job.
88
+ * The name order is normalised to "First name Surname". Then the rule has ONE `surname()` function
89
+ * (the last alphabetic token). Normalising the format is the parser's job; comparing is the rule's
90
+ * job.
221
91
  *
222
92
  * 🔴 TWO SHAPES, AND BOTH ARE LISTED EXPLICITLY. The parser returns an institution
223
93
  * (`author={{Adversa AI}}` — the double brace means "one name as a whole, do not split") as
@@ -527,27 +397,41 @@ export function recordFrom(key, cached) {
527
397
 
528
398
  // ── assembling the facts ─────────────────────────────────────────────────────
529
399
 
530
- /** `paper.md` → `draft.md` → `refs.bib` → `build/custom.bib`. `refs.bib` ADDED 26.08 (defect #1). */
531
- export const SOURCE_ORDER = [
532
- "paper.md",
533
- "draft.md",
534
- "refs.bib",
535
- "build/custom.bib",
536
- ];
400
+ /**
401
+ * `paper.tex` when it embeds its `.bib` in `filecontents` → `refs.bib` → `build/custom.bib`.
402
+ * `refs.bib` ADDED 26.08 (defect #1). The embedded one comes first, as in `paperlint build`
403
+ * (`bibliographyOf`, src/references.ts): the author edits it there, and the `refs.bib` LaTeX
404
+ * writes out of it is missing before a build and stale after an edit.
405
+ */
406
+ export const SOURCE_ORDER = ["paper.tex", "refs.bib", "build/custom.bib"];
407
+
408
+ /** Whether `p` is a source: any `.bib`, and a `.tex` only when it carries a `.bib`. */
409
+ const isSource = (p) =>
410
+ !p.endsWith(".tex") || bibRange(readFileSync(p, "utf8")) !== null;
537
411
 
538
412
  export function resolveSource(target) {
539
413
  const t = resolve(target);
540
414
  if (existsSync(t) && statSync(t).isFile()) return t;
541
415
  for (const c of SOURCE_ORDER) {
542
416
  const p = join(t, c);
543
- if (existsSync(p)) return p;
417
+ if (existsSync(p) && isSource(p)) return p;
544
418
  }
545
419
  return null;
546
420
  }
547
421
 
422
+ /**
423
+ * The entries of a `.bib`, or of the `.bib` a `.tex` embeds (none when it embeds none) — each
424
+ * entry's `line` its line in the file read, so a finding points where the author edits.
425
+ */
548
426
  export async function loadEntries(path) {
549
427
  const text = readFileSync(path, "utf8");
550
- return path.endsWith(".bib") ? parseBib(text) : parseMarkdownRefs(text);
428
+ if (!path.endsWith(".tex")) return parseBib(text);
429
+ const bib = bibRange(text);
430
+ if (bib === null) return [];
431
+ const above = text.slice(0, bib.bodyStart).split("\n").length - 1;
432
+ return (await parseBib(bib.body)).map((e) =>
433
+ e.line === 0 ? e : { ...e, line: e.line + above },
434
+ );
551
435
  }
552
436
 
553
437
  /** Every registry key a list of entries needs. Computed once so the requests go in one batch. */
@@ -571,7 +455,7 @@ export function buildFacts({ source, text, entries, cache, cachePath }) {
571
455
  return {
572
456
  schema: SCHEMA,
573
457
  source: rel(source),
574
- source_kind: source.endsWith(".bib") ? "bibtex" : "markdown",
458
+ source_kind: "bibtex",
575
459
  source_sha256: createHash("sha256").update(text).digest("hex"),
576
460
  cache: cachePath ? rel(cachePath) : null,
577
461
  generated: new Date().toISOString().slice(0, 10),
@@ -601,6 +485,14 @@ async function main(argv) {
601
485
  );
602
486
  return 1;
603
487
  }
488
+ // A paper's references are read from a `.bib`, or the one a `.tex` embeds: any other file
489
+ // parsed as BibTeX would report zero entries for the wrong reason.
490
+ if (!src.endsWith(".bib") && !src.endsWith(".tex")) {
491
+ console.error(
492
+ `🛑 ${rel(src)} is neither a .bib nor a .tex — the bibliography is read from ${SOURCE_ORDER.join(", ")}.`,
493
+ );
494
+ return 1;
495
+ }
604
496
  const paperDir = statSync(resolve(target)).isFile()
605
497
  ? dirname(src)
606
498
  : resolve(target);
@@ -1,6 +1,6 @@
1
1
  /**
2
- * extract-ref-facts.mjs beyond its harness: the edges of each reader (markdown list, .bib, CrossRef,
3
- * arXiv, cache) and the command itself — sources, flags, the network loop (with `fetch` stubbed in
2
+ * extract-ref-facts.mjs beyond its harness: the edges of each reader (.bib, CrossRef, arXiv,
3
+ * cache) and the command itself — sources, flags, the network loop (with `fetch` stubbed in
4
4
  * the child process), and the refusals.
5
5
  */
6
6
  import assert from "node:assert/strict";
@@ -15,7 +15,6 @@ import {
15
15
  loadCache,
16
16
  loadEntries,
17
17
  parseBib,
18
- parseMarkdownRefs,
19
18
  primaryOf,
20
19
  readArxiv,
21
20
  readCrossref,
@@ -29,68 +28,6 @@ const SCRIPT = join(
29
28
  );
30
29
  const root = useTempDir("extract-ref-facts-");
31
30
 
32
- test("markdown: the list ends at the file's end or a second References heading; lines continue an entry", () => {
33
- assert.deepEqual(parseMarkdownRefs("# P\n\n## References"), []);
34
- const refs = parseMarkdownRefs(
35
- [
36
- "## References",
37
- "",
38
- "1. A. Author, B. Author. *A Title.* Venue,",
39
- " continued 2021.",
40
- "2. *Untitled Authors.* 2019 note",
41
- "3. C. Writer 2018. *Late Year.* Venue",
42
- "4. No title here at all",
43
- "",
44
- "## References",
45
- "5. Never read. *X.* 2000",
46
- ].join("\n"),
47
- );
48
- assert.deepEqual(
49
- refs.map(({ n, line, authors, title, year, venue_text }) => ({
50
- n,
51
- line,
52
- authors,
53
- title,
54
- year,
55
- venue_text,
56
- })),
57
- [
58
- {
59
- n: 1,
60
- line: 3,
61
- authors: ["A. Author", "B. Author"],
62
- title: "A Title",
63
- year: "2021",
64
- venue_text: "Venue, continued 2021.",
65
- },
66
- {
67
- n: 2,
68
- line: 5,
69
- authors: [],
70
- title: "Untitled Authors",
71
- year: "2019",
72
- venue_text: "2019 note",
73
- },
74
- {
75
- n: 3,
76
- line: 6,
77
- authors: ["C. Writer 2018"],
78
- title: "Late Year",
79
- year: "2018",
80
- venue_text: "Venue",
81
- },
82
- {
83
- n: 4,
84
- line: 7,
85
- authors: [],
86
- title: null,
87
- year: null,
88
- venue_text: "No title here at all",
89
- },
90
- ],
91
- );
92
- });
93
-
94
31
  test(".bib: a key the text spells with spaces has no line; a missing title or year is null, not empty", async () => {
95
32
  const [entry] = await parseBib(
96
33
  "@misc{ spaced ,\n note={see arXiv:2101.00001}\n}\n",
@@ -269,9 +206,11 @@ const CROSSREF_OK = JSON.stringify({
269
206
  },
270
207
  });
271
208
  writeTree(root, {
272
- "paper/paper.md":
273
- "## References\n\n1. A. A. *Found.* doi:10.1234/ok\n2. B. B. *Lost.* doi:10.1234/down\n",
274
- "empty/paper.md": "# Nothing cited\n",
209
+ "paper/refs.bib":
210
+ "@misc{a, author={A. A}, title={Found}, note={doi:10.1234/ok}}\n" +
211
+ "@misc{b, author={B. B}, title={Lost}, note={doi:10.1234/down}}\n",
212
+ "empty/refs.bib": "% nothing cited\n",
213
+ "notes/notes.txt": "A. A. Found. doi:10.1234/ok\n",
275
214
  // `fetch` in the child: 10.1234/down fails at the transport, anything else is a CrossRef hit.
276
215
  "fetch.mjs":
277
216
  `globalThis.fetch = async (url) => {\n` +
@@ -286,6 +225,18 @@ writeTree(root, {
286
225
  "import { register } from 'node:module';\n" +
287
226
  "register('data:text/javascript,' + encodeURIComponent(\"export async function resolve(s, c, next) { if (s === '@retorquere/bibtex-parser') throw Object.assign(new Error('broken install'), { code: 'EACCES' }); return next(s, c); }\"));\n",
288
227
  "bib/refs.bib": "@misc{k, title={T}, note={doi:10.1234/ok}}\n",
228
+ // The bibliography is embedded in paper.tex; the refs.bib beside it is a stale build output.
229
+ "inline/paper.tex":
230
+ "\\documentclass{article}\n" +
231
+ "\\begin{filecontents*}[overwrite]{refs.bib}\n" +
232
+ "@misc{a, author={A. A}, title={Found}, note={doi:10.1234/ok}}\n" +
233
+ "@misc{b, author={B. B}, title={Lost}, note={doi:10.1234/down}}\n" +
234
+ "\\end{filecontents*}\n\\begin{document}x\\end{document}\n",
235
+ "inline/refs.bib": "@misc{stale, title={Stale}, note={doi:10.1234/old}}\n",
236
+ // A paper.tex with no embedded .bib is not a source: the refs.bib beside it is.
237
+ "plain/paper.tex":
238
+ "\\documentclass{article}\\begin{document}x\\end{document}\n",
239
+ "plain/refs.bib": "@misc{k, title={T}, note={doi:10.1234/ok}}\n",
289
240
  });
290
241
  const cli = (args, { cwd = root, nodeArgs = [] } = {}) =>
291
242
  runNode(SCRIPT, args, {
@@ -309,7 +260,7 @@ test("online: hits are fetched and cached, a transport failure is named and stay
309
260
  {
310
261
  status: 0,
311
262
  stdout:
312
- "📚 paper.md → paper/_build/refs.facts.json (2 entries, 2 identifiers, 1 resolved, +1 fetched)\n",
263
+ "📚 refs.bib → paper/_build/refs.facts.json (2 entries, 2 identifiers, 1 resolved, +1 fetched)\n",
313
264
  stderr: " … doi:10.1234/down: network down\n",
314
265
  cached: ["doi:10.1234/ok"],
315
266
  },
@@ -321,7 +272,7 @@ test("online again: cached keys are not refetched; --quiet hides the failure; --
321
272
  assert.deepEqual(again, {
322
273
  status: 0,
323
274
  stdout:
324
- "📚 paper.md → paper/_build/refs.facts.json (2 entries, 2 identifiers, 1 resolved, all from cache)\n",
275
+ "📚 refs.bib → paper/_build/refs.facts.json (2 entries, 2 identifiers, 1 resolved, all from cache)\n",
325
276
  stderr: "",
326
277
  });
327
278
  const refresh = cli(["paper", "--refresh", "--quiet"], { nodeArgs: FETCH });
@@ -330,7 +281,7 @@ test("online again: cached keys are not refetched; --quiet hides the failure; --
330
281
 
331
282
  test("the paper file itself as the target, with --cache, --out and --offline", () => {
332
283
  const r = cli([
333
- join(root, "paper/paper.md"),
284
+ join(root, "paper/refs.bib"),
334
285
  "--offline",
335
286
  `--cache=${join(root, "paper/repro/refs-cache.json")}`,
336
287
  `--out=${join(root, "out/facts.json")}`,
@@ -338,7 +289,7 @@ test("the paper file itself as the target, with --cache, --out and --offline", (
338
289
  assert.deepEqual(r, {
339
290
  status: 0,
340
291
  stdout:
341
- "📚 paper.md → out/facts.json (2 entries, 2 identifiers, 1 resolved, offline)\n",
292
+ "📚 refs.bib → out/facts.json (2 entries, 2 identifiers, 1 resolved, offline)\n",
342
293
  stderr: "",
343
294
  });
344
295
  });
@@ -348,17 +299,24 @@ test("no target: the current directory", () => {
348
299
  assert.equal(r.status, 0, r.stderr);
349
300
  });
350
301
 
351
- test("refusals: nowhere to read from, and zero entries (not written as a clean bibliography)", () => {
302
+ test("refusals: nowhere to read from, a file that is not a .bib, and zero entries (not written as a clean bibliography)", () => {
352
303
  assert.deepEqual(cli([join(root, "nope"), "--offline"]), {
353
304
  status: 1,
354
305
  stdout: "",
355
- stderr: `🛑 no paper.md, draft.md, refs.bib, build/custom.bib under ${join(root, "nope")} — nowhere to take a bibliography from.\n`,
306
+ stderr: `🛑 no paper.tex, refs.bib, build/custom.bib under ${join(root, "nope")} — nowhere to take a bibliography from.\n`,
356
307
  });
357
308
  assert.deepEqual(cli(["empty", "--offline"]), {
358
309
  status: 1,
359
310
  stdout: "",
360
311
  stderr:
361
- "🛑 parsed 0 entries out of empty/paper.md. Silence here would look like a clean bibliography.\n",
312
+ "🛑 parsed 0 entries out of empty/refs.bib. Silence here would look like a clean bibliography.\n",
313
+ });
314
+ // Guards: a file that is neither a .bib nor a .tex is named, not parsed as BibTeX, and nothing is written.
315
+ assert.deepEqual(cli([join(root, "notes/notes.txt"), "--offline"]), {
316
+ status: 1,
317
+ stdout: "",
318
+ stderr:
319
+ "🛑 notes/notes.txt is neither a .bib nor a .tex — the bibliography is read from paper.tex, refs.bib, build/custom.bib.\n",
362
320
  });
363
321
  });
364
322
 
@@ -378,3 +336,51 @@ test(".bib without its optional parser: the error names the install; any other f
378
336
  assert.match(broken.stderr, /broken install/);
379
337
  assert.doesNotMatch(broken.stderr, /npm i -D/);
380
338
  });
339
+
340
+ test("🔴 a paper directory whose paper.tex embeds its .bib: the embedded one is read, not a stale refs.bib", () => {
341
+ const r = cli(["inline", "--offline"]);
342
+ // Guards: only materialized .bib files were looked for, so a paper with its bibliography in
343
+ // `filecontents` was refused before a build and read from a stale refs.bib after an edit.
344
+ assert.deepEqual(r, {
345
+ status: 0,
346
+ stdout:
347
+ "📚 paper.tex → inline/_build/refs.facts.json (2 entries, 2 identifiers, 0 resolved, offline)\n",
348
+ stderr: "",
349
+ });
350
+ const facts = JSON.parse(
351
+ readFileSync(join(root, "inline/_build/refs.facts.json"), "utf8"),
352
+ );
353
+ // Each entry's line is its line in paper.tex, where the author edits it.
354
+ assert.deepEqual(
355
+ facts.entries.map((e) => [e.key, e.line]),
356
+ [
357
+ ["a", 3],
358
+ ["b", 4],
359
+ ],
360
+ );
361
+ });
362
+
363
+ test("a paper.tex that embeds no .bib: in a directory the refs.bib beside it is read; named alone, it has no entries", () => {
364
+ assert.deepEqual(
365
+ cli(["plain", "--offline"]).stdout,
366
+ "📚 refs.bib → plain/_build/refs.facts.json (1 entries, 1 identifiers, 0 resolved, offline)\n",
367
+ );
368
+ assert.deepEqual(cli([join(root, "plain/paper.tex"), "--offline"]), {
369
+ status: 1,
370
+ stdout: "",
371
+ stderr:
372
+ "🛑 parsed 0 entries out of plain/paper.tex. Silence here would look like a clean bibliography.\n",
373
+ });
374
+ });
375
+
376
+ test("an embedded entry whose key has no line of its own keeps line 0, not a shifted 0", async () => {
377
+ const tex = join(root, "spaced.tex");
378
+ writeFileSync(
379
+ tex,
380
+ "x\n\\begin{filecontents*}{refs.bib}\n@misc{ spaced ,\n note={arXiv:2101.00001}\n}\n\\end{filecontents*}\n",
381
+ );
382
+ assert.deepEqual(
383
+ (await loadEntries(tex)).map((e) => e.line),
384
+ [0],
385
+ );
386
+ });
@@ -68,7 +68,7 @@ function run(dir, flagsOnly = true) {
68
68
  function fixture({ scripts = {}, allow = null, bundle = {}, at = "repro" }) {
69
69
  const d = join(tmp, `f${++n}`);
70
70
  mkdirSync(d, { recursive: true });
71
- writeFileSync(join(d, "paper.md"), "## 1. x\n\nprose\n");
71
+ writeFileSync(join(d, "paper.tex"), "\\section{x}\nprose\n");
72
72
  if (at === null) return d;
73
73
  mkdirSync(join(d, at), { recursive: true });
74
74
  for (const [p, src] of Object.entries(scripts)) {
@@ -142,13 +142,13 @@ function fixture({ scripts = {}, allow = null, bundle = {}, at = "repro" }) {
142
142
  fixture({
143
143
  scripts: {
144
144
  "wc.py":
145
- 'text = open("/home/example/papers/paper.md").read()\nprint(len(text.split()))\n',
145
+ 'text = open("/home/example/papers/paper.tex").read()\nprint(len(text.split()))\n',
146
146
  },
147
147
  }),
148
148
  );
149
149
  assert.match(
150
150
  r.out,
151
- /ABS_PATH\s+repro\/wc\.py — hard-codes 1 absolute path\(s\), e\.g\. `\/home\/example\/papers\/paper\.md`/,
151
+ /ABS_PATH\s+repro\/wc\.py — hard-codes 1 absolute path\(s\), e\.g\. `\/home\/example\/papers\/paper\.tex`/,
152
152
  "a script that only runs on its author's laptop shipped in the reproduction directory unremarked:\n" +
153
153
  r.out,
154
154
  );
@@ -4,7 +4,7 @@ description: Answer "what's the status of the paper?" in one pass — MEASURE wh
4
4
  allowed-tools: [Bash, Read, Grep, Glob]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:cdf5e1fcefdb916d compiled from skills/paper-status/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:c90bd362b565fa22 compiled from skills/paper-status/SKILL.md.spec.ts -->
8
8
 
9
9
  # paper-status — state, measured before it is narrated
10
10
 
@@ -65,7 +65,7 @@ git log --oneline @{u}..HEAD # unpushed commits
65
65
  A gate is stale if the paper changed after the gate ran. Check it mechanically, not by recall:
66
66
 
67
67
  ```bash
68
- git log -1 --format=%cd --date=short -- paper.md # when the text last moved
68
+ git log -1 --format=%cd --date=short -- paper.tex # when the text last moved
69
69
  ls -la reviews/ | tail -20 # when each gate last reported
70
70
  ```
71
71
 
@@ -71,7 +71,7 @@ git log --oneline @{u}..HEAD # unpushed commits
71
71
  A gate is stale if the paper changed after the gate ran. Check it mechanically, not by recall:
72
72
 
73
73
  \`\`\`bash
74
- git log -1 --format=%cd --date=short -- paper.md # when the text last moved
74
+ git log -1 --format=%cd --date=short -- paper.tex # when the text last moved
75
75
  ls -la reviews/ | tail -20 # when each gate last reported
76
76
  \`\`\`
77
77
 
@@ -4,7 +4,7 @@ description: Compile a LaTeX paper (ACM/IEEE/arXiv) to PDF and render its pages
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, SendUserFile]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:5e2ee8850c098375 compiled from skills/render-paper/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:ff8dca1e1be3977e compiled from skills/render-paper/SKILL.md.spec.ts -->
8
8
 
9
9
  # render-paper — .tex → PDF → readable page PNGs
10
10
 
@@ -15,7 +15,7 @@ first try.
15
15
  ## 🔴 This is a COMMAND, not a stage
16
16
 
17
17
  You run it twenty times a day. It has no position in the pipeline and nothing waits its turn to reach
18
- it: **any `.tex` / `paper.md` edit is the trigger** — render, then eyeball pages 1–2 before believing
18
+ it: **any `.tex` edit is the trigger** — render, then eyeball pages 1–2 before believing
19
19
  anything you say about the paper. Scorecard row **`render`** (in the CONTINUOUS section of
20
20
  `paper-pipeline`) records the last render and its page count; that page count is the hard input the
21
21
  `tighten-paper` structure gate is blocked on, which is the only sense in which anything "comes after"
@@ -21,7 +21,7 @@ first try.
21
21
  ## 🔴 This is a COMMAND, not a stage
22
22
 
23
23
  You run it twenty times a day. It has no position in the pipeline and nothing waits its turn to reach
24
- it: **any \`.tex\` / \`paper.md\` edit is the trigger** — render, then eyeball pages 1–2 before believing
24
+ it: **any \`.tex\` edit is the trigger** — render, then eyeball pages 1–2 before believing
25
25
  anything you say about the paper. Scorecard row **\`render\`** (in the CONTINUOUS section of
26
26
  \`paper-pipeline\`) records the last render and its page count; that page count is the hard input the
27
27
  \`tighten-paper\` structure gate is blocked on, which is the only sense in which anything "comes after"
@@ -56,8 +56,8 @@ const ROOT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
56
56
  /**
57
57
  * Accept either a path to a PDF or a PAPER DIRECTORY — and in the second case find the artifact on
58
58
  * its own: the path the paper declares in the `pdf` field of `paperlint.json`, else `paper.pdf` beside
59
- * it. A paper written in markdown and built by its own script into `build/acl_latex.pdf` would
60
- * otherwise be skipped silently. The facts ALWAYS go into `<paper directory>/_build/`, not next to
59
+ * it. A paper built by its own script into `build/acl_latex.pdf` would otherwise be skipped
60
+ * silently. The facts ALWAYS go into `<paper directory>/_build/`, not next to
61
61
  * the PDF, where neither a rule's glob nor a human looks.
62
62
  */
63
63
  export function resolveTarget(arg) {