paperlint 4.1.0 → 5.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/CLAUDE.md +26 -2
- package/CONTRIBUTING.md +7 -0
- package/README.md +15 -4
- package/action.harness.mjs +54 -36
- package/action.yml +12 -27
- package/dist/adapters/latex/document-class.d.ts +6 -5
- package/dist/adapters/latex/document-class.d.ts.map +1 -1
- package/dist/adapters/latex/document-class.js +18 -10
- package/dist/adapters/latex/document-class.js.map +1 -1
- package/dist/adapters/latex/includes.d.ts +16 -0
- package/dist/adapters/latex/includes.d.ts.map +1 -0
- package/dist/adapters/latex/includes.js +48 -0
- package/dist/adapters/latex/includes.js.map +1 -0
- package/dist/adapters/latex/index.d.ts +9 -5
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +8 -1
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/nodes.d.ts +5 -1
- package/dist/adapters/latex/nodes.d.ts.map +1 -1
- package/dist/adapters/latex/nodes.js +6 -2
- package/dist/adapters/latex/nodes.js.map +1 -1
- package/dist/adapters/latex/parse.d.ts.map +1 -1
- package/dist/adapters/latex/parse.js +1 -0
- package/dist/adapters/latex/parse.js.map +1 -1
- package/dist/adapters/latex/prose.d.ts +8 -0
- package/dist/adapters/latex/prose.d.ts.map +1 -0
- package/dist/adapters/latex/prose.js +353 -0
- package/dist/adapters/latex/prose.js.map +1 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +7 -3
- package/dist/build.js.map +1 -1
- package/dist/claim-provenance.d.ts +34 -0
- package/dist/claim-provenance.d.ts.map +1 -0
- package/dist/claim-provenance.js +146 -0
- package/dist/claim-provenance.js.map +1 -0
- package/dist/cli.d.ts +2 -8
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +81 -31
- package/dist/cli.js.map +1 -1
- package/dist/domain/paper-source.d.ts +58 -0
- package/dist/domain/paper-source.d.ts.map +1 -0
- package/dist/domain/paper-source.js +109 -0
- package/dist/domain/paper-source.js.map +1 -0
- package/dist/domain/sentences.d.ts +26 -0
- package/dist/domain/sentences.d.ts.map +1 -0
- package/dist/domain/sentences.js +48 -0
- package/dist/domain/sentences.js.map +1 -0
- package/dist/domain/tex-document.d.ts +39 -4
- package/dist/domain/tex-document.d.ts.map +1 -1
- package/dist/domain/tex-document.js.map +1 -1
- package/dist/eslint-rules/latex-language.d.ts +5 -1
- package/dist/eslint-rules/latex-language.d.ts.map +1 -1
- package/dist/eslint-rules/latex-language.js +6 -3
- package/dist/eslint-rules/latex-language.js.map +1 -1
- package/dist/eslint-rules/paper-context.d.ts +20 -0
- package/dist/eslint-rules/paper-context.d.ts.map +1 -0
- package/dist/eslint-rules/paper-context.js +44 -0
- package/dist/eslint-rules/paper-context.js.map +1 -0
- package/dist/eslint-rules/paper-typography.d.ts +8 -1
- package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
- package/dist/eslint-rules/paper-typography.js +85 -19
- package/dist/eslint-rules/paper-typography.js.map +1 -1
- package/dist/eslint-rules/tex-build.d.ts.map +1 -1
- package/dist/eslint-rules/tex-build.js +7 -1
- package/dist/eslint-rules/tex-build.js.map +1 -1
- package/dist/init.d.ts +4 -19
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +24 -29
- package/dist/init.js.map +1 -1
- package/dist/package-dirs.d.ts +2 -0
- package/dist/package-dirs.d.ts.map +1 -1
- package/dist/package-dirs.js +8 -0
- package/dist/package-dirs.js.map +1 -1
- package/dist/paper-files.d.ts +2 -2
- package/dist/paper-includes.d.ts +17 -0
- package/dist/paper-includes.d.ts.map +1 -0
- package/dist/paper-includes.js +51 -0
- package/dist/paper-includes.js.map +1 -0
- package/dist/ports/latex.d.ts +12 -1
- package/dist/ports/latex.d.ts.map +1 -1
- package/dist/register.d.ts +40 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +134 -0
- package/dist/register.js.map +1 -0
- package/dist/rules-config.d.ts +1 -0
- package/dist/rules-config.d.ts.map +1 -1
- package/dist/rules-config.js.map +1 -1
- package/dist/structure.js +1 -1
- package/dist/structure.js.map +1 -1
- package/dist/tex-paper.d.ts +33 -0
- package/dist/tex-paper.d.ts.map +1 -0
- package/dist/tex-paper.js +103 -0
- package/dist/tex-paper.js.map +1 -0
- package/dist/tex-venue-rules.d.ts +7 -2
- package/dist/tex-venue-rules.d.ts.map +1 -1
- package/dist/tex-venue-rules.js +31 -17
- package/dist/tex-venue-rules.js.map +1 -1
- package/docs/configuration.md +5 -4
- package/docs/install.md +8 -8
- package/docs/prior-art/paper-folder-scaffolding.md +4 -7
- package/docs/rules/tex/claim-provenance.md +83 -0
- package/docs/rules/tex/register.md +77 -0
- package/docs/rules/tex/required-section.md +3 -1
- package/docs/rules/tex/template.md +12 -0
- package/docs/rules/tex/venue-leftover.md +4 -2
- package/docs/rules.md +17 -10
- package/eslint-rules/latex-language.ts +10 -4
- package/eslint-rules/paper-context.test.ts +47 -0
- package/eslint-rules/paper-context.ts +57 -0
- package/eslint-rules/paper-typography.test.mjs +48 -1
- package/eslint-rules/paper-typography.ts +139 -24
- package/eslint-rules/tex-build.ts +8 -1
- package/eslint.config.mjs +1 -0
- package/fixtures/real-markdown-paper/baseline.json +1 -2
- package/fixtures/real-markdown-paper/baseline.ts +8 -7
- package/package.json +2 -1
- package/scripts/check.ts +2 -2
- package/scripts/eslint-report-guard.mjs +5 -2
- package/scripts/harness-api.frozen.json +0 -1
- package/skills/cold-read-diff/SKILL.md +31 -10
- package/skills/cold-read-diff/SKILL.md.spec.ts +30 -9
- package/skills/draft-paper/SKILL.md +2 -2
- package/skills/draft-paper/SKILL.md.spec.ts +1 -1
- package/skills/grade-paper-writing/SKILL.md +109 -44
- package/skills/grade-paper-writing/SKILL.md.spec.ts +108 -43
- package/skills/harden-paper/SKILL.md +35 -36
- package/skills/harden-paper/SKILL.md.spec.ts +34 -35
- 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 +3 -3
- package/skills/paper-pipeline/SKILL.md.spec.ts +2 -2
- package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +6 -6
- package/skills/paper-pipeline/references/pipeline-status-template.md +2 -16
- package/skills/paper-pipeline/references/writing-craft.md +53 -18
- package/skills/pc-panel-review/SKILL.md +36 -11
- package/skills/pc-panel-review/SKILL.md.spec.ts +35 -10
- package/src/adapters/latex/document-class.ts +20 -12
- package/src/adapters/latex/includes.ts +74 -0
- package/src/adapters/latex/index.test.ts +183 -1
- package/src/adapters/latex/index.ts +14 -4
- package/src/adapters/latex/nodes.ts +11 -2
- package/src/adapters/latex/parse.ts +1 -0
- package/src/adapters/latex/prose.ts +484 -0
- package/src/build.harness.mjs +4 -2
- package/src/build.test.ts +18 -1
- package/src/build.ts +7 -3
- package/src/claim-provenance.test.ts +352 -0
- package/src/claim-provenance.ts +217 -0
- package/src/cli.harness.mjs +22 -54
- package/src/cli.test.ts +2 -18
- package/src/cli.ts +92 -36
- package/src/domain/paper-source.test.ts +156 -0
- package/src/domain/paper-source.ts +233 -0
- package/src/domain/sentences.ts +67 -0
- package/src/domain/tex-document.ts +36 -3
- package/src/init.test.ts +33 -13
- package/src/init.ts +25 -48
- package/src/linted-set.test.ts +11 -1
- package/src/new-paper.harness.mjs +1 -5
- package/src/package-dirs.ts +9 -0
- package/src/paper-files.ts +2 -2
- package/src/paper-includes.test.ts +161 -0
- package/src/paper-includes.ts +77 -0
- package/src/ports/latex.ts +13 -0
- package/src/register.test.ts +165 -0
- package/src/register.ts +177 -0
- package/src/rule-cases.test.ts +43 -23
- package/src/rule-docs.test.ts +0 -1
- package/src/rules-config.ts +1 -0
- package/src/structure.harness.ts +1 -3
- package/src/structure.ts +1 -1
- package/src/tex-paper.test.ts +148 -0
- package/src/tex-paper.ts +161 -0
- package/src/tex-venue-rules.test.ts +87 -5
- package/src/tex-venue-rules.ts +62 -30
- package/templates/paper/PIPELINE-STATUS.md +0 -4
- package/dist/action-ref.d.ts +0 -12
- package/dist/action-ref.d.ts.map +0 -1
- package/dist/action-ref.js +0 -16
- package/dist/action-ref.js.map +0 -1
- package/dist/eslint-rules/paper-research-question.d.ts +0 -31
- package/dist/eslint-rules/paper-research-question.d.ts.map +0 -1
- package/dist/eslint-rules/paper-research-question.js +0 -188
- package/dist/eslint-rules/paper-research-question.js.map +0 -1
- package/eslint-rules/paper-research-question.harness.mjs +0 -212
- package/eslint-rules/paper-research-question.ts +0 -199
- package/fixtures/paper-research-question/comment-only/PIPELINE-STATUS.md +0 -9
- package/fixtures/paper-research-question/comment-only/paper.tex +0 -7
- package/fixtures/paper-research-question/declared-not-in-paper/PIPELINE-STATUS.md +0 -10
- package/fixtures/paper-research-question/declared-not-in-paper/paper.tex +0 -6
- package/fixtures/paper-research-question/draft/PIPELINE-STATUS.md +0 -6
- package/fixtures/paper-research-question/draft/paper.tex +0 -2
- package/fixtures/paper-research-question/markdown-no-rq/PIPELINE-STATUS.md +0 -9
- package/fixtures/paper-research-question/markdown-no-rq/paper.md +0 -4
- package/fixtures/paper-research-question/shipped-no-rq/PIPELINE-STATUS.md +0 -12
- package/fixtures/paper-research-question/shipped-no-rq/paper.tex +0 -3
- package/fixtures/paper-research-question/shipped-with-rq/PIPELINE-STATUS.md +0 -10
- package/fixtures/paper-research-question/shipped-with-rq/paper.tex +0 -2
- package/skills/harden-paper/fixtures/uncited-assertions-sample.md +0 -43
- package/skills/harden-paper/fixtures/uncited-assertions-sample.tex +0 -77
- package/src/action-ref.test.ts +0 -26
- package/src/action-ref.ts +0 -15
package/src/rule-cases.test.ts
CHANGED
|
@@ -128,6 +128,9 @@ const AIDC = {
|
|
|
128
128
|
};
|
|
129
129
|
const IEEE = "\\documentclass[conference,compsoc]{IEEEtran}";
|
|
130
130
|
const STATEMENT = "\\section*{LLM Usage Statement}\nNone.";
|
|
131
|
+
/** Two `\\documentclass` lines behind a switch: `article` for one venue, `second` for this one. */
|
|
132
|
+
const switched = (second: string): string =>
|
|
133
|
+
`\\def\\venue{2}\n\\if\\venue1\n\\documentclass{article}\n\\fi\n\\if\\venue2\n${second}\n\\fi`;
|
|
131
134
|
const aidc = (body: string, cls = IEEE) =>
|
|
132
135
|
paper(tex(`\\section{Introduction}\n${body}\n${STATEMENT}`, cls), AIDC);
|
|
133
136
|
|
|
@@ -147,6 +150,13 @@ interface RuleCases {
|
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
const TEX_FILE = `${P}/paper.tex`;
|
|
153
|
+
|
|
154
|
+
/** A body of 200 sentences of 14 words: enough words for `tex/register` to judge a rate. */
|
|
155
|
+
const FORMAL = Array.from(
|
|
156
|
+
{ length: 200 },
|
|
157
|
+
() =>
|
|
158
|
+
"The rule reads the body of the paper and counts every sentence it holds.",
|
|
159
|
+
).join(" ");
|
|
150
160
|
const STATUS_FILE = `${P}/PIPELINE-STATUS.md`;
|
|
151
161
|
const SHIPPED_STATUS =
|
|
152
162
|
"---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/a.pdf\n bytes: 1\n---\n# PIPELINE-STATUS\n";
|
|
@@ -176,25 +186,6 @@ const CASES: Readonly<Record<string, RuleCases>> = {
|
|
|
176
186
|
},
|
|
177
187
|
silent: paper(tex("x")),
|
|
178
188
|
},
|
|
179
|
-
"paper/research-question": {
|
|
180
|
-
reports: {
|
|
181
|
-
tree: {
|
|
182
|
-
[STATUS_FILE]: fixture(
|
|
183
|
-
"paper-research-question/shipped-no-rq/PIPELINE-STATUS.md",
|
|
184
|
-
),
|
|
185
|
-
[TEX_FILE]: fixture("paper-research-question/shipped-no-rq/paper.tex"),
|
|
186
|
-
},
|
|
187
|
-
file: TEX_FILE,
|
|
188
|
-
severity: 1,
|
|
189
|
-
line: 1,
|
|
190
|
-
},
|
|
191
|
-
silent: {
|
|
192
|
-
[STATUS_FILE]: fixture(
|
|
193
|
-
"paper-research-question/shipped-with-rq/PIPELINE-STATUS.md",
|
|
194
|
-
),
|
|
195
|
-
[TEX_FILE]: fixture("paper-research-question/shipped-with-rq/paper.tex"),
|
|
196
|
-
},
|
|
197
|
-
},
|
|
198
189
|
"paper/section-word": {
|
|
199
190
|
reports: {
|
|
200
191
|
tree: paper(tex("As in §2.")),
|
|
@@ -202,7 +193,13 @@ const CASES: Readonly<Record<string, RuleCases>> = {
|
|
|
202
193
|
severity: 1,
|
|
203
194
|
line: 3,
|
|
204
195
|
},
|
|
205
|
-
|
|
196
|
+
// A cleveref name definition sets a label, it is not prose.
|
|
197
|
+
silent: paper(
|
|
198
|
+
tex(
|
|
199
|
+
"As in Section~2.",
|
|
200
|
+
"\\documentclass{article}\n\\usepackage{cleveref}\n\\crefname{section}{§}{§§}",
|
|
201
|
+
),
|
|
202
|
+
),
|
|
206
203
|
},
|
|
207
204
|
"paper/leading-zero": {
|
|
208
205
|
reports: {
|
|
@@ -299,14 +296,15 @@ const CASES: Readonly<Record<string, RuleCases>> = {
|
|
|
299
296
|
},
|
|
300
297
|
silent: paper(fixture("tex-build/frontmatter-clean.tex")),
|
|
301
298
|
},
|
|
299
|
+
// The class picked behind a TeX switch: two candidates, one of them the venue's or none.
|
|
302
300
|
"tex/template": {
|
|
303
301
|
reports: {
|
|
304
|
-
tree: aidc("Text.", "\\documentclass[conference]{IEEEtran}"),
|
|
302
|
+
tree: aidc("Text.", switched("\\documentclass[conference]{IEEEtran}")),
|
|
305
303
|
file: TEX_FILE,
|
|
306
304
|
severity: 2,
|
|
307
|
-
line:
|
|
305
|
+
line: 3,
|
|
308
306
|
},
|
|
309
|
-
silent: aidc("Text."),
|
|
307
|
+
silent: aidc("Text.", switched(IEEE)),
|
|
310
308
|
},
|
|
311
309
|
"tex/required-section": {
|
|
312
310
|
reports: {
|
|
@@ -326,6 +324,28 @@ const CASES: Readonly<Record<string, RuleCases>> = {
|
|
|
326
324
|
},
|
|
327
325
|
silent: aidc("Text."),
|
|
328
326
|
},
|
|
327
|
+
"tex/claim-provenance": {
|
|
328
|
+
reports: {
|
|
329
|
+
tree: paper(tex("Text.\nAgents ignore 42\\% of the rules.")),
|
|
330
|
+
file: TEX_FILE,
|
|
331
|
+
severity: 1,
|
|
332
|
+
line: 4,
|
|
333
|
+
},
|
|
334
|
+
silent: paper(
|
|
335
|
+
tex(
|
|
336
|
+
"Text.\nWe measured that agents ignore 42\\% of the rules.\nThey run on Claude 3 and Python 3.12.",
|
|
337
|
+
),
|
|
338
|
+
),
|
|
339
|
+
},
|
|
340
|
+
"tex/register": {
|
|
341
|
+
reports: {
|
|
342
|
+
tree: paper(tex(`${FORMAL} But it holds. So it goes. And it ends.`)),
|
|
343
|
+
file: TEX_FILE,
|
|
344
|
+
severity: 1,
|
|
345
|
+
line: 3,
|
|
346
|
+
},
|
|
347
|
+
silent: paper(tex(FORMAL)),
|
|
348
|
+
},
|
|
329
349
|
"bib/reachable-entry": {
|
|
330
350
|
reports: {
|
|
331
351
|
tree: paper(inlineBib("@book{a,\n title = {A},\n year = {2026}\n}")),
|
package/src/rule-docs.test.ts
CHANGED
package/src/rules-config.ts
CHANGED
|
@@ -64,6 +64,7 @@ export interface ConfigBlock {
|
|
|
64
64
|
readonly languageOptions?: Readonly<Record<string, unknown>>;
|
|
65
65
|
readonly linterOptions?: Readonly<Record<string, unknown>>;
|
|
66
66
|
readonly rules?: Readonly<Record<string, unknown>>;
|
|
67
|
+
readonly settings?: Readonly<Record<string, unknown>>;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
export type Parsed<T> =
|
package/src/structure.harness.ts
CHANGED
|
@@ -79,9 +79,7 @@ try {
|
|
|
79
79
|
check(
|
|
80
80
|
"and the message names the CONSEQUENCE, not a restatement of the condition",
|
|
81
81
|
/are skipped/.test(at("no-scorecard")[0]?.message ?? "") &&
|
|
82
|
-
/stage
|
|
83
|
-
at("no-scorecard")[0]?.message ?? "",
|
|
84
|
-
),
|
|
82
|
+
/stage and source checks/.test(at("no-scorecard")[0]?.message ?? ""),
|
|
85
83
|
);
|
|
86
84
|
check(
|
|
87
85
|
"and the consequence names THIS directory by name",
|
package/src/structure.ts
CHANGED
|
@@ -122,7 +122,7 @@ export function checkStructure(
|
|
|
122
122
|
*/
|
|
123
123
|
function whyMissingMatters(file: string, dirName: string): string {
|
|
124
124
|
if (file === "PIPELINE-STATUS.md")
|
|
125
|
-
return `the stage
|
|
125
|
+
return `the stage and source checks are skipped for \`${dirName}\``;
|
|
126
126
|
return `declared as required by your \`structure\` configuration`;
|
|
127
127
|
}
|
|
128
128
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reading every parse-tree rule shares (`readPaper`), the files of the paper's body that lint
|
|
3
|
+
* reads on their own (`bodyFiles`), and where a finding of the assembled text is reported
|
|
4
|
+
* (`reportInPaper`).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { latexReader } from "./adapters/latex/index.ts";
|
|
8
|
+
import { memoryFiles } from "./adapters/memory/index.ts";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { texInputsDir } from "./package-dirs.ts";
|
|
11
|
+
import { bodyFiles, readPaper, reportInPaper } from "./tex-paper.ts";
|
|
12
|
+
|
|
13
|
+
describe("readPaper — the paper as the rules read it", () => {
|
|
14
|
+
it("reads includes from the paper's own directory", () => {
|
|
15
|
+
const p = readPaper("/p/paper.tex", "x\\input{s/a}y", {
|
|
16
|
+
files: memoryFiles({ "/p/s/a.tex": "AAA" }),
|
|
17
|
+
latex: latexReader,
|
|
18
|
+
});
|
|
19
|
+
expect(p.text).toBe("xAAAy");
|
|
20
|
+
expect(p.main).toBe("paper.tex");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("an include the paper's directory lacks is found in paperlint's own inputs, as the build finds it", () => {
|
|
24
|
+
const p = readPaper("/p/paper.tex", "x\\input{guards}y", {
|
|
25
|
+
files: memoryFiles({ [join(texInputsDir(), "guards.tex")]: "G" }),
|
|
26
|
+
latex: latexReader,
|
|
27
|
+
});
|
|
28
|
+
expect({ text: p.text, missing: p.missing }).toEqual({
|
|
29
|
+
text: "xGy",
|
|
30
|
+
missing: [],
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("the paper's directory comes first: its file wins over paperlint's of the same name", () => {
|
|
35
|
+
const p = readPaper("/p/paper.tex", "\\input{guards}", {
|
|
36
|
+
files: memoryFiles({
|
|
37
|
+
"/p/guards.tex": "mine",
|
|
38
|
+
[join(texInputsDir(), "guards.tex")]: "package",
|
|
39
|
+
}),
|
|
40
|
+
latex: latexReader,
|
|
41
|
+
});
|
|
42
|
+
expect(p.text).toBe("mine");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("bodyFiles — the files of the body lint reads on their own", () => {
|
|
47
|
+
const doc = (preamble: string, body: string): string =>
|
|
48
|
+
`\\documentclass{article}\n${preamble}\n\\begin{document}\n${body}\n\\end{document}\n`;
|
|
49
|
+
const files = (extra: Record<string, string> = {}) =>
|
|
50
|
+
memoryFiles({
|
|
51
|
+
"/p/macros.tex": "\\newcommand{\\x}{y}",
|
|
52
|
+
"/p/sections/a.tex": "A\n\\input{sections/b}\n",
|
|
53
|
+
"/p/sections/b.tex": "B",
|
|
54
|
+
[join(texInputsDir(), "guards.tex")]: "G",
|
|
55
|
+
...extra,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("every file an include in the document body brings in, nested ones too, by absolute path", () => {
|
|
59
|
+
expect(
|
|
60
|
+
bodyFiles("/p/paper.tex", doc("", "\\input{sections/a}"), {
|
|
61
|
+
files: files(),
|
|
62
|
+
latex: latexReader,
|
|
63
|
+
}),
|
|
64
|
+
).toEqual({
|
|
65
|
+
files: ["/p/sections/a.tex", "/p/sections/b.tex"],
|
|
66
|
+
missing: [],
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("not a preamble include (macros), and not a file found only in paperlint's inputs", () => {
|
|
71
|
+
expect(
|
|
72
|
+
bodyFiles(
|
|
73
|
+
"/p/paper.tex",
|
|
74
|
+
doc("\\input{macros}", "\\input{guards}\nText."),
|
|
75
|
+
{ files: files(), latex: latexReader },
|
|
76
|
+
),
|
|
77
|
+
).toEqual({ files: [], missing: [] });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("an include that resolves nowhere is named, with the file that wrote it", () => {
|
|
81
|
+
expect(
|
|
82
|
+
bodyFiles("/p/paper.tex", doc("", "\\input{gone}"), {
|
|
83
|
+
files: files(),
|
|
84
|
+
latex: latexReader,
|
|
85
|
+
}),
|
|
86
|
+
).toEqual({ files: [], missing: [{ file: "paper.tex", target: "gone" }] });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("a main file with no document environment: every include is the body", () => {
|
|
90
|
+
expect(
|
|
91
|
+
bodyFiles("/p/paper.tex", "\\input{sections/b}", {
|
|
92
|
+
files: files(),
|
|
93
|
+
latex: latexReader,
|
|
94
|
+
}),
|
|
95
|
+
).toEqual({ files: ["/p/sections/b.tex"], missing: [] });
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("reportInPaper — where a finding of the assembled text is reported", () => {
|
|
100
|
+
const paper = readPaper("/p/paper.tex", "x\n\\input{a}\ny", {
|
|
101
|
+
files: memoryFiles({ "/p/a.tex": "one\ntwo" }),
|
|
102
|
+
latex: latexReader,
|
|
103
|
+
});
|
|
104
|
+
const report = (
|
|
105
|
+
at: { start: number; end: number } | null,
|
|
106
|
+
messageId = "m",
|
|
107
|
+
) => {
|
|
108
|
+
const out: unknown[] = [];
|
|
109
|
+
reportInPaper(
|
|
110
|
+
{
|
|
111
|
+
filename: "/p/paper.tex",
|
|
112
|
+
sourceCode: {
|
|
113
|
+
text: "",
|
|
114
|
+
getLocFromIndex: (i) => ({ line: i, column: 0 }),
|
|
115
|
+
},
|
|
116
|
+
report: (d) =>
|
|
117
|
+
out.push(
|
|
118
|
+
"messageId" in d
|
|
119
|
+
? { at: d.loc.start.line, id: d.messageId }
|
|
120
|
+
: { at: d.loc.start.line, message: d.message },
|
|
121
|
+
),
|
|
122
|
+
},
|
|
123
|
+
{ m: "said {{w}}" },
|
|
124
|
+
paper,
|
|
125
|
+
[{ messageId, data: { w: "so" }, at }],
|
|
126
|
+
);
|
|
127
|
+
return out;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
it("in the main file: at its own place, by its id", () => {
|
|
131
|
+
expect(report({ start: 0, end: 1 })).toEqual([{ at: 0, id: "m" }]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("in an included file: at the include, the file, line and column in front of the filled message", () => {
|
|
135
|
+
// "x\n" then "one\ntwo": offset 6 is the `t` of `two`, line 2 column 1 of a.tex.
|
|
136
|
+
expect(report({ start: 6, end: 9 })).toEqual([
|
|
137
|
+
{ at: 2, message: "a.tex:2:1: said so" },
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("no place, or a place outside the text: the top of the file; an unknown id is printed as itself", () => {
|
|
142
|
+
expect(report(null)).toEqual([{ at: 0, id: "m" }]);
|
|
143
|
+
expect(report({ start: 99, end: 99 })).toEqual([{ at: 0, id: "m" }]);
|
|
144
|
+
expect(report({ start: 6, end: 9 }, "unknown")).toEqual([
|
|
145
|
+
{ at: 2, message: "a.tex:2:1: unknown" },
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
});
|
package/src/tex-paper.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PAPER, NOT ITS MAIN FILE — what every rule over the LaTeX parse tree reads, and how it reports.
|
|
3
|
+
*
|
|
4
|
+
* `readPaper` gives a rule `paper.tex` with every `\input`, `\include` and `\subfile` spliced in, the
|
|
5
|
+
* way TeX reads it (`assemblePaper`, src/domain/paper-source.ts). `reportInPaper` puts each finding
|
|
6
|
+
* back where its text came from: a finding in `paper.tex` at its own place; a finding in an included
|
|
7
|
+
* file at the include in `paper.tex` that brought it in — the one place ESLint can point to in this
|
|
8
|
+
* file — with the included file, line and column at the front of the message.
|
|
9
|
+
*
|
|
10
|
+
* An include is looked for where the build tells TeX to look (`texSearchPath`): the paper's own
|
|
11
|
+
* directory, then paperlint's inputs. `bodyFiles` names the files of the paper's BODY the author
|
|
12
|
+
* wrote — what the rules over ESLint's own LaTeX text read one by one, each at its own path.
|
|
13
|
+
*/
|
|
14
|
+
import { basename, dirname, join } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
assemblePaper,
|
|
17
|
+
originOf,
|
|
18
|
+
type PaperSource,
|
|
19
|
+
} from "./domain/paper-source.ts";
|
|
20
|
+
import type { Span } from "./domain/tex-document.ts";
|
|
21
|
+
import type { LatexReader } from "./ports/latex.ts";
|
|
22
|
+
import type { Files } from "./ports/files.ts";
|
|
23
|
+
import { callerPath } from "./caller-path.ts";
|
|
24
|
+
import type { AbsolutePath } from "./domain/paths.ts";
|
|
25
|
+
import { texSearchPath } from "./package-dirs.ts";
|
|
26
|
+
import type { Located, TexRuleContext } from "./tex-venue-rules.ts";
|
|
27
|
+
|
|
28
|
+
/** What reading a paper needs: the disk, and the LaTeX reader. */
|
|
29
|
+
export interface PaperDeps {
|
|
30
|
+
readonly files: Files;
|
|
31
|
+
readonly latex: LatexReader;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The paper whose main file is `filename` with the text `src`, its includes spliced. */
|
|
35
|
+
export function readPaper(
|
|
36
|
+
filename: string,
|
|
37
|
+
src: string,
|
|
38
|
+
deps: PaperDeps,
|
|
39
|
+
): PaperSource {
|
|
40
|
+
const dir = dirname(filename);
|
|
41
|
+
return assemblePaper(basename(filename), src, {
|
|
42
|
+
includes: deps.latex.includes,
|
|
43
|
+
documentBody: deps.latex.documentBody,
|
|
44
|
+
read: (rel) => {
|
|
45
|
+
const at = located(dir, rel, deps.files);
|
|
46
|
+
const b = at === null ? null : deps.files.readBytes(at);
|
|
47
|
+
return b === null ? null : new TextDecoder().decode(b);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `rel` in the first directory of the paper's TeX search path that holds it, or null. */
|
|
53
|
+
const located = (dir: string, rel: string, files: Files): AbsolutePath | null =>
|
|
54
|
+
texSearchPath(dir)
|
|
55
|
+
.map((d) => callerPath(join(d, rel)))
|
|
56
|
+
.find((p) => files.isFile(p)) ?? null;
|
|
57
|
+
|
|
58
|
+
/** An include that resolved nowhere: the file that wrote it, and the path as written. */
|
|
59
|
+
export interface Unread {
|
|
60
|
+
readonly file: string;
|
|
61
|
+
readonly target: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The files of the body of the paper whose main file is `filename`: every file an include inside its
|
|
66
|
+
* `document` environment brings in, nested ones too, found in the paper's own directory. Not a
|
|
67
|
+
* preamble include (macros are not the body), and not a file found only in paperlint's inputs (not
|
|
68
|
+
* the author's text). `missing` is every include, anywhere, that resolved nowhere.
|
|
69
|
+
*/
|
|
70
|
+
export function bodyFiles(
|
|
71
|
+
filename: string,
|
|
72
|
+
src: string,
|
|
73
|
+
deps: PaperDeps,
|
|
74
|
+
): {
|
|
75
|
+
readonly files: readonly AbsolutePath[];
|
|
76
|
+
readonly missing: readonly Unread[];
|
|
77
|
+
} {
|
|
78
|
+
const dir = dirname(filename);
|
|
79
|
+
const paper = readPaper(filename, src, deps);
|
|
80
|
+
const body = deps.latex.documentBody(src);
|
|
81
|
+
const inBody = (via: Span | null): boolean =>
|
|
82
|
+
via !== null &&
|
|
83
|
+
(body === null || (via.start >= body.start && via.end <= body.end));
|
|
84
|
+
const own = (rel: string): AbsolutePath | null => {
|
|
85
|
+
const at = located(dir, rel, deps.files);
|
|
86
|
+
return at === callerPath(join(dir, rel)) ? at : null;
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
files: [
|
|
90
|
+
...new Set(
|
|
91
|
+
paper.segments
|
|
92
|
+
.filter((s) => s.file !== paper.main && inBody(s.via))
|
|
93
|
+
.map((s) => own(s.file))
|
|
94
|
+
.filter((f): f is AbsolutePath => f !== null),
|
|
95
|
+
),
|
|
96
|
+
],
|
|
97
|
+
missing: paper.missing.map(({ file, target }) => ({ file, target })),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Line and column (1-based) of an offset in a text. */
|
|
102
|
+
function lineColumn(text: string, at: number): string {
|
|
103
|
+
const line = text.slice(0, at).split("\n").length;
|
|
104
|
+
const column = at - text.lastIndexOf("\n", at - 1);
|
|
105
|
+
return `${String(line)}:${String(column)}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A message template with its data filled in, the way ESLint fills it. */
|
|
109
|
+
const filled = (
|
|
110
|
+
template: string,
|
|
111
|
+
data: Readonly<Record<string, string | number>> = {},
|
|
112
|
+
): string =>
|
|
113
|
+
Object.entries(data).reduce(
|
|
114
|
+
(m, [k, v]) => m.replaceAll(`{{${k}}}`, String(v)),
|
|
115
|
+
template,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
/** Where a finding stands in the main file, and — when its text came from another file — which. */
|
|
119
|
+
interface Placed {
|
|
120
|
+
readonly at: Span | null;
|
|
121
|
+
readonly from: string | null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A finding's span of the assembled text, as a place in the main file. */
|
|
125
|
+
function placed(paper: PaperSource, at: Span | null): Placed {
|
|
126
|
+
const origin = at === null ? null : originOf(paper, at);
|
|
127
|
+
if (origin === null) return { at: null, from: null };
|
|
128
|
+
if (origin.file === paper.main) return { at: origin.span, from: null };
|
|
129
|
+
return {
|
|
130
|
+
at: origin.via,
|
|
131
|
+
from: `${origin.file}:${lineColumn(origin.source, origin.span.start)}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Each finding reported in the main file: at its own place, or at the include that brought its text
|
|
137
|
+
* in, named in the message (`sections/results.tex:12:3: …`). Offsets are the assembled text's.
|
|
138
|
+
*/
|
|
139
|
+
export function reportInPaper(
|
|
140
|
+
context: TexRuleContext,
|
|
141
|
+
messages: Readonly<Record<string, string>>,
|
|
142
|
+
paper: PaperSource,
|
|
143
|
+
findings: readonly Located[],
|
|
144
|
+
): void {
|
|
145
|
+
const loc = (i: number) => context.sourceCode.getLocFromIndex(i);
|
|
146
|
+
findings.forEach((f) => {
|
|
147
|
+
const p = placed(paper, f.at);
|
|
148
|
+
const where = {
|
|
149
|
+
start: loc(p.at?.start ?? 0),
|
|
150
|
+
end: loc(p.at?.end ?? 0),
|
|
151
|
+
};
|
|
152
|
+
if (p.from === null)
|
|
153
|
+
context.report({ loc: where, messageId: f.messageId, data: f.data });
|
|
154
|
+
else
|
|
155
|
+
context.report({
|
|
156
|
+
loc: where,
|
|
157
|
+
// A message id the rule does not define is printed as itself rather than dropped.
|
|
158
|
+
message: `${p.from}: ${filled(messages[f.messageId] ?? f.messageId, f.data)}`,
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
@@ -79,11 +79,16 @@ function lint(
|
|
|
79
79
|
report: (d) =>
|
|
80
80
|
out.push({
|
|
81
81
|
rule: `tex/${name}`,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
// A finding from an included file arrives as a whole message, its file at the front.
|
|
83
|
+
messageId: "messageId" in d ? d.messageId : "(message)",
|
|
84
|
+
message:
|
|
85
|
+
"messageId" in d
|
|
86
|
+
? Object.entries(d.data ?? {}).reduce(
|
|
87
|
+
(m, [k, v]) => m.replaceAll(`{{${k}}}`, String(v)),
|
|
88
|
+
rule.meta.messages[d.messageId] ??
|
|
89
|
+
`(no message ${d.messageId})`,
|
|
90
|
+
)
|
|
91
|
+
: d.message,
|
|
87
92
|
line: d.loc.start.line,
|
|
88
93
|
}),
|
|
89
94
|
});
|
|
@@ -183,6 +188,83 @@ describe("tex/template — the class line's states: missing, empty, commented ou
|
|
|
183
188
|
});
|
|
184
189
|
});
|
|
185
190
|
|
|
191
|
+
describe("the venue rules read the whole paper: the files it \\inputs, spliced where they stand", () => {
|
|
192
|
+
it("🔴 a required section kept in an included file is found, and its order judged in the paper", () => {
|
|
193
|
+
const tex = paper(
|
|
194
|
+
"\\documentclass[conference,compsoc]{IEEEtran}",
|
|
195
|
+
"Text.\n\\input{sections/closing}",
|
|
196
|
+
);
|
|
197
|
+
const extra = {
|
|
198
|
+
[`${PAPER}/sections/closing.tex`]:
|
|
199
|
+
"\\section*{LLM Usage Statement}\nNone.\n",
|
|
200
|
+
};
|
|
201
|
+
expect(lint(tex, AIDC, { extra })).toEqual([]);
|
|
202
|
+
// Without the file, the section is missing.
|
|
203
|
+
expect(ids(lint(tex, AIDC))).toEqual(["tex/required-section:missing"]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("a leftover venue name in an included file is reported at the \\input, naming the file and line", () => {
|
|
207
|
+
const tex = paper(
|
|
208
|
+
"\\documentclass[conference,compsoc]{IEEEtran}",
|
|
209
|
+
"Text.\n\\input{intro}\n\\section*{LLM Usage Statement}\nNone.",
|
|
210
|
+
);
|
|
211
|
+
const fs = lint(tex, AIDC, {
|
|
212
|
+
extra: {
|
|
213
|
+
[`${PAPER}/intro.tex`]: "One line.\nFirst written for AgenticDev.\n",
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
expect(fs.map((f) => [f.line, f.message.split(": ")[0]])).toEqual([
|
|
217
|
+
[5, "intro.tex:2:19"],
|
|
218
|
+
]);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("tex/template — several \\documentclass lines behind a TeX switch", () => {
|
|
223
|
+
/** One source for two venues, the class picked by `\\if` — as an accepted ACSAC paper does it. */
|
|
224
|
+
const switched = (a: string, b: string) =>
|
|
225
|
+
paper(
|
|
226
|
+
`\\def\\venue{2}\n\\if\\venue1\n${a}\n\\fi\n\\if\\venue2\n${b}\n\\fi`,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
it("passes when one of the candidates is the preset's class with its options", () => {
|
|
230
|
+
expect(
|
|
231
|
+
lint(
|
|
232
|
+
switched(
|
|
233
|
+
"\\documentclass[letterpaper,twocolumn]{article}",
|
|
234
|
+
"\\documentclass[conference,compsoc]{IEEEtran}",
|
|
235
|
+
),
|
|
236
|
+
AIDC,
|
|
237
|
+
),
|
|
238
|
+
).toEqual([]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("reports once, at the first candidate, naming every candidate, when none is the preset's", () => {
|
|
242
|
+
const fs = lint(
|
|
243
|
+
switched(
|
|
244
|
+
"\\documentclass{article}",
|
|
245
|
+
"\\documentclass[conference]{IEEEtran}",
|
|
246
|
+
),
|
|
247
|
+
AIDC,
|
|
248
|
+
);
|
|
249
|
+
expect(ids(fs)).toEqual(["tex/template:noCandidate"]);
|
|
250
|
+
// Line 4: the first candidate, after the comment, `\\def` and `\\if` lines.
|
|
251
|
+
expect(fs[0]?.line).toBe(4);
|
|
252
|
+
expect(fs[0]?.message).toBe(
|
|
253
|
+
"none of the 2 \\documentclass lines is `\\documentclass[conference,compsoc]{IEEEtran}`, which aidc requires: `\\documentclass{article}`, `\\documentclass[conference]{IEEEtran}`. The source picks one behind a TeX switch, which is not evaluated; make one of them the venue's",
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("an empty candidate is named as such", () => {
|
|
258
|
+
const fs = lint(
|
|
259
|
+
switched("\\documentclass{}", "\\documentclass{article}"),
|
|
260
|
+
AIDC,
|
|
261
|
+
);
|
|
262
|
+
expect(fs[0]?.message).toMatch(
|
|
263
|
+
/`\\documentclass\{\}`, `\\documentclass\{article\}`/,
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
186
268
|
describe("tex/template — the shipped ACM and ACL templates", () => {
|
|
187
269
|
it("🔴 acmart without sigconf — the manuscript format — fails every ACM preset", () => {
|
|
188
270
|
for (const settings of [
|