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.
Files changed (73) hide show
  1. package/README.md +179 -180
  2. package/dist/build-engine.d.ts.map +1 -1
  3. package/dist/build-engine.js +6 -2
  4. package/dist/build-engine.js.map +1 -1
  5. package/dist/build.d.ts.map +1 -1
  6. package/dist/build.js +22 -3
  7. package/dist/build.js.map +1 -1
  8. package/dist/cli.d.ts +66 -5
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +230 -30
  11. package/dist/cli.js.map +1 -1
  12. package/dist/engine.d.ts +9 -0
  13. package/dist/engine.d.ts.map +1 -1
  14. package/dist/engine.js +27 -0
  15. package/dist/engine.js.map +1 -1
  16. package/dist/init.js +1 -1
  17. package/dist/init.js.map +1 -1
  18. package/dist/new-paper.d.ts +8 -1
  19. package/dist/new-paper.d.ts.map +1 -1
  20. package/dist/new-paper.js +50 -15
  21. package/dist/new-paper.js.map +1 -1
  22. package/dist/paper-files.d.ts +30 -8
  23. package/dist/paper-files.d.ts.map +1 -1
  24. package/dist/paper-files.js +56 -29
  25. package/dist/paper-files.js.map +1 -1
  26. package/dist/structure.js +1 -1
  27. package/dist/structure.js.map +1 -1
  28. package/dist/toolchain.d.ts +5 -1
  29. package/dist/toolchain.d.ts.map +1 -1
  30. package/dist/toolchain.js +22 -5
  31. package/dist/toolchain.js.map +1 -1
  32. package/dist/types.d.ts +6 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/venue-rules.js +1 -1
  35. package/dist/venue-rules.js.map +1 -1
  36. package/docs/configuration.md +48 -4
  37. package/docs/rules.md +29 -25
  38. package/docs/skills.md +80 -0
  39. package/eslint-rules/paper-typography.mjs +47 -7
  40. package/eslint-rules/paper-typography.test.mjs +40 -0
  41. package/eslint-rules/review-frontmatter.schema.json +1 -1
  42. package/fixtures/toolchain-mirror/catalog.txt +1 -0
  43. package/fixtures/toolchain-mirror/install-tl +4 -1
  44. package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
  45. package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
  46. package/package.json +1 -1
  47. package/scripts/mutation-batteries.frozen.json +1 -1
  48. package/src/build-engine.ts +6 -1
  49. package/src/build.harness.mjs +46 -2
  50. package/src/build.ts +27 -5
  51. package/src/cli.harness.mjs +2 -2
  52. package/src/cli.ts +301 -32
  53. package/src/engine.harness.mjs +50 -0
  54. package/src/engine.ts +35 -0
  55. package/src/init.ts +1 -1
  56. package/src/linted-set.test.ts +118 -0
  57. package/src/new-paper.harness.mjs +2 -0
  58. package/src/new-paper.mutations.mjs +0 -14
  59. package/src/new-paper.test.ts +172 -1
  60. package/src/new-paper.ts +84 -17
  61. package/src/paper-files.test.ts +110 -5
  62. package/src/paper-files.ts +93 -37
  63. package/src/paper-settings-commands.test.ts +41 -5
  64. package/src/parse-args.test.ts +48 -0
  65. package/src/rules-off.test.ts +111 -0
  66. package/src/structure.harness.mjs +2 -2
  67. package/src/structure.ts +1 -1
  68. package/src/toolchain.harness.mjs +41 -6
  69. package/src/toolchain.ts +29 -5
  70. package/src/types.ts +6 -0
  71. package/src/venue-rules.test.ts +3 -0
  72. package/src/venue-rules.ts +1 -1
  73. package/templates/paper/paperlint.json +1 -1
@@ -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((b) => globsOf(b as Block)))];
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 globs of the blocks that register its plugin with that rule. `null`: the plugin
98
- * is registered for every file (a block without `files`), so the rule resolves anywhere.
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 string[] | null> {
103
- const owners = new Map<string, readonly string[] | null>();
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 files = b.files ? globsOf(b) : null;
107
- for (const id of ruleIdsOf(b)) owners.set(id, widen(owners.get(id), files));
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 owners;
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 owner's globs. A rule no block of this config registers (the LaTeX block is
115
- * absent when its parser did not load) gets no block: it has no file to reach in this run, and
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 string[] | null>,
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].map(([key, rules]) => {
130
- const owner = JSON.parse(key) as string[] | null;
131
- const files: readonly FilesEntry[] | undefined =
132
- owner === null
133
- ? block.files
134
- : block.files
135
- ? block.files.flatMap((f) => owner.map((o) => [f, o]))
136
- : owner;
137
- return {
138
- basePath: block.basePath,
139
- ...(files ? { files } : {}),
140
- ...(block.ignores ? { ignores: block.ignores } : {}),
141
- rules,
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
- /is checked/.test(at("no-scorecard")[0].message) &&
75
- /stages, sources or research question/.test(
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 `\`paper/stages\`, \`paper/source\` and \`paper/research-question\` read this file, so nothing \`${dirName}\` declares about its stages, sources or research question is checked`;
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
- ...["install-tl", "stub-pdflatex", "stub-kpsewhich", "stub-tlmgr"].map((f) =>
172
- join(staging, f),
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((c) => c.cmd === "curl" || c.cmd.endsWith("tlmgr")),
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((c) => c.cmd === "curl" || c.cmd.endsWith("tlmgr")),
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
- /** What a tree lacks: packages by `kpsewhich`, tools by executable. */
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 && g.tools.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 count = gaps.packages.length + gaps.tools.length;
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 ${count} of ${n} declared packages: ${describeGaps(gaps, o.tex)}`,
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. */
@@ -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", () => {
@@ -430,7 +430,7 @@ const META: Readonly<Record<VenueRuleName, Meta>> = {
430
430
  },
431
431
  messages: {
432
432
  unbuilt:
433
- "this paper names the venue `{{venue}}`, but {{file}} does not exist, so its page limit, fonts and format were NOT checked — run `paperlint build` before `paperlint lint`",
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\" (shipped: acm-sigconf, agenticdev, aisec, realm — see paperlint's docs/rules.md) or \"./venues/my-venue.jsonc\"; add \"kind\" for its page limit. Until then the page limit, fonts and format are not checked.",
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
  }