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.
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +1 -6
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +3 -4
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +13 -42
- package/dist/cli.js.map +1 -1
- package/dist/eslint-rules/latex-language.d.ts.map +1 -1
- package/dist/eslint-rules/latex-language.js +10 -13
- package/dist/eslint-rules/latex-language.js.map +1 -1
- package/dist/eslint-rules/paper-context.js +2 -2
- package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
- package/dist/eslint-rules/paper-typography.js +22 -91
- package/dist/eslint-rules/paper-typography.js.map +1 -1
- package/dist/eslint-rules/papers.d.ts +1 -3
- package/dist/eslint-rules/papers.d.ts.map +1 -1
- package/dist/eslint-rules/papers.js +2 -4
- package/dist/eslint-rules/papers.js.map +1 -1
- package/dist/eslint-rules/tex-build.d.ts.map +1 -1
- package/dist/eslint-rules/tex-build.js +8 -25
- package/dist/eslint-rules/tex-build.js.map +1 -1
- package/dist/init.d.ts +1 -4
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +3 -9
- package/dist/init.js.map +1 -1
- package/dist/lib/markdown.d.ts +5 -81
- package/dist/lib/markdown.d.ts.map +1 -1
- package/dist/lib/markdown.js +5 -116
- package/dist/lib/markdown.js.map +1 -1
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +0 -2
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/lib/skill-eval-fixture.d.ts +6 -4
- package/dist/lib/skill-eval-fixture.d.ts.map +1 -1
- package/dist/lib/skill-eval-fixture.js +25 -18
- package/dist/lib/skill-eval-fixture.js.map +1 -1
- package/dist/new-paper.d.ts +5 -11
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +12 -23
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-files.d.ts +4 -4
- package/dist/paper-prose.d.ts +17 -0
- package/dist/paper-prose.d.ts.map +1 -0
- package/dist/paper-prose.js +46 -0
- package/dist/paper-prose.js.map +1 -0
- package/dist/structure.d.ts.map +1 -1
- package/dist/structure.js +4 -3
- package/dist/structure.js.map +1 -1
- package/dist/types.d.ts +0 -2
- package/dist/types.d.ts.map +1 -1
- package/docs/configuration.md +7 -9
- package/docs/e2e.md +4 -6
- package/docs/prior-art/paper-folder-scaffolding.md +24 -29
- package/docs/rules/tex/claim-provenance.md +0 -1
- package/docs/rules.md +2 -6
- package/eslint-rules/latex-language.harness.mjs +3 -3
- package/eslint-rules/latex-language.ts +10 -13
- package/eslint-rules/paper-context.ts +2 -2
- package/eslint-rules/paper-typography.test.mjs +17 -85
- package/eslint-rules/paper-typography.ts +22 -98
- package/eslint-rules/papers.harness.mjs +1 -1
- package/eslint-rules/papers.ts +2 -4
- package/eslint-rules/tex-build.harness.mjs +2 -2
- package/eslint-rules/tex-build.ts +8 -25
- package/eslint.config.mjs +0 -1
- package/hooks/hooks.harness.ts +15 -15
- package/hooks/paper-edit-guard.hook.mjs +5 -8
- package/hooks/paper-edit-guard.test.ts +12 -3
- package/hooks/paper-skills-nudge.hook.mjs +2 -3
- package/hooks/paper-skills-nudge.test.ts +13 -3
- package/hooks/paper-status-gates.hook.mjs +2 -2
- package/hooks/paper-status-gates.test.ts +14 -2
- package/lib/markdown.test.mjs +5 -60
- package/lib/markdown.ts +9 -136
- package/lib/paper-config.ts +0 -2
- package/lib/skill-checks.test.mjs +4 -1
- package/lib/skill-eval-fixture.ts +25 -18
- package/package.json +1 -3
- package/presets/realm.jsonc +1 -1
- package/scripts/harness-api.frozen.json +0 -5
- package/skills/cold-read-diff/SKILL.md +3 -3
- package/skills/cold-read-diff/SKILL.md.spec.ts +2 -2
- package/skills/grade-paper-writing/SKILL.md +14 -22
- package/skills/grade-paper-writing/SKILL.md.spec.ts +13 -21
- package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +40 -26
- package/skills/grade-paper-writing/prose-lint.mjs +108 -278
- package/skills/grade-paper-writing/prose-lint.test.ts +184 -81
- package/skills/paper-adversarial-review/SKILL.md +2 -2
- package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
- package/skills/paper-pipeline/SKILL.md +9 -12
- package/skills/paper-pipeline/SKILL.md.spec.ts +8 -11
- package/skills/paper-pipeline/description-language.eval.mjs +25 -15
- package/skills/paper-pipeline/framing-vs-vocabulary.eval.mjs +25 -15
- package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +25 -15
- package/skills/paper-pipeline/pipeline-firing.eval.mjs +27 -16
- package/skills/paper-pipeline/pipeline-language.eval.mjs +25 -15
- package/skills/paper-pipeline/references/acceptance-gate.md +16 -20
- package/skills/paper-pipeline/references/artifact-checklist.md +4 -5
- package/skills/paper-pipeline/references/pipeline-status-template.md +1 -1
- package/skills/paper-pipeline/scripts/README.md +0 -4
- package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +8 -72
- package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +48 -156
- package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +81 -75
- package/skills/paper-pipeline/scripts/generated-code.harness.mjs +3 -3
- package/skills/paper-status/SKILL.md +2 -2
- package/skills/paper-status/SKILL.md.spec.ts +1 -1
- package/skills/render-paper/SKILL.md +2 -2
- package/skills/render-paper/SKILL.md.spec.ts +1 -1
- package/skills/render-paper/extract-pdf-facts.mjs +2 -2
- package/skills/tighten-paper/SKILL.md +29 -42
- package/skills/tighten-paper/SKILL.md.spec.ts +28 -41
- package/skills/verify-citations/scripts/bib-authors.check.test.mjs +0 -13
- package/skills/verify-citations/scripts/bib-authors.cli.test.ts +23 -11
- package/skills/verify-citations/scripts/bib-authors.mjs +8 -93
- package/skills/verify-citations/scripts/bib-authors.test.mjs +1 -44
- package/src/build.harness.mjs +2 -2
- package/src/build.test.ts +1 -1
- package/src/build.ts +1 -6
- package/src/cli.harness.mjs +26 -46
- package/src/cli.test.ts +32 -40
- package/src/cli.ts +14 -54
- package/src/folder-venue-rule.test.ts +1 -1
- package/src/init.test.ts +6 -7
- package/src/init.ts +3 -22
- package/src/lint-fix.test.ts +10 -4
- package/src/new-paper.harness.mjs +9 -28
- package/src/new-paper.test.ts +6 -6
- package/src/new-paper.ts +11 -30
- package/src/paper-files.test.ts +5 -4
- package/src/paper-files.ts +4 -4
- package/src/paper-prose.test.ts +49 -0
- package/src/paper-prose.ts +67 -0
- package/src/parse-args.test.ts +0 -1
- package/src/structure.harness.ts +5 -11
- package/src/structure.ts +4 -3
- package/src/types.ts +0 -2
- package/fixtures/real-markdown-paper/baseline.json +0 -22
- package/fixtures/real-markdown-paper/baseline.ts +0 -70
- package/skills/paper-pipeline/PIPELINE-MAP.md +0 -381
- package/skills/paper-pipeline/pipeline-firing.baseline.json +0 -270
- package/skills/paper-pipeline/references/sweep-2026-08-09-ideal-pipeline.md +0 -585
- package/skills/paper-pipeline/scripts/artifact-coverage.harness.mjs +0 -492
- package/skills/paper-pipeline/scripts/artifact-coverage.mjs +0 -397
- package/skills/paper-pipeline/scripts/artifact-coverage.test.ts +0 -86
- package/skills/paper-pipeline/scripts/check-provenance.mjs +0 -184
- package/skills/paper-pipeline/scripts/population-map.harness.mjs +0 -135
- package/skills/paper-pipeline/scripts/population-map.mjs +0 -188
- package/skills/paper-pipeline/scripts/population-map.test.mjs +0 -154
- package/skills/paper-pipeline/scripts/provenance.harness.mjs +0 -266
- package/skills/paper-pipeline/scripts/round-diff.harness.ts +0 -907
- package/skills/paper-pipeline/scripts/round-diff.mjs +0 -583
- package/skills/paper-pipeline/scripts/round-diff.test.mjs +0 -159
- package/skills/tighten-paper/__snapshots__/structure.test.ts.snap +0 -133
- package/skills/tighten-paper/structure.mjs +0 -364
- package/skills/tighten-paper/structure.test.ts +0 -117
- 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.
|
|
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,
|
|
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
|
|
15
|
-
*
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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`
|
|
31
|
-
*
|
|
32
|
-
* "or .bib
|
|
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 {
|
|
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"
|
|
218
|
-
*
|
|
219
|
-
*
|
|
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
|
-
/**
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
3
|
-
*
|
|
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/
|
|
273
|
-
"
|
|
274
|
-
|
|
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
|
-
"📚
|
|
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
|
-
"📚
|
|
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/
|
|
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
|
-
"📚
|
|
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.
|
|
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/
|
|
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.
|
|
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.
|
|
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\.
|
|
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:
|
|
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.
|
|
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.
|
|
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:
|
|
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`
|
|
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\`
|
|
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
|
|
60
|
-
*
|
|
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) {
|