paperlint 3.0.0 → 3.1.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/README.md +179 -180
- package/dist/build-engine.d.ts.map +1 -1
- package/dist/build-engine.js +6 -2
- package/dist/build-engine.js.map +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +22 -3
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +66 -5
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +230 -30
- package/dist/cli.js.map +1 -1
- package/dist/engine.d.ts +9 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +27 -0
- package/dist/engine.js.map +1 -1
- package/dist/init.js +1 -1
- package/dist/init.js.map +1 -1
- package/dist/new-paper.d.ts +8 -1
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +50 -15
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-files.d.ts +30 -8
- package/dist/paper-files.d.ts.map +1 -1
- package/dist/paper-files.js +56 -29
- package/dist/paper-files.js.map +1 -1
- package/dist/structure.js +1 -1
- package/dist/structure.js.map +1 -1
- package/dist/toolchain.d.ts +5 -1
- package/dist/toolchain.d.ts.map +1 -1
- package/dist/toolchain.js +22 -5
- package/dist/toolchain.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/venue-rules.js +1 -1
- package/dist/venue-rules.js.map +1 -1
- package/docs/configuration.md +48 -4
- package/docs/rules.md +29 -25
- package/docs/skills.md +80 -0
- package/eslint-rules/paper-typography.mjs +47 -7
- package/eslint-rules/paper-typography.test.mjs +40 -0
- package/eslint-rules/review-frontmatter.schema.json +1 -1
- package/fixtures/toolchain-mirror/catalog.txt +1 -0
- package/fixtures/toolchain-mirror/install-tl +4 -1
- package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
- package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
- package/package.json +1 -1
- package/scripts/mutation-batteries.frozen.json +1 -1
- package/src/build-engine.ts +6 -1
- package/src/build.harness.mjs +46 -2
- package/src/build.ts +27 -5
- package/src/cli.harness.mjs +2 -2
- package/src/cli.ts +301 -32
- package/src/engine.harness.mjs +50 -0
- package/src/engine.ts +35 -0
- package/src/init.ts +1 -1
- package/src/linted-set.test.ts +118 -0
- package/src/new-paper.harness.mjs +2 -0
- package/src/new-paper.mutations.mjs +0 -14
- package/src/new-paper.test.ts +172 -1
- package/src/new-paper.ts +84 -17
- package/src/paper-files.test.ts +110 -5
- package/src/paper-files.ts +93 -37
- package/src/paper-settings-commands.test.ts +41 -5
- package/src/parse-args.test.ts +48 -0
- package/src/rules-off.test.ts +111 -0
- package/src/structure.harness.mjs +2 -2
- package/src/structure.ts +1 -1
- package/src/toolchain.harness.mjs +41 -6
- package/src/toolchain.ts +29 -5
- package/src/types.ts +6 -0
- package/src/venue-rules.test.ts +3 -0
- package/src/venue-rules.ts +1 -1
- package/templates/paper/paperlint.json +1 -1
package/src/paper-files.ts
CHANGED
|
@@ -24,6 +24,14 @@
|
|
|
24
24
|
* per owner and its `files` ANDed with the owner's (ESLint's nested-array form), so a rule can
|
|
25
25
|
* only land where its plugin is registered.
|
|
26
26
|
*
|
|
27
|
+
* Both read ONE definition of "the files paperlint lints": `ownedScopes`, each own block's `files`
|
|
28
|
+
* together with its `ignores`. The globs alone are not that definition. The sibling-card block
|
|
29
|
+
* claims every markdown file under `siblings/` EXCEPT the index, and a rule whose plugin is
|
|
30
|
+
* registered for every file (`pdf`) used to keep the consumer block's `files` as they were — so a
|
|
31
|
+
* venue preset's rules reached `siblings/README.md`, a block with no `language` matched it, and
|
|
32
|
+
* ESLint parsed the markdown as JavaScript (#101). Now every generated block narrows to scopes,
|
|
33
|
+
* exceptions included, and a plugin registered everywhere owns exactly what paperlint lints.
|
|
34
|
+
*
|
|
27
35
|
* ESLint's semantics that this leans on, each measured against ESLint 10 before writing it:
|
|
28
36
|
* - a global-ignore pattern that matches a DIRECTORY hides everything inside it, so directories
|
|
29
37
|
* must be un-ignored (`!` + a pattern ending in a slash) before owned files can be;
|
|
@@ -37,11 +45,21 @@ import type { RuleBlock, RuleEntry } from "./rules-config.ts";
|
|
|
37
45
|
/** What this module reads of a config block. */
|
|
38
46
|
interface Block {
|
|
39
47
|
readonly files?: readonly unknown[];
|
|
48
|
+
readonly ignores?: readonly unknown[];
|
|
40
49
|
readonly plugins?: Readonly<
|
|
41
50
|
Record<string, { readonly rules?: Readonly<Record<string, unknown>> }>
|
|
42
51
|
>;
|
|
43
52
|
}
|
|
44
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A set of files paperlint lints: an own block's globs and the exceptions that block makes. A file is
|
|
56
|
+
* in it when it matches one of `files` and none of `ignores`.
|
|
57
|
+
*/
|
|
58
|
+
export interface OwnedScope {
|
|
59
|
+
readonly files: readonly string[];
|
|
60
|
+
readonly ignores: readonly string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
45
63
|
/** A `files` entry: one glob, or several that must ALL match. */
|
|
46
64
|
export type FilesEntry = string | readonly string[];
|
|
47
65
|
|
|
@@ -56,9 +74,42 @@ export interface ScopedBlock {
|
|
|
56
74
|
const globsOf = (b: Block): string[] =>
|
|
57
75
|
(b.files ?? []).filter((f): f is string => typeof f === "string");
|
|
58
76
|
|
|
77
|
+
const stringsOf = (xs: readonly unknown[] | undefined): string[] =>
|
|
78
|
+
(xs ?? []).filter((x): x is string => typeof x === "string");
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Scopes with the same exceptions, merged into one: their globs united, once, in order. Keeps a
|
|
82
|
+
* generated block per distinct set of exceptions rather than one per own block.
|
|
83
|
+
*/
|
|
84
|
+
const merged = (scopes: readonly OwnedScope[]): OwnedScope[] => {
|
|
85
|
+
const byIgnores = new Map<string, OwnedScope>();
|
|
86
|
+
for (const s of scopes) {
|
|
87
|
+
const key = JSON.stringify(s.ignores);
|
|
88
|
+
const had = byIgnores.get(key);
|
|
89
|
+
byIgnores.set(key, {
|
|
90
|
+
files: [...new Set([...(had?.files ?? []), ...s.files])],
|
|
91
|
+
ignores: s.ignores,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return [...byIgnores.values()];
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* THE FILES PAPERLINT LINTS: every own block that names `files`, with its `ignores`. A block without
|
|
99
|
+
* `files` (a global ignore, or the block registering `pdf` for every file) claims nothing.
|
|
100
|
+
*/
|
|
101
|
+
export function ownedScopes(own: readonly unknown[]): OwnedScope[] {
|
|
102
|
+
return merged(
|
|
103
|
+
own
|
|
104
|
+
.map((raw) => raw as Block)
|
|
105
|
+
.filter((b) => b.files !== undefined)
|
|
106
|
+
.map((b) => ({ files: globsOf(b), ignores: stringsOf(b.ignores) })),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
59
110
|
/** Every glob paperlint's own blocks lint, once, in config order. */
|
|
60
111
|
export function ownedPatterns(own: readonly unknown[]): string[] {
|
|
61
|
-
return [...new Set(own.flatMap((
|
|
112
|
+
return [...new Set(ownedScopes(own).flatMap((s) => s.files))];
|
|
62
113
|
}
|
|
63
114
|
|
|
64
115
|
/** ESLint's default ignores, re-applied after directories are un-ignored. */
|
|
@@ -84,40 +135,37 @@ const ruleIdsOf = (b: Block): string[] =>
|
|
|
84
135
|
Object.keys(plugin.rules ?? {}).map((rule) => `${name}/${rule}`),
|
|
85
136
|
);
|
|
86
137
|
|
|
87
|
-
/** An owner seen so far, widened by one more block's globs; `null` (every file) absorbs all. */
|
|
88
|
-
const widen = (
|
|
89
|
-
had: readonly string[] | null | undefined,
|
|
90
|
-
files: readonly string[] | null,
|
|
91
|
-
): readonly string[] | null =>
|
|
92
|
-
had === null || files === null
|
|
93
|
-
? null
|
|
94
|
-
: [...new Set([...(had ?? []), ...files])];
|
|
95
|
-
|
|
96
138
|
/**
|
|
97
|
-
* Rule id → the
|
|
98
|
-
*
|
|
139
|
+
* Rule id → the scopes of the blocks that register its plugin with that rule. A plugin registered
|
|
140
|
+
* for every file (a block without `files`) owns every scope — everything paperlint lints, and not
|
|
141
|
+
* one file more.
|
|
99
142
|
*/
|
|
100
143
|
export function ruleOwners(
|
|
101
144
|
own: readonly unknown[],
|
|
102
|
-
): Map<string, readonly
|
|
103
|
-
const
|
|
145
|
+
): Map<string, readonly OwnedScope[]> {
|
|
146
|
+
const all = ownedScopes(own);
|
|
147
|
+
const found = new Map<string, OwnedScope[]>();
|
|
104
148
|
for (const raw of own) {
|
|
105
149
|
const b = raw as Block;
|
|
106
|
-
const
|
|
107
|
-
|
|
150
|
+
const scopes =
|
|
151
|
+
b.files === undefined
|
|
152
|
+
? all
|
|
153
|
+
: [{ files: globsOf(b), ignores: stringsOf(b.ignores) }];
|
|
154
|
+
for (const id of ruleIdsOf(b))
|
|
155
|
+
found.set(id, [...(found.get(id) ?? []), ...scopes]);
|
|
108
156
|
}
|
|
109
|
-
return
|
|
157
|
+
return new Map([...found].map(([id, scopes]) => [id, merged(scopes)]));
|
|
110
158
|
}
|
|
111
159
|
|
|
112
160
|
/**
|
|
113
|
-
* One consumer block → one block per owner, each carrying only that owner's rules, its `files`
|
|
114
|
-
* ANDed with the
|
|
115
|
-
* absent when its parser did not load) gets no block:
|
|
116
|
-
* naming it to ESLint would only crash.
|
|
161
|
+
* One consumer block → one block per owner scope, each carrying only that owner's rules, its `files`
|
|
162
|
+
* ANDed with the scope's globs and its `ignores` joined by the scope's exceptions. A rule no block
|
|
163
|
+
* of this config registers (the LaTeX block is absent when its parser did not load) gets no block:
|
|
164
|
+
* it has no file to reach in this run, and naming it to ESLint would only crash.
|
|
117
165
|
*/
|
|
118
166
|
export function narrowToOwners(
|
|
119
167
|
block: RuleBlock,
|
|
120
|
-
owners: ReadonlyMap<string, readonly
|
|
168
|
+
owners: ReadonlyMap<string, readonly OwnedScope[]>,
|
|
121
169
|
): ScopedBlock[] {
|
|
122
170
|
const groups = new Map<string, Record<string, RuleEntry>>();
|
|
123
171
|
for (const [id, entry] of Object.entries(block.rules)) {
|
|
@@ -126,19 +174,27 @@ export function narrowToOwners(
|
|
|
126
174
|
const key = JSON.stringify(owner);
|
|
127
175
|
groups.set(key, { ...groups.get(key), [id]: entry });
|
|
128
176
|
}
|
|
129
|
-
return [...groups].
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
177
|
+
return [...groups].flatMap(([key, rules]) =>
|
|
178
|
+
(JSON.parse(key) as OwnedScope[]).map((scope) =>
|
|
179
|
+
within(block, scope, rules),
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `block`'s rules over the files both it and `scope` claim, minus both sets of exceptions. */
|
|
185
|
+
function within(
|
|
186
|
+
block: RuleBlock,
|
|
187
|
+
scope: OwnedScope,
|
|
188
|
+
rules: Readonly<Record<string, RuleEntry>>,
|
|
189
|
+
): ScopedBlock {
|
|
190
|
+
const files: readonly FilesEntry[] = block.files
|
|
191
|
+
? block.files.flatMap((f) => scope.files.map((o) => [f, o]))
|
|
192
|
+
: scope.files;
|
|
193
|
+
const ignores = [...(block.ignores ?? []), ...scope.ignores];
|
|
194
|
+
return {
|
|
195
|
+
basePath: block.basePath,
|
|
196
|
+
files,
|
|
197
|
+
...(ignores.length ? { ignores } : {}),
|
|
198
|
+
rules,
|
|
199
|
+
};
|
|
144
200
|
}
|
|
@@ -42,10 +42,11 @@ function project(files: Record<string, string> = {}): string {
|
|
|
42
42
|
|
|
43
43
|
async function lint(
|
|
44
44
|
root: string,
|
|
45
|
+
args: string[] = [],
|
|
45
46
|
): Promise<{ code: number; out: string; err: string }> {
|
|
46
47
|
const out: string[] = [];
|
|
47
48
|
const err: string[] = [];
|
|
48
|
-
const code = await run(["lint", "--json"], {
|
|
49
|
+
const code = await run(["lint", "--json", ...args], {
|
|
49
50
|
cwd: root,
|
|
50
51
|
log: (s: string) => out.push(s),
|
|
51
52
|
err: (s: string) => err.push(s),
|
|
@@ -58,6 +59,43 @@ const rulesIn = (stdout: string, paper: string): string[] =>
|
|
|
58
59
|
.filter((r) => r.filePath.endsWith(join(paper, "paper.tex")))
|
|
59
60
|
.flatMap((r) => r.messages.map((m) => m.ruleId));
|
|
60
61
|
|
|
62
|
+
describe("🔴 an optional rule that reaches no paper — judged against the PROJECT, not the run (#103)", () => {
|
|
63
|
+
const THIRD = {
|
|
64
|
+
"papers/c/paper.tex":
|
|
65
|
+
"\\documentclass{article}\n\\begin{document}x\\end{document}\n",
|
|
66
|
+
"papers/c/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
|
|
67
|
+
};
|
|
68
|
+
const rootRules = (files: string[]) => ({
|
|
69
|
+
"paperlint.json": JSON.stringify({
|
|
70
|
+
rules: [{ files, rules: { "pdf/last-page-balance": "error" } }],
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("linting paper c alone passes when the block reaches papers a and b", async () => {
|
|
75
|
+
const root = project({
|
|
76
|
+
...THIRD,
|
|
77
|
+
...rootRules(["papers/a/**", "papers/b/**"]),
|
|
78
|
+
});
|
|
79
|
+
const r = await lint(root, ["papers/c"]);
|
|
80
|
+
// Guards: the defect — the guard counted only the paper.tex files this run linted.
|
|
81
|
+
expect(r.err).not.toMatch(/no linted paper\.tex gets it/);
|
|
82
|
+
expect(r.code).toBe(0);
|
|
83
|
+
expect(rulesIn(r.out, "c")).not.toContain("pdf/last-page-balance");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("…and a glob that reaches no paper of the project still fails, from a subset too", async () => {
|
|
87
|
+
const root = project({ ...THIRD, ...rootRules(["papers/typo/**"]) });
|
|
88
|
+
for (const args of [["papers/c"], []]) {
|
|
89
|
+
const r = await lint(root, args);
|
|
90
|
+
// Guards: the other half — widening the judged set did not turn the guard off.
|
|
91
|
+
expect(r.err).toMatch(
|
|
92
|
+
/pdf\/last-page-balance is turned on in "rules", but no paper\.tex/,
|
|
93
|
+
);
|
|
94
|
+
expect(r.code).toBe(1);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
61
99
|
describe("paperlint lint — `rules` in a paper's paperlint.json", () => {
|
|
62
100
|
it("turns an optional rule on for that paper alone", async () => {
|
|
63
101
|
const root = project({
|
|
@@ -177,11 +215,9 @@ describe("paperlint lint — the venue preset's rules", () => {
|
|
|
177
215
|
expect(cfgOf.ok && cfgOf.value).toEqual({
|
|
178
216
|
preset: [
|
|
179
217
|
{
|
|
218
|
+
// Scoped to the paper by `basePath` alone: which of its files the rule reaches is decided
|
|
219
|
+
// once, by narrowing to paperlint's owned scopes (#101), not by a copy of their globs.
|
|
180
220
|
basePath: join(root, "papers/a"),
|
|
181
|
-
files: expect.arrayContaining([
|
|
182
|
-
"**/paper.tex",
|
|
183
|
-
"**/PIPELINE-STATUS.md",
|
|
184
|
-
]),
|
|
185
221
|
rules: { "pdf/last-page-balance": ["error", { tolerancePt: 120 }] },
|
|
186
222
|
},
|
|
187
223
|
],
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--flag=value` and unknown flags — the command line the CI action actually writes.
|
|
3
|
+
*
|
|
4
|
+
* action.yml runs `npx paperlint lint $PATHS --max-warnings="$MAXWARN" --json`. The parser knew
|
|
5
|
+
* only `--max-warnings <n>`, so `--max-warnings=-1` fell through to the list of PATHS: ESLint was
|
|
6
|
+
* handed a file named `--max-warnings=-1`, found nothing for it, and the run reported "nothing was
|
|
7
|
+
* linted under papers, --max-warnings=-1". Any flag the parser did not know became a path the same
|
|
8
|
+
* way, silently.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { parseArgs, run } from "./cli.ts";
|
|
12
|
+
|
|
13
|
+
describe("parseArgs — `--flag=value` is the same as `--flag value`", () => {
|
|
14
|
+
it("🔴 the action's own command line: `--max-warnings=-1` is a threshold, not a path", () => {
|
|
15
|
+
const a = parseArgs(["lint", "papers", "--max-warnings=-1", "--json"]);
|
|
16
|
+
// Guards: the defect — the flag landed in `paths`.
|
|
17
|
+
expect(a.paths).toEqual(["papers"]);
|
|
18
|
+
expect(a.maxWarnings).toBe(-1);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it.each([
|
|
22
|
+
["--config=o.json", "config", "o.json"],
|
|
23
|
+
["--venue=agenticdev", "venue", "agenticdev"],
|
|
24
|
+
["--kind=short", "kind", "short"],
|
|
25
|
+
["--format=md", "format", "md"],
|
|
26
|
+
["--paper=p", "paper", "p"],
|
|
27
|
+
] as const)("%s", (flag, key, value) => {
|
|
28
|
+
const a = parseArgs(["new", "x", flag]);
|
|
29
|
+
expect(a[key]).toBe(value);
|
|
30
|
+
expect(a.paths).toEqual(["x"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("an empty value after `=` is a missing value, refused like `--config` with none", () => {
|
|
34
|
+
expect(parseArgs(["lint", "--config="]).missingValue).toBe("--config");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("an unknown flag is refused, not read as a path", () => {
|
|
39
|
+
it("exits 2 and names the flag", async () => {
|
|
40
|
+
const err: string[] = [];
|
|
41
|
+
const code = await run(["lint", "--frobnicate"], {
|
|
42
|
+
log: () => {},
|
|
43
|
+
err: (s: string) => err.push(s),
|
|
44
|
+
});
|
|
45
|
+
expect(code).toBe(2);
|
|
46
|
+
expect(err.join("\n")).toMatch(/unknown flag `--frobnicate`/);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A consumer that ALSO lints paper files with its own ESLint config (#104).
|
|
3
|
+
*
|
|
4
|
+
* 3.0.0 made `% eslint-disable-next-line <rule> -- <why>` the way to keep a deliberate exception.
|
|
5
|
+
* A project that runs its own `eslint` over the same `paper.tex` — for its own rules, with
|
|
6
|
+
* paperlint's LaTeX language — did not register paperlint's rule plugins, so that run failed on the
|
|
7
|
+
* directive: "Definition for rule 'paper/leading-zero' was not found". `rulesOff` is the fragment
|
|
8
|
+
* such a config spreads in: every rule paperlint ships, registered and turned off.
|
|
9
|
+
*/
|
|
10
|
+
import { ESLint, type Linter } from "eslint";
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
// @ts-expect-error — the module is .mjs and has no types.
|
|
13
|
+
import { texLanguage } from "../eslint-rules/latex-language.mjs";
|
|
14
|
+
import { rulePlugins, rulesOff, SHIPPED_RULES } from "./cli.ts";
|
|
15
|
+
|
|
16
|
+
const PAPER = [
|
|
17
|
+
"\\documentclass{article}",
|
|
18
|
+
"\\begin{document}",
|
|
19
|
+
"% eslint-disable-next-line paper/leading-zero -- quoted from the reviewer",
|
|
20
|
+
"We use a threshold of .05 throughout.",
|
|
21
|
+
"% eslint-disable-next-line bib/reachable-entry -- kept for the artifact",
|
|
22
|
+
"Nothing else.",
|
|
23
|
+
"\\end{document}",
|
|
24
|
+
"",
|
|
25
|
+
].join("\n");
|
|
26
|
+
|
|
27
|
+
/** The consumer's own config: its LaTeX files, with paperlint's language, and its own rules. */
|
|
28
|
+
async function lintAsConsumer(
|
|
29
|
+
fragment: Linter.Config[],
|
|
30
|
+
): Promise<Linter.LintMessage[]> {
|
|
31
|
+
const eslint = new ESLint({
|
|
32
|
+
overrideConfigFile: true,
|
|
33
|
+
overrideConfig: [
|
|
34
|
+
...fragment,
|
|
35
|
+
{
|
|
36
|
+
files: ["**/*.tex"],
|
|
37
|
+
plugins:
|
|
38
|
+
fragment.length > 0
|
|
39
|
+
? {}
|
|
40
|
+
: { tex: { languages: { latex: texLanguage } } },
|
|
41
|
+
language: "tex/latex",
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
const [res] = await eslint.lintText(PAPER, {
|
|
46
|
+
filePath: "papers/a/paper.tex",
|
|
47
|
+
});
|
|
48
|
+
return res!.messages;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("rulesOff — paperlint's rules, registered and off, for a consumer's own ESLint run", () => {
|
|
52
|
+
it("without it, the documented escape hatch breaks the consumer's run (the defect)", async () => {
|
|
53
|
+
const messages = await lintAsConsumer([]);
|
|
54
|
+
expect(messages.map((m) => m.message)).toContain(
|
|
55
|
+
"Definition for rule 'paper/leading-zero' was not found.",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("🔴 spreading it: no 'Definition … not found', and no paperlint finding runs", async () => {
|
|
60
|
+
const messages = await lintAsConsumer(rulesOff(texLanguage));
|
|
61
|
+
// Guards: the defect — every directive names a rule ESLint now knows.
|
|
62
|
+
expect(messages.map((m) => m.message).join("\n")).not.toMatch(
|
|
63
|
+
/Definition for rule/,
|
|
64
|
+
);
|
|
65
|
+
// Guards: off means off — paperlint's own run judges these files, not the consumer's.
|
|
66
|
+
expect(messages.filter((m) => m.ruleId?.startsWith("paper/"))).toEqual([]);
|
|
67
|
+
// Guards: a directive naming an OFF rule suppresses nothing, and ESLint would call it unused
|
|
68
|
+
// ("Unused eslint-disable directive (no problems were reported from 'paper/leading-zero')").
|
|
69
|
+
expect(messages).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("registers every rule paperlint ships, each off, and carries the LaTeX language", () => {
|
|
73
|
+
const [cfg] = rulesOff(texLanguage);
|
|
74
|
+
expect(new Set(Object.keys(cfg?.rules ?? {}))).toEqual(SHIPPED_RULES);
|
|
75
|
+
expect(Object.values(cfg?.rules ?? {}).every((e) => e === "off")).toBe(
|
|
76
|
+
true,
|
|
77
|
+
);
|
|
78
|
+
const plugins = rulePlugins(texLanguage);
|
|
79
|
+
const defined = new Set<string>();
|
|
80
|
+
for (const [name, p] of Object.entries(plugins))
|
|
81
|
+
for (const r of Object.keys(p.rules ?? {})) defined.add(`${name}/${r}`);
|
|
82
|
+
expect(defined).toEqual(SHIPPED_RULES);
|
|
83
|
+
expect(plugins["tex"]?.languages?.["latex"]).toBe(texLanguage);
|
|
84
|
+
// `@eslint/markdown` is a dependency's plugin: the consumer registers its own.
|
|
85
|
+
expect(plugins["markdown"]).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("the unused-directive report is off on paper files only, not on the consumer's code", async () => {
|
|
89
|
+
const eslint = new ESLint({
|
|
90
|
+
overrideConfigFile: true,
|
|
91
|
+
overrideConfig: [
|
|
92
|
+
...rulesOff(texLanguage),
|
|
93
|
+
{
|
|
94
|
+
files: ["**/*.js"],
|
|
95
|
+
linterOptions: { reportUnusedDisableDirectives: "warn" },
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
const [res] = await eslint.lintText(
|
|
100
|
+
"// eslint-disable-next-line paper/leading-zero -- nothing to silence\nconst x = 1;\n",
|
|
101
|
+
{ filePath: "src/x.js" },
|
|
102
|
+
);
|
|
103
|
+
expect(res!.messages.map((m) => m.message)).toEqual([
|
|
104
|
+
"Unused eslint-disable directive (no problems were reported from 'paper/leading-zero').",
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("without a LaTeX language, the `tex` plugin carries rules only", () => {
|
|
109
|
+
expect(rulePlugins()["tex"]?.languages).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -71,8 +71,8 @@ try {
|
|
|
71
71
|
);
|
|
72
72
|
check(
|
|
73
73
|
"and the message names the CONSEQUENCE, not a restatement of the condition",
|
|
74
|
-
/
|
|
75
|
-
/
|
|
74
|
+
/are skipped/.test(at("no-scorecard")[0].message) &&
|
|
75
|
+
/stage, source and research-question checks/.test(
|
|
76
76
|
at("no-scorecard")[0].message,
|
|
77
77
|
),
|
|
78
78
|
);
|
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
|
|
125
|
+
return `the stage, source and research-question checks are skipped for \`${dirName}\``;
|
|
126
126
|
return `declared as required by your \`structure\` configuration`;
|
|
127
127
|
}
|
|
128
128
|
|
|
@@ -163,14 +163,19 @@ for (const f of [
|
|
|
163
163
|
"stub-pdflatex",
|
|
164
164
|
"stub-kpsewhich",
|
|
165
165
|
"stub-tlmgr",
|
|
166
|
+
"stub-fmtutil-sys",
|
|
166
167
|
"release-texlive.txt",
|
|
167
168
|
])
|
|
168
169
|
copyFileSync(join(FIXTURE, f), join(staging, f));
|
|
169
170
|
spawnSync("chmod", [
|
|
170
171
|
"+x",
|
|
171
|
-
...[
|
|
172
|
-
|
|
173
|
-
|
|
172
|
+
...[
|
|
173
|
+
"install-tl",
|
|
174
|
+
"stub-pdflatex",
|
|
175
|
+
"stub-kpsewhich",
|
|
176
|
+
"stub-tlmgr",
|
|
177
|
+
"stub-fmtutil-sys",
|
|
178
|
+
].map((f) => join(staging, f)),
|
|
174
179
|
]);
|
|
175
180
|
spawnSync(
|
|
176
181
|
"tar",
|
|
@@ -192,6 +197,10 @@ writeFileSync(
|
|
|
192
197
|
const url = (dir) => pathToFileURL(dir).href;
|
|
193
198
|
const MISSING = url(join(work, "no-such-mirror"));
|
|
194
199
|
|
|
200
|
+
/** A call that downloads or installs. `tlmgr check depends` only reads, so it is not one. */
|
|
201
|
+
const installs = (c) =>
|
|
202
|
+
c.cmd === "curl" || (c.cmd.endsWith("tlmgr") && c.args.includes("install"));
|
|
203
|
+
|
|
195
204
|
/** A `spawnSync` that records every program it was asked to run. */
|
|
196
205
|
const recorder = () => {
|
|
197
206
|
const calls = [];
|
|
@@ -364,8 +373,33 @@ const cmd = ({ banal = BANAL, ...over } = {}) => {
|
|
|
364
373
|
);
|
|
365
374
|
check(
|
|
366
375
|
"second run: downloads nothing and installs nothing",
|
|
367
|
-
!r.calls.some(
|
|
368
|
-
JSON.stringify(r.calls.map((c) => c.cmd)),
|
|
376
|
+
!r.calls.some(installs),
|
|
377
|
+
JSON.stringify(r.calls.map((c) => [c.cmd, ...c.args])),
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
// Guards: a tree with every venue package can still be broken. install-tl that lost a package
|
|
381
|
+
// to a mirror prints "continuing anyway"; seen 2026-09-26 with unicode-data, after which the
|
|
382
|
+
// pdflatex format was never built and every build died before writing a log.
|
|
383
|
+
{
|
|
384
|
+
const tree = join(root, "2026");
|
|
385
|
+
writeFileSync(join(tree, "missing-deps.txt"), "unicode-data\n");
|
|
386
|
+
const check1 = cmd({ check: true });
|
|
387
|
+
check(
|
|
388
|
+
"🔴 a dropped base dependency: --check does not call the tree complete",
|
|
389
|
+
check1.code === 1,
|
|
390
|
+
`${check1.out}\n${check1.err}`,
|
|
391
|
+
);
|
|
392
|
+
const r = cmd();
|
|
393
|
+
check(
|
|
394
|
+
"🔴 a dropped base dependency: the run installs it, then builds the missing formats",
|
|
395
|
+
r.code === 0 &&
|
|
396
|
+
readFileSync(join(tree, "tlmgr.log"), "utf8").includes("unicode-data") &&
|
|
397
|
+
readFileSync(join(tree, "fmtutil.log"), "utf8").trim() === "--missing",
|
|
398
|
+
`${r.out}\n${r.err}`,
|
|
399
|
+
);
|
|
400
|
+
check(
|
|
401
|
+
"a dropped base dependency: the tree is whole afterwards",
|
|
402
|
+
readFileSync(join(tree, "missing-deps.txt"), "utf8").trim() === "",
|
|
369
403
|
);
|
|
370
404
|
}
|
|
371
405
|
{
|
|
@@ -500,6 +534,7 @@ const mirrorFor = (name, year, installerYear = year) => {
|
|
|
500
534
|
"stub-pdflatex",
|
|
501
535
|
"stub-kpsewhich",
|
|
502
536
|
"stub-tlmgr",
|
|
537
|
+
"stub-fmtutil-sys",
|
|
503
538
|
])
|
|
504
539
|
copyFileSync(join(staging, f), join(stage, f));
|
|
505
540
|
writeFileSync(
|
|
@@ -544,7 +579,7 @@ check(
|
|
|
544
579
|
r.out.includes("TeX Live 2026") &&
|
|
545
580
|
r.out.includes("nothing to do") &&
|
|
546
581
|
!existsSync(join(years, "2027")) &&
|
|
547
|
-
!r.calls.some(
|
|
582
|
+
!r.calls.some(installs),
|
|
548
583
|
`${r.out}\n${JSON.stringify(r.calls.map((c) => c.cmd))}`,
|
|
549
584
|
);
|
|
550
585
|
}
|
package/src/toolchain.ts
CHANGED
|
@@ -45,6 +45,7 @@ import { join } from "node:path";
|
|
|
45
45
|
import {
|
|
46
46
|
isExecutable,
|
|
47
47
|
missingTools,
|
|
48
|
+
missingDependencies,
|
|
48
49
|
probeTree,
|
|
49
50
|
supportedPlatform,
|
|
50
51
|
type Runner,
|
|
@@ -240,14 +241,20 @@ function yearAfter(lines: readonly string[], label: string): string | null {
|
|
|
240
241
|
: null;
|
|
241
242
|
}
|
|
242
243
|
|
|
243
|
-
/**
|
|
244
|
+
/**
|
|
245
|
+
* What a tree lacks: the venue's packages by `kpsewhich`, tools by executable, and packages the
|
|
246
|
+
* tree's own database depends on but never installed (`tlmgr check depends`).
|
|
247
|
+
*/
|
|
244
248
|
export interface Gaps {
|
|
245
249
|
readonly packages: readonly string[];
|
|
246
250
|
readonly tools: readonly string[];
|
|
251
|
+
readonly dependencies: readonly string[];
|
|
247
252
|
}
|
|
248
253
|
|
|
249
254
|
export const noGaps = (g: Gaps): boolean =>
|
|
250
|
-
g.packages.length === 0 &&
|
|
255
|
+
g.packages.length === 0 &&
|
|
256
|
+
g.tools.length === 0 &&
|
|
257
|
+
g.dependencies.length === 0;
|
|
251
258
|
|
|
252
259
|
/** `acmart (acmart.cls), texcount (texcount)` — a gap named with the file that proves it. */
|
|
253
260
|
export function describeGaps(g: Gaps, tex: TexRequirements): string {
|
|
@@ -258,6 +265,9 @@ export function describeGaps(g: Gaps, tex: TexRequirements): string {
|
|
|
258
265
|
return [
|
|
259
266
|
...named(g.packages, tex.packages),
|
|
260
267
|
...named(g.tools, tex.tools),
|
|
268
|
+
...g.dependencies.map(
|
|
269
|
+
(d) => `${d} (a TeX Live dependency the install left out)`,
|
|
270
|
+
),
|
|
261
271
|
].join(", ");
|
|
262
272
|
}
|
|
263
273
|
|
|
@@ -296,6 +306,7 @@ export function gapsOf(
|
|
|
296
306
|
return {
|
|
297
307
|
packages: probeTree(tree.bin, tex.packages, run),
|
|
298
308
|
tools: missingTools(tex.tools, tree.bin),
|
|
309
|
+
dependencies: missingDependencies(tree.bin, run),
|
|
299
310
|
};
|
|
300
311
|
}
|
|
301
312
|
|
|
@@ -460,7 +471,8 @@ export function installPackages(
|
|
|
460
471
|
let last: string[] = [];
|
|
461
472
|
for (const mirror of mirrors) {
|
|
462
473
|
if (noGaps(gaps)) break;
|
|
463
|
-
const want = [...gaps.packages, ...gaps.tools];
|
|
474
|
+
const want = [...gaps.packages, ...gaps.tools, ...gaps.dependencies];
|
|
475
|
+
const repairsBase = gaps.dependencies.length > 0;
|
|
464
476
|
io.log(` tlmgr: installing ${want.length} package(s) from ${mirror}…`);
|
|
465
477
|
const r = io.run(
|
|
466
478
|
join(tree.bin, "tlmgr"),
|
|
@@ -481,6 +493,10 @@ export function installPackages(
|
|
|
481
493
|
],
|
|
482
494
|
};
|
|
483
495
|
last = tail(r);
|
|
496
|
+
// A dependency of the base was missing, so the formats it feeds were never built (pdflatex dies
|
|
497
|
+
// before writing a log without them). Build the missing ones now that the package is in.
|
|
498
|
+
if (repairsBase)
|
|
499
|
+
io.run(join(tree.bin, "fmtutil-sys"), ["--missing"], quiet(io, TLMGR_MS));
|
|
484
500
|
gaps = gapsOf(tree, tex, io.run);
|
|
485
501
|
}
|
|
486
502
|
return { ok: true, value: { gaps, tail: last } };
|
|
@@ -688,9 +704,17 @@ function report(o: Resolved, tree: CachedTree | null): number {
|
|
|
688
704
|
o.log(binLine(tree));
|
|
689
705
|
return 0;
|
|
690
706
|
}
|
|
691
|
-
const
|
|
707
|
+
const declared = gaps.packages.length + gaps.tools.length;
|
|
708
|
+
// A dependency the install left out is not one of the venue's declared packages: counted apart,
|
|
709
|
+
// so the line never reads "lacks 0 of 51" followed by a name.
|
|
710
|
+
const what = [
|
|
711
|
+
...(declared ? [`${declared} of ${n} declared packages`] : []),
|
|
712
|
+
...(gaps.dependencies.length
|
|
713
|
+
? [`${gaps.dependencies.length} TeX Live base package(s)`]
|
|
714
|
+
: []),
|
|
715
|
+
].join(" and ");
|
|
692
716
|
o.log(
|
|
693
|
-
`✗ TeX Live ${tree.year} in ${tree.dir} lacks ${
|
|
717
|
+
`✗ TeX Live ${tree.year} in ${tree.dir} lacks ${what}: ${describeGaps(gaps, o.tex)}`,
|
|
694
718
|
);
|
|
695
719
|
o.log(` run \`npx paperlint toolchain\` to install them`);
|
|
696
720
|
return 1;
|
package/src/types.ts
CHANGED
|
@@ -60,11 +60,17 @@ export interface Args {
|
|
|
60
60
|
paper: string | null;
|
|
61
61
|
/** `--format tex|md` for `new` and `init --paper`. Validated by the command, not here. */
|
|
62
62
|
format: string | null;
|
|
63
|
+
/** `--venue <preset>` for `new`: a shipped preset name, or a path to the project's own. */
|
|
64
|
+
venue: string | null;
|
|
65
|
+
/** `--kind <kind>` for `new`: one of the venue preset's kinds. */
|
|
66
|
+
kind: string | null;
|
|
63
67
|
/** `--hooks=<mode>` — parsed only so it can be REFUSED by name rather than read as a path. */
|
|
64
68
|
hooksMode: string | null;
|
|
65
69
|
help?: boolean;
|
|
66
70
|
/** A flag that turned out to have no value. A non-empty field is a REFUSAL, not a default. */
|
|
67
71
|
missingValue?: string;
|
|
72
|
+
/** The first flag the parser does not know. Refused, never read as a path. */
|
|
73
|
+
unknownFlag?: string;
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
/** A finding about the PRESENCE of a file — what an ESLint rule cannot express. */
|
package/src/venue-rules.test.ts
CHANGED
|
@@ -316,6 +316,9 @@ describe("pdf/measured — a paper that names a venue and was not measured says
|
|
|
316
316
|
const fs = lint({ venue: DECL, facts: null, pdf: null });
|
|
317
317
|
expect(ids(fs)).toEqual(["pdf/measured:unbuilt"]);
|
|
318
318
|
expect(fs[0]?.message).toMatch(/paperlint build/);
|
|
319
|
+
// Guards: the message stays one short line — it is the first thing a new user sees.
|
|
320
|
+
expect(fs[0]?.message).toMatch(/^not built yet, so /);
|
|
321
|
+
expect(fs[0]?.message.length).toBeLessThan(110);
|
|
319
322
|
});
|
|
320
323
|
|
|
321
324
|
it("facts without geometry (no banal): pdf/measured names it, the three geometry rules are silent, fonts still judge", () => {
|
package/src/venue-rules.ts
CHANGED
|
@@ -430,7 +430,7 @@ const META: Readonly<Record<VenueRuleName, Meta>> = {
|
|
|
430
430
|
},
|
|
431
431
|
messages: {
|
|
432
432
|
unbuilt:
|
|
433
|
-
"
|
|
433
|
+
"not built yet, so `{{venue}}`'s page limit, fonts and format are not checked — run `paperlint build`",
|
|
434
434
|
noPreset:
|
|
435
435
|
'this paper names no venue preset yet, so its page limit, fonts and format are not checked — set "extends" in {{file}} (e.g. "paperlint:agenticdev"; see docs/rules.md)',
|
|
436
436
|
noGeometry:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "Name this paper's venue preset in \"extends\", e.g. \"paperlint:agenticdev\" (
|
|
2
|
+
"$comment": "Name this paper's venue preset in \"extends\": \"paperlint:<name>\" for a shipped one, e.g. \"paperlint:agenticdev\" (`npx paperlint --help` lists them), or a path to your own preset relative to THIS file, e.g. \"../../venues/my-venue.jsonc\"; add \"kind\" for its page limit. `npx paperlint new <name> --venue <preset>` writes both for a new paper. Until then the page limit, fonts and format are not checked.",
|
|
3
3
|
"extends": null
|
|
4
4
|
}
|