paperlint 4.2.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 +2 -2
- package/README.md +15 -4
- package/action.harness.mjs +54 -36
- package/action.yml +12 -27
- package/dist/adapters/latex/includes.d.ts +5 -1
- package/dist/adapters/latex/includes.d.ts.map +1 -1
- package/dist/adapters/latex/includes.js +8 -2
- package/dist/adapters/latex/includes.js.map +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +7 -3
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +2 -8
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +67 -37
- package/dist/cli.js.map +1 -1
- package/dist/domain/paper-source.js +2 -2
- 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 +1 -1
- package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
- package/dist/eslint-rules/paper-typography.js +32 -18
- 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/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 +17 -3
- package/dist/tex-paper.d.ts.map +1 -1
- package/dist/tex-paper.js +36 -38
- package/dist/tex-paper.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.md +15 -11
- 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.ts +67 -23
- package/eslint-rules/tex-build.ts +8 -1
- package/fixtures/real-markdown-paper/baseline.json +1 -2
- package/package.json +1 -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/paper-pipeline/references/pipeline-status-template.md +2 -16
- package/src/adapters/latex/includes.ts +10 -2
- package/src/adapters/latex/index.test.ts +8 -0
- package/src/build.harness.mjs +4 -2
- package/src/build.test.ts +18 -1
- package/src/build.ts +7 -3
- package/src/cli.harness.mjs +22 -54
- package/src/cli.test.ts +2 -18
- package/src/cli.ts +78 -42
- package/src/domain/paper-source.ts +2 -2
- 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/rule-cases.test.ts +0 -30
- 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 +78 -61
- package/src/tex-paper.ts +57 -48
- package/src/tex-venue-rules.test.ts +1 -1
- 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/docs/rules/tex/missing-input.md +0 -58
- 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/src/action-ref.test.ts +0 -26
- package/src/action-ref.ts +0 -15
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The paper a linted file belongs to, as `paperlint lint` hands it in ESLint's settings, and the
|
|
3
|
+
* reading of the paper's other files.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdtempSync, realpathSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
PAPERLINT_SETTINGS,
|
|
11
|
+
otherTexts,
|
|
12
|
+
paperOf,
|
|
13
|
+
textOnDisk,
|
|
14
|
+
} from "./paper-context.ts";
|
|
15
|
+
|
|
16
|
+
describe("paperOf", () => {
|
|
17
|
+
it("the paper under settings.paperlint.paper", () => {
|
|
18
|
+
const paper = { main: "/p/paper.tex", files: ["/p/paper.tex", "/p/a.tex"] };
|
|
19
|
+
expect(paperOf({ [PAPERLINT_SETTINGS]: { paper } })).toEqual(paper);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("none when lint handed none, or handed something else", () => {
|
|
23
|
+
expect(paperOf({})).toBe(null);
|
|
24
|
+
expect(paperOf({ [PAPERLINT_SETTINGS]: { paper: { main: 1 } } })).toBe(
|
|
25
|
+
null,
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("otherTexts", () => {
|
|
31
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-ctx-")));
|
|
32
|
+
writeFileSync(join(dir, "a.tex"), "A");
|
|
33
|
+
writeFileSync(join(dir, "b.tex"), "B");
|
|
34
|
+
const paper = {
|
|
35
|
+
main: join(dir, "a.tex"),
|
|
36
|
+
files: [join(dir, "a.tex"), join(dir, "b.tex"), join(dir, "gone.tex")],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
it("every file of the paper but the linted one; a file that cannot be read holds no text", () => {
|
|
40
|
+
expect(otherTexts(paper, join(dir, "a.tex"))).toEqual(["B", ""]);
|
|
41
|
+
expect(textOnDisk(join(dir, "gone.tex"))).toBe("");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("none without a paper", () => {
|
|
45
|
+
expect(otherTexts(null, join(dir, "a.tex"))).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PAPER A LINTED FILE BELONGS TO — handed to the rules by `paperlint lint` in ESLint's `settings`.
|
|
3
|
+
*
|
|
4
|
+
* ESLint lints one file at a time. A paper whose body is in `sections/*.tex` is several files, and
|
|
5
|
+
* two questions are about the paper, not the file: which reference form (`Fig.` or `Figure`) the
|
|
6
|
+
* paper uses most, and whether the build `paper.tex` declares is a review build. The CLI knows the
|
|
7
|
+
* files (it resolves the includes the way the build does, `bodyFiles` in src/tex-paper.ts) and puts
|
|
8
|
+
* them under `settings.paperlint.paper`
|
|
9
|
+
* for `paper.tex` and for every file it includes. A rule reads the other files from disk.
|
|
10
|
+
*
|
|
11
|
+
* No setting — a file linted outside `paperlint lint`, or a markdown paper — means a paper of one
|
|
12
|
+
* file: every rule decides on the file it was handed, as it did before.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
|
|
17
|
+
/** The key under ESLint's `settings`; the paper is at `settings[PAPERLINT_SETTINGS].paper`. */
|
|
18
|
+
export const PAPERLINT_SETTINGS = "paperlint";
|
|
19
|
+
|
|
20
|
+
const PaperSetting = z.object({
|
|
21
|
+
/** The absolute path of the paper's `paper.tex`. */
|
|
22
|
+
main: z.string(),
|
|
23
|
+
/** Absolute paths: `paper.tex`, then every file of its body, each once. */
|
|
24
|
+
files: z.array(z.string()).readonly(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** The paper a file belongs to: its main file and all of its files. */
|
|
28
|
+
export type PaperOfFile = Readonly<z.infer<typeof PaperSetting>>;
|
|
29
|
+
|
|
30
|
+
const Settings = z.looseObject({ paper: PaperSetting });
|
|
31
|
+
|
|
32
|
+
/** The paper the linted file belongs to, or null when lint handed none. */
|
|
33
|
+
export function paperOf(
|
|
34
|
+
settings: Readonly<Record<string, unknown>>,
|
|
35
|
+
): PaperOfFile | null {
|
|
36
|
+
const r = Settings.safeParse(settings[PAPERLINT_SETTINGS]);
|
|
37
|
+
return r.success ? r.data.paper : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The text of a file on disk; empty when it cannot be read — a file gone since `paperlint lint`
|
|
42
|
+
* named it holds no text for any rule.
|
|
43
|
+
*/
|
|
44
|
+
export function textOnDisk(path: string): string {
|
|
45
|
+
try {
|
|
46
|
+
return readFileSync(path, "utf8");
|
|
47
|
+
} catch {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The texts of the paper's files other than `filename`, read from disk; none without a paper. */
|
|
53
|
+
export const otherTexts = (
|
|
54
|
+
paper: PaperOfFile | null,
|
|
55
|
+
filename: string,
|
|
56
|
+
): readonly string[] =>
|
|
57
|
+
(paper?.files ?? []).filter((f) => f !== filename).map(textOnDisk);
|
|
@@ -63,7 +63,13 @@ import assert from "node:assert/strict";
|
|
|
63
63
|
import { getParser } from "@unified-latex/unified-latex-util-parse";
|
|
64
64
|
import { takeWhile } from "remeda";
|
|
65
65
|
import type { Nodes } from "mdast";
|
|
66
|
-
import
|
|
66
|
+
import {
|
|
67
|
+
texToMdast,
|
|
68
|
+
type TexArg,
|
|
69
|
+
type TexNode,
|
|
70
|
+
type TexRoot,
|
|
71
|
+
} from "./latex-language.ts";
|
|
72
|
+
import { otherTexts, paperOf } from "./paper-context.ts";
|
|
67
73
|
import type {
|
|
68
74
|
RuleContext,
|
|
69
75
|
RuleFixer,
|
|
@@ -102,16 +108,27 @@ export function bibRange(text: string): BibRange | null {
|
|
|
102
108
|
return { start: m.index, end: m.index + whole.length, bodyStart, body };
|
|
103
109
|
}
|
|
104
110
|
|
|
111
|
+
/** A node of a file's projection, as far as its place goes: mdast's, or the `.tex` language's. */
|
|
112
|
+
interface ProjectedNode {
|
|
113
|
+
readonly type: string;
|
|
114
|
+
readonly position?:
|
|
115
|
+
| {
|
|
116
|
+
readonly start: { readonly offset?: number | undefined };
|
|
117
|
+
readonly end: { readonly offset?: number | undefined };
|
|
118
|
+
}
|
|
119
|
+
| undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
105
122
|
/**
|
|
106
123
|
* The ranges a markup rule must not read: LaTeX comments (the language gives them as `html`
|
|
107
124
|
* nodes, with positions) and the inline bibliography.
|
|
108
125
|
*/
|
|
109
126
|
function skippedRanges(
|
|
110
|
-
|
|
127
|
+
nodes: readonly ProjectedNode[],
|
|
111
128
|
raw: string,
|
|
112
129
|
): [number, number][] {
|
|
113
130
|
const out: [number, number][] = [];
|
|
114
|
-
for (const n of
|
|
131
|
+
for (const n of nodes)
|
|
115
132
|
if (n.type === "html" && n.position) {
|
|
116
133
|
// An offset-less point compares as NaN: inside no range, as `undefined` always did.
|
|
117
134
|
const {
|
|
@@ -197,12 +214,22 @@ export function crefDefinitionRanges(
|
|
|
197
214
|
const inside = (ranges: readonly [number, number][], i: number) =>
|
|
198
215
|
ranges.some(([s, e]) => i >= s && i < e);
|
|
199
216
|
|
|
200
|
-
/** Every match of `re` in `raw` outside the skipped
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
217
|
+
/** Every match of `re` in `raw` outside the ranges skipped in it (`nodes`: its projection's). */
|
|
218
|
+
const markupMatchesIn = (
|
|
219
|
+
raw: string,
|
|
220
|
+
nodes: readonly ProjectedNode[],
|
|
221
|
+
re: RegExp,
|
|
222
|
+
): readonly RegExpExecArray[] => {
|
|
223
|
+
const skip = skippedRanges(nodes, raw);
|
|
204
224
|
return [...raw.matchAll(re)].filter((m) => !inside(skip, m.index));
|
|
205
|
-
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** Every match of `re` in the linted file outside the skipped ranges. */
|
|
228
|
+
const markupMatches = (
|
|
229
|
+
sourceCode: TexSourceCode,
|
|
230
|
+
re: RegExp,
|
|
231
|
+
): readonly RegExpExecArray[] =>
|
|
232
|
+
markupMatchesIn(sourceCode.raw, sourceCode.ast?.children ?? [], re);
|
|
206
233
|
|
|
207
234
|
const isTex = (sourceCode: RuleSourceCode): sourceCode is TexSourceCode =>
|
|
208
235
|
typeof sourceCode.raw === "string";
|
|
@@ -510,25 +537,42 @@ function leadingZero(context: RuleContext) {
|
|
|
510
537
|
|
|
511
538
|
const FIG_REF = /\b(Fig\.|Figure)(?=~?\\(?:ref|autoref)\b)/g;
|
|
512
539
|
|
|
540
|
+
/** The reference forms another file of the paper writes, read the way the linted file is. */
|
|
541
|
+
const figureFormsIn = (raw: string): readonly (string | undefined)[] =>
|
|
542
|
+
markupMatchesIn(raw, texToMdast(raw).root.children, FIG_REF).map(
|
|
543
|
+
([, form]) => form,
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* The minority form is rewritten to the majority one; on a tie, to the full word. The majority is
|
|
548
|
+
* the PAPER's: a body split into `sections/*.tex` is one document to its reader, so the other files
|
|
549
|
+
* `paperlint lint` names for this one (`paperOf`) are counted too, and only this file is reported.
|
|
550
|
+
*/
|
|
513
551
|
function figureRefStyle(context: RuleContext) {
|
|
514
552
|
const sc = context.sourceCode;
|
|
515
553
|
if (!isTex(sc)) return;
|
|
516
|
-
const
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
554
|
+
const own = markupMatches(sc, FIG_REF);
|
|
555
|
+
const forms: readonly (string | undefined)[] = [
|
|
556
|
+
...own.map(([, form]) => form),
|
|
557
|
+
...otherTexts(paperOf(context.settings), context.filename).flatMap(
|
|
558
|
+
figureFormsIn,
|
|
559
|
+
),
|
|
560
|
+
];
|
|
561
|
+
const short = forms.filter((f) => f === "Fig.").length;
|
|
562
|
+
const long = forms.filter((f) => f === "Figure").length;
|
|
563
|
+
if (short === 0 || long === 0) return;
|
|
564
|
+
const [minority, word, n] =
|
|
565
|
+
short > long ? ["Figure", "Fig.", short] : ["Fig.", "Figure", long];
|
|
566
|
+
own
|
|
567
|
+
.filter(([, form]) => form === minority)
|
|
568
|
+
.forEach((m) => {
|
|
569
|
+
const range = [m.index, m.index + minority.length] as const;
|
|
570
|
+
at(context, range[0], range[1], {
|
|
571
|
+
messageId: "mixed",
|
|
572
|
+
data: { form: minority, word, n: String(n) },
|
|
573
|
+
fix: (f) => f.replaceTextRange(range, word),
|
|
574
|
+
});
|
|
530
575
|
});
|
|
531
|
-
}
|
|
532
576
|
}
|
|
533
577
|
|
|
534
578
|
const rule = (
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
*/
|
|
52
52
|
import { readFileSync } from "node:fs";
|
|
53
53
|
import type { RuleContext } from "./rule-context.ts";
|
|
54
|
+
import { paperOf, textOnDisk } from "./paper-context.ts";
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
57
|
* A copy rather than an import, for the same reason the prose lexicons are copies: a rule may
|
|
@@ -169,7 +170,13 @@ const futurePromise = {
|
|
|
169
170
|
} catch {
|
|
170
171
|
return;
|
|
171
172
|
}
|
|
172
|
-
|
|
173
|
+
// The build mode is the PAPER's: an included file declares none, `paper.tex` does.
|
|
174
|
+
const main = paperOf(context.settings)?.main;
|
|
175
|
+
const declares =
|
|
176
|
+
main === undefined || main === context.filename
|
|
177
|
+
? text
|
|
178
|
+
: textOnDisk(main);
|
|
179
|
+
if (REVIEW_MODE_RE.test(declares)) return;
|
|
173
180
|
// Comments are blanked by CUTTING THE TAIL of the line, exactly as the previous
|
|
174
181
|
// implementation did. Columns do not shift: a match is only possible to the LEFT of
|
|
175
182
|
// `%`, and the left part of the line stays in place character for character. An
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"this file names the rule. That is not expressible on an eleven-line stub, where every line was",
|
|
8
8
|
"written by someone who already knew which rule would read it.",
|
|
9
9
|
"",
|
|
10
|
-
"Recorded 2026-09-19, re-recorded 2026-09-26 for 3.0.0.
|
|
10
|
+
"Recorded 2026-09-19, re-recorded 2026-09-26 for 3.0.0. Two of the three rules are about the",
|
|
11
11
|
"scorecard being deliberately thin (no pdf, no frozen source) rather than about the prose.",
|
|
12
12
|
"paper/leading-zero reports each of the 18 p-values of paperlint#44 where it is (one finding per",
|
|
13
13
|
"occurrence since 3.0.0; before, paper/typography reported them as one count). They are real",
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
"findings": {
|
|
18
18
|
"paper/stages": 2,
|
|
19
19
|
"paper/source": 1,
|
|
20
|
-
"paper/research-question": 1,
|
|
21
20
|
"paper/leading-zero": 18
|
|
22
21
|
}
|
|
23
22
|
}
|
package/package.json
CHANGED
package/scripts/check.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* `npm run check` — ONE command you run on yourself before pushing.
|
|
4
4
|
*
|
|
5
5
|
* ── WHY IT EXISTS, AND IT IS A MEASURED FAILURE, NOT A TIDINESS IDEA ───────────────────────
|
|
6
|
-
* On 2026-09-19 a change to
|
|
6
|
+
* On 2026-09-19 a change to a lint rule was pushed that broke the test suite.
|
|
7
7
|
* The gates were run afterwards — `lint`, `check:readme`, `check:globs`, `check:content-only`,
|
|
8
|
-
* and the rule's own
|
|
8
|
+
* and the rule's own mutation battery, all green — but `npm test` and `test:install` were not,
|
|
9
9
|
* because there were eleven separate scripts and no way to run them except from memory. Five
|
|
10
10
|
* callers across two files had been left behind by the change, and the suite said so; nobody
|
|
11
11
|
* asked it.
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* proves the BEHAVIOUR rather than the presence of a string in YAML.
|
|
18
18
|
*
|
|
19
19
|
* Usage: node scripts/eslint-report-guard.mjs <report.json> <eslint-rc> [paths] [config]
|
|
20
|
+
* An empty or absent `paths` means the CLI was given none and linted the papersDir of the config.
|
|
20
21
|
* Exit: 1 when nothing was measured (unparsable report, or zero files linted)
|
|
21
22
|
* otherwise ESLint's own return code, passed through untouched
|
|
22
23
|
*/
|
|
@@ -26,8 +27,10 @@ import { readFileSync } from "node:fs";
|
|
|
26
27
|
export function guard(
|
|
27
28
|
reportPath,
|
|
28
29
|
eslintRc,
|
|
29
|
-
{ paths = "
|
|
30
|
+
{ paths = "", config = "paperlint.json" } = {},
|
|
30
31
|
) {
|
|
32
|
+
const scope =
|
|
33
|
+
paths.trim() === "" ? "(none: the papersDir of paperlint.json)" : paths;
|
|
31
34
|
const lines = [];
|
|
32
35
|
let res;
|
|
33
36
|
try {
|
|
@@ -48,7 +51,7 @@ export function guard(
|
|
|
48
51
|
if (res.length === 0) {
|
|
49
52
|
lines.push(
|
|
50
53
|
"::error::ESLint linted ZERO files. In the exit code this is indistinguishable from a clean " +
|
|
51
|
-
`run, and it means the paths or the config matched nothing. paths=${
|
|
54
|
+
`run, and it means the paths or the config matched nothing. paths=${scope} config=${config}`,
|
|
52
55
|
);
|
|
53
56
|
return { code: 1, lines };
|
|
54
57
|
}
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
"action.harness.mjs",
|
|
6
6
|
"eslint-rules/install-path-literals.harness.mjs",
|
|
7
7
|
"eslint-rules/latex-language.harness.mjs",
|
|
8
|
-
"eslint-rules/paper-research-question.harness.mjs",
|
|
9
8
|
"eslint-rules/paper-stages.harness.mjs",
|
|
10
9
|
"eslint-rules/papers.harness.mjs",
|
|
11
10
|
"eslint-rules/pdf-last-page-balance.harness.mjs",
|
|
@@ -11,7 +11,7 @@ the verdict line's wording and the `☐`/`☑` marks as they are, or the hook go
|
|
|
11
11
|
|
|
12
12
|
## The front matter
|
|
13
13
|
|
|
14
|
-
Above the sections, the file carries YAML.
|
|
14
|
+
Above the sections, the file carries YAML. The rules read its `stages` field:
|
|
15
15
|
|
|
16
16
|
```yaml
|
|
17
17
|
---
|
|
@@ -22,20 +22,9 @@ stages:
|
|
|
22
22
|
bytes: 305412
|
|
23
23
|
source: versions/2026-07-22-submitted.tex
|
|
24
24
|
sourceBytes: 57210
|
|
25
|
-
researchQuestion: "Does pruning the state space reduce review cost?"
|
|
26
25
|
---
|
|
27
26
|
```
|
|
28
27
|
|
|
29
|
-
`researchQuestion` is **your sentence, written once** — not a label the checker hunts for in the
|
|
30
|
-
prose. `paper/research-question` then asks two things it can actually answer: is it written down,
|
|
31
|
-
and does the paper contain it (whitespace collapsed). Leave the field out and a shipped paper gets
|
|
32
|
-
an advisory finding, which is the right outcome for a position paper with no question: the absence
|
|
33
|
-
becomes a decision on the record rather than an omission.
|
|
34
|
-
|
|
35
|
-
⚠️ Write the sentence the way it appears in the paper. If the paper breaks it across lines that is
|
|
36
|
-
fine — only whitespace is normalised — but LaTeX markup inside the sentence (`\emph{cost}`) will not
|
|
37
|
-
match, and the finding will quote exactly what it looked for.
|
|
38
|
-
|
|
39
28
|
## The row ids, and what each one runs
|
|
40
29
|
|
|
41
30
|
The `id` cell is the **join key** — the same string appears in this template, in every paper's
|
|
@@ -116,10 +105,7 @@ existed. Neither is expressible now. Keep new ids lowercase, one word, and say w
|
|
|
116
105
|
The template itself is a FILE, not this page: `templates/paper/PIPELINE-STATUS.md` at the package
|
|
117
106
|
root (beside `templates/paper/paper.tex` and `paper.md`). `paperlint new <name>` copies it with the paper's
|
|
118
107
|
name filled in, and a project that keeps its own richer scorecard puts one at
|
|
119
|
-
`<papers>/.template/PIPELINE-STATUS.md`, which `paperlint new` prefers.
|
|
120
|
-
here, which meant a command would have had to parse markdown to extract it, and a human had to copy
|
|
121
|
-
it by hand. The copy carries `researchQuestion` (empty) and no `stages` — a new paper has shipped
|
|
122
|
-
nothing.
|
|
108
|
+
`<papers>/.template/PIPELINE-STATUS.md`, which `paperlint new` prefers. The copy carries no `stages` — a new paper has shipped nothing.
|
|
123
109
|
|
|
124
110
|
---
|
|
125
111
|
|
|
@@ -55,10 +55,18 @@ export const includesOf = (t: ParsedTex): readonly Include[] =>
|
|
|
55
55
|
const isDocument = (n: Node): n is Readonly<Ast.Environment> =>
|
|
56
56
|
n.type === "environment" && n.env === "document";
|
|
57
57
|
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* The span of the `document` environment's body, or null when the source has none. A macro's own
|
|
60
|
+
* position ends at its name, so its place is taken with its arguments: a body ending in
|
|
61
|
+
* `\input{sections/a}` ends at the closing brace.
|
|
62
|
+
*/
|
|
59
63
|
export const documentBodyOf = (t: ParsedTex): Span | null =>
|
|
60
64
|
(t.root.content.find(isDocument)?.content ?? [])
|
|
61
|
-
.flatMap((n) =>
|
|
65
|
+
.flatMap((n) =>
|
|
66
|
+
inPlace(n.type === "macro" ? macroPlace(n) : placeOf(n), (span) => [
|
|
67
|
+
span,
|
|
68
|
+
]),
|
|
69
|
+
)
|
|
62
70
|
.reduce<Span | null>(
|
|
63
71
|
(body, span) =>
|
|
64
72
|
body === null ? span : { start: body.start, end: span.end },
|
|
@@ -526,6 +526,14 @@ describe("documentBodyOf — the body of a source that is a document of its own"
|
|
|
526
526
|
expect(latexReader.documentBody(src)).toEqual(body);
|
|
527
527
|
});
|
|
528
528
|
|
|
529
|
+
it("a body ending in a macro ends after that macro's arguments, not after its name", () => {
|
|
530
|
+
const src = "\\begin{document}\nText.\n\\input{sections/a}\\end{document}";
|
|
531
|
+
const body = documentBodyOf(parseLatex(src));
|
|
532
|
+
expect(body && src.slice(body.start, body.end)).toBe(
|
|
533
|
+
"Text.\n\\input{sections/a}",
|
|
534
|
+
);
|
|
535
|
+
});
|
|
536
|
+
|
|
529
537
|
it("none without a document environment, or with an empty one", () => {
|
|
530
538
|
expect(documentBodyOf(parseLatex("just text"))).toBe(null);
|
|
531
539
|
expect(documentBodyOf(parseLatex("\\begin{document}\\end{document}"))).toBe(
|
package/src/build.harness.mjs
CHANGED
|
@@ -264,8 +264,10 @@ try {
|
|
|
264
264
|
tex.calls.every((c) => c.cwd === clean),
|
|
265
265
|
);
|
|
266
266
|
check(
|
|
267
|
-
"🔴 pdflatex got paperlint's venues directory on TEXINPUTS, trailing separator kept",
|
|
268
|
-
tex.calls.every(
|
|
267
|
+
"🔴 pdflatex got the paper's directory, then paperlint's venues directory, on TEXINPUTS, trailing separator kept",
|
|
268
|
+
tex.calls.every(
|
|
269
|
+
(c) => c.texinputs === `.${delimiter}${venues}${delimiter}`,
|
|
270
|
+
),
|
|
269
271
|
);
|
|
270
272
|
|
|
271
273
|
// ── the measure step: facts for the lint rules ────────────────────────────────────────
|
package/src/build.test.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import assert from "node:assert/strict";
|
|
8
8
|
import { existsSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { join } from "node:path";
|
|
9
|
+
import { delimiter, join } from "node:path";
|
|
10
|
+
import { texInputsDir } from "./package-dirs.ts";
|
|
10
11
|
import { test } from "vitest";
|
|
11
12
|
import { present, useTempDir, writeTree } from "../test/support.ts";
|
|
12
13
|
import {
|
|
@@ -344,6 +345,22 @@ test("paths: a paper that IS the working directory is named by its full path", a
|
|
|
344
345
|
assert.equal(existsSync(join(dir, "paper.pdf")), false);
|
|
345
346
|
});
|
|
346
347
|
|
|
348
|
+
test("🔴 pdflatex looks for an \\input in the paper's directory, then paperlint's inputs, then the system tree", async () => {
|
|
349
|
+
const dir = paper();
|
|
350
|
+
const f: Fake = {};
|
|
351
|
+
const inner = fakeRun(f);
|
|
352
|
+
const seen: (string | undefined)[] = [];
|
|
353
|
+
await build(dir, f, {
|
|
354
|
+
run: (bin, args, opts) => {
|
|
355
|
+
if (bin === "pdflatex") seen.push(opts.env?.["TEXINPUTS"]);
|
|
356
|
+
return inner(bin, args, opts);
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
// The same search path the lint rules read includes through (`texSearchPath`).
|
|
360
|
+
// A pass runs in the paper's directory, where `.` is that directory.
|
|
361
|
+
assert.equal(seen[0], [".", texInputsDir(), ""].join(delimiter));
|
|
362
|
+
});
|
|
363
|
+
|
|
347
364
|
test("facts that cannot be read fail the build at `facts`", async () => {
|
|
348
365
|
const { line } = await build(
|
|
349
366
|
paper({ "paper.tex": TEX, "paperlint.json": "{ nope" }),
|
package/src/build.ts
CHANGED
|
@@ -44,7 +44,7 @@ import { createHash } from "node:crypto";
|
|
|
44
44
|
import { delimiter, join, relative } from "node:path";
|
|
45
45
|
// eslint-disable-next-line boundaries/dependencies -- legacy layer, moves behind a port in #76
|
|
46
46
|
import { getParser } from "@unified-latex/unified-latex-util-parse";
|
|
47
|
-
import { texInputsDir } from "./package-dirs.ts";
|
|
47
|
+
import { texInputsDir, texSearchPath } from "./package-dirs.ts";
|
|
48
48
|
import { CONFIG_FILE } from "#lib/paper-config";
|
|
49
49
|
import {
|
|
50
50
|
declaredVenue,
|
|
@@ -268,11 +268,15 @@ export const inputsStep: BuildStep = {
|
|
|
268
268
|
required: false,
|
|
269
269
|
applies: () => ({
|
|
270
270
|
yes: true,
|
|
271
|
-
why: `TEXINPUTS += ${texInputsDir()}`,
|
|
271
|
+
why: `TEXINPUTS += the paper's directory, then ${texInputsDir()}`,
|
|
272
272
|
}),
|
|
273
|
+
// The same search path the lint rules read includes through: one answer to which file is meant.
|
|
274
|
+
// A pass runs IN the paper's directory, so that directory is `.` here. TeX names a file it finds
|
|
275
|
+
// through an absolute entry by that absolute path: with the directory itself on TEXINPUTS, the
|
|
276
|
+
// `broken` build fixture's log said `/tmp/…/paper.tex:6:` instead of `./paper.tex:6:` (measured).
|
|
273
277
|
run: (ctx) => ({
|
|
274
278
|
ok: true,
|
|
275
|
-
env: withTexInputs(ctx.env,
|
|
279
|
+
env: withTexInputs(ctx.env, texSearchPath(".")),
|
|
276
280
|
}),
|
|
277
281
|
};
|
|
278
282
|
|
package/src/cli.harness.mjs
CHANGED
|
@@ -132,8 +132,8 @@ check(
|
|
|
132
132
|
Array.isArray(cfg) && ruleBlocks(cfg).length === 4,
|
|
133
133
|
);
|
|
134
134
|
check(
|
|
135
|
-
"with a LaTeX language
|
|
136
|
-
ruleBlocks(buildConfig({}, {})).length ===
|
|
135
|
+
"with a LaTeX language two blocks are added: paper.tex, and the files it includes",
|
|
136
|
+
ruleBlocks(buildConfig({}, {})).length === 6,
|
|
137
137
|
);
|
|
138
138
|
check(
|
|
139
139
|
"🔴 the project's paper TEMPLATE directory is ignored — flat config does not skip dot-directories",
|
|
@@ -181,7 +181,7 @@ check(
|
|
|
181
181
|
writeFileSync(join(paper, "paper.md"), "# Intro\n\nRQ1: does it hold?\n");
|
|
182
182
|
writeFileSync(
|
|
183
183
|
join(paper, "PIPELINE-STATUS.md"),
|
|
184
|
-
`---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: 100\n source: versions/s.tex\n sourceBytes: 4\
|
|
184
|
+
`---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: 100\n source: versions/s.tex\n sourceBytes: 4\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`,
|
|
185
185
|
);
|
|
186
186
|
writeFileSync(
|
|
187
187
|
join(root, "package.json"),
|
|
@@ -441,32 +441,14 @@ check(
|
|
|
441
441
|
!existsSync(join(dir, WORKFLOW_PATH)),
|
|
442
442
|
);
|
|
443
443
|
check(
|
|
444
|
-
"and the CI
|
|
445
|
-
|
|
446
|
-
/paths: writing/.test(out.text()),
|
|
447
|
-
);
|
|
448
|
-
}
|
|
449
|
-
{
|
|
450
|
-
// Guards: the printed step carries the tag of the running release, not a placeholder.
|
|
451
|
-
const dir = project("ci-printed-pin", { papers: ["writing"] });
|
|
452
|
-
const out = say();
|
|
453
|
-
await init(dir, {
|
|
454
|
-
log: out.log,
|
|
455
|
-
err: out.log,
|
|
456
|
-
interactive: false,
|
|
457
|
-
run: haveAll,
|
|
458
|
-
version: "1.2.3",
|
|
459
|
-
});
|
|
460
|
-
check(
|
|
461
|
-
"🔴 a released version — the printed step is pinned to its tag, no <commit-sha>",
|
|
462
|
-
/uses: zernie\/paperlint@v1\.2\.3\b/.test(out.text()) &&
|
|
463
|
-
!/<commit-sha>/.test(out.text()),
|
|
444
|
+
"and the CI steps are still printed — they just get pasted in by hand",
|
|
445
|
+
out.text().includes("- uses: ./node_modules/paperlint"),
|
|
464
446
|
);
|
|
465
447
|
}
|
|
466
448
|
{
|
|
467
449
|
const dir = project("ci-yes", { papers: ["writing"] });
|
|
468
450
|
const asked = [];
|
|
469
|
-
const wf = await offerWorkflow(dir,
|
|
451
|
+
const wf = await offerWorkflow(dir, {
|
|
470
452
|
interactive: true,
|
|
471
453
|
ask: async (q) => {
|
|
472
454
|
asked.push(q);
|
|
@@ -477,16 +459,22 @@ check(
|
|
|
477
459
|
"saying yes WRITES the workflow",
|
|
478
460
|
wf === "written" && existsSync(join(dir, WORKFLOW_PATH)),
|
|
479
461
|
);
|
|
462
|
+
const steps =
|
|
463
|
+
yamlLoad(readFileSync(join(dir, WORKFLOW_PATH), "utf8"))?.jobs?.papers
|
|
464
|
+
?.steps ?? [];
|
|
480
465
|
check(
|
|
481
|
-
"
|
|
482
|
-
|
|
466
|
+
"🔴 the workflow runs the action from the installed package — one revision, set by the lockfile",
|
|
467
|
+
steps.some((st) => st.uses === "./node_modules/paperlint") &&
|
|
468
|
+
!steps.some((st) =>
|
|
469
|
+
String(st.uses ?? "").startsWith("zernie/paperlint"),
|
|
470
|
+
),
|
|
483
471
|
);
|
|
484
472
|
check(
|
|
485
|
-
"
|
|
486
|
-
|
|
473
|
+
"and it repeats no papers directory — the action reads papersDir from paperlint.json",
|
|
474
|
+
steps.every((st) => st.with?.paths === undefined),
|
|
487
475
|
);
|
|
488
476
|
check("the question is asked exactly once", asked.length === 1);
|
|
489
|
-
const again = await offerWorkflow(dir,
|
|
477
|
+
const again = await offerWorkflow(dir, {
|
|
490
478
|
interactive: true,
|
|
491
479
|
ask: async () => "y",
|
|
492
480
|
});
|
|
@@ -495,29 +483,9 @@ check(
|
|
|
495
483
|
again === "kept",
|
|
496
484
|
);
|
|
497
485
|
}
|
|
498
|
-
{
|
|
499
|
-
const dir = project("ci-pinned", { papers: ["writing"] });
|
|
500
|
-
const wf = await offerWorkflow(dir, "writing", {
|
|
501
|
-
interactive: true,
|
|
502
|
-
ask: async () => "y",
|
|
503
|
-
version: "1.2.3",
|
|
504
|
-
});
|
|
505
|
-
const yaml = existsSync(join(dir, WORKFLOW_PATH))
|
|
506
|
-
? readFileSync(join(dir, WORKFLOW_PATH), "utf8")
|
|
507
|
-
: "";
|
|
508
|
-
const uses = (yamlLoad(yaml)?.jobs?.papers?.steps ?? []).map(
|
|
509
|
-
(s) => s.uses,
|
|
510
|
-
);
|
|
511
|
-
check(
|
|
512
|
-
"🔴 a released version — the written workflow is pinned to its tag, no <commit-sha> left",
|
|
513
|
-
wf === "written" &&
|
|
514
|
-
uses.includes("zernie/paperlint@v1.2.3") &&
|
|
515
|
-
!yaml.includes("<commit-sha>"),
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
486
|
{
|
|
519
487
|
const dir = project("ci-no", { papers: ["writing"] });
|
|
520
|
-
const no = await offerWorkflow(dir,
|
|
488
|
+
const no = await offerWorkflow(dir, {
|
|
521
489
|
interactive: true,
|
|
522
490
|
ask: async () => "",
|
|
523
491
|
});
|
|
@@ -532,7 +500,7 @@ check(
|
|
|
532
500
|
// 🔴 THE `.catch` HERE IS LOAD-BEARING, NOT CAUTION. A leaked exception is a PROPERTY that
|
|
533
501
|
// gets asserted; without it, the harness crashes instead of failing a named assertion,
|
|
534
502
|
// i.e. the real defect would look like a hole in the test rather than a caught one.
|
|
535
|
-
const aborted = await offerWorkflow(dir,
|
|
503
|
+
const aborted = await offerWorkflow(dir, {
|
|
536
504
|
interactive: true,
|
|
537
505
|
ask: async () => {
|
|
538
506
|
throw new Error("Aborted with Ctrl+D");
|
|
@@ -733,7 +701,7 @@ check(
|
|
|
733
701
|
"x".repeat(100),
|
|
734
702
|
);
|
|
735
703
|
const status = (bytes) =>
|
|
736
|
-
`---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: ${bytes}\n source: versions/s.tex\n sourceBytes: 4\
|
|
704
|
+
`---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: ${bytes}\n source: versions/s.tex\n sourceBytes: 4\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`;
|
|
737
705
|
writeFileSync(join(paper, "versions", "s.tex"), "abcd");
|
|
738
706
|
writeFileSync(join(paper, "paper.md"), "# Intro\n\nRQ1: does it hold?\n");
|
|
739
707
|
|
|
@@ -822,7 +790,7 @@ check(
|
|
|
822
790
|
);
|
|
823
791
|
writeFileSync(join(paper, "paper.md"), "# Intro\n\nRQ1: does it hold?\n");
|
|
824
792
|
const status = (bytes) =>
|
|
825
|
-
`---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: ${bytes}\n source: versions/s.tex\n sourceBytes: 4\
|
|
793
|
+
`---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: ${bytes}\n source: versions/s.tex\n sourceBytes: 4\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`;
|
|
826
794
|
writeFileSync(join(paper, "PIPELINE-STATUS.md"), status(100));
|
|
827
795
|
|
|
828
796
|
// findProjectRoot — kept separate from the run so the failure is distinguishable
|
|
@@ -934,7 +902,7 @@ check(
|
|
|
934
902
|
);
|
|
935
903
|
writeFileSync(
|
|
936
904
|
join(paper, "PIPELINE-STATUS.md"),
|
|
937
|
-
`---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: 100\n source: versions/s.tex\n sourceBytes: 4\
|
|
905
|
+
`---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/2026-07-22-submitted.pdf\n bytes: 100\n source: versions/s.tex\n sourceBytes: 4\n---\n# S\n\n| id | note |\n|---|---|\n| cites | bib-authors run |\n`,
|
|
938
906
|
);
|
|
939
907
|
writeFileSync(
|
|
940
908
|
join(root, "paperlint.json"),
|