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.
Files changed (119) hide show
  1. package/CLAUDE.md +2 -2
  2. package/README.md +15 -4
  3. package/action.harness.mjs +54 -36
  4. package/action.yml +12 -27
  5. package/dist/adapters/latex/includes.d.ts +5 -1
  6. package/dist/adapters/latex/includes.d.ts.map +1 -1
  7. package/dist/adapters/latex/includes.js +8 -2
  8. package/dist/adapters/latex/includes.js.map +1 -1
  9. package/dist/build.d.ts.map +1 -1
  10. package/dist/build.js +7 -3
  11. package/dist/build.js.map +1 -1
  12. package/dist/cli.d.ts +2 -8
  13. package/dist/cli.d.ts.map +1 -1
  14. package/dist/cli.js +67 -37
  15. package/dist/cli.js.map +1 -1
  16. package/dist/domain/paper-source.js +2 -2
  17. package/dist/eslint-rules/latex-language.d.ts +5 -1
  18. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  19. package/dist/eslint-rules/latex-language.js +6 -3
  20. package/dist/eslint-rules/latex-language.js.map +1 -1
  21. package/dist/eslint-rules/paper-context.d.ts +20 -0
  22. package/dist/eslint-rules/paper-context.d.ts.map +1 -0
  23. package/dist/eslint-rules/paper-context.js +44 -0
  24. package/dist/eslint-rules/paper-context.js.map +1 -0
  25. package/dist/eslint-rules/paper-typography.d.ts +1 -1
  26. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  27. package/dist/eslint-rules/paper-typography.js +32 -18
  28. package/dist/eslint-rules/paper-typography.js.map +1 -1
  29. package/dist/eslint-rules/tex-build.d.ts.map +1 -1
  30. package/dist/eslint-rules/tex-build.js +7 -1
  31. package/dist/eslint-rules/tex-build.js.map +1 -1
  32. package/dist/init.d.ts +4 -19
  33. package/dist/init.d.ts.map +1 -1
  34. package/dist/init.js +24 -29
  35. package/dist/init.js.map +1 -1
  36. package/dist/package-dirs.d.ts +2 -0
  37. package/dist/package-dirs.d.ts.map +1 -1
  38. package/dist/package-dirs.js +8 -0
  39. package/dist/package-dirs.js.map +1 -1
  40. package/dist/paper-files.d.ts +2 -2
  41. package/dist/paper-includes.d.ts +17 -0
  42. package/dist/paper-includes.d.ts.map +1 -0
  43. package/dist/paper-includes.js +51 -0
  44. package/dist/paper-includes.js.map +1 -0
  45. package/dist/rules-config.d.ts +1 -0
  46. package/dist/rules-config.d.ts.map +1 -1
  47. package/dist/rules-config.js.map +1 -1
  48. package/dist/structure.js +1 -1
  49. package/dist/structure.js.map +1 -1
  50. package/dist/tex-paper.d.ts +17 -3
  51. package/dist/tex-paper.d.ts.map +1 -1
  52. package/dist/tex-paper.js +36 -38
  53. package/dist/tex-paper.js.map +1 -1
  54. package/docs/configuration.md +5 -4
  55. package/docs/install.md +8 -8
  56. package/docs/prior-art/paper-folder-scaffolding.md +4 -7
  57. package/docs/rules.md +15 -11
  58. package/eslint-rules/latex-language.ts +10 -4
  59. package/eslint-rules/paper-context.test.ts +47 -0
  60. package/eslint-rules/paper-context.ts +57 -0
  61. package/eslint-rules/paper-typography.ts +67 -23
  62. package/eslint-rules/tex-build.ts +8 -1
  63. package/fixtures/real-markdown-paper/baseline.json +1 -2
  64. package/package.json +1 -1
  65. package/scripts/check.ts +2 -2
  66. package/scripts/eslint-report-guard.mjs +5 -2
  67. package/scripts/harness-api.frozen.json +0 -1
  68. package/skills/paper-pipeline/references/pipeline-status-template.md +2 -16
  69. package/src/adapters/latex/includes.ts +10 -2
  70. package/src/adapters/latex/index.test.ts +8 -0
  71. package/src/build.harness.mjs +4 -2
  72. package/src/build.test.ts +18 -1
  73. package/src/build.ts +7 -3
  74. package/src/cli.harness.mjs +22 -54
  75. package/src/cli.test.ts +2 -18
  76. package/src/cli.ts +78 -42
  77. package/src/domain/paper-source.ts +2 -2
  78. package/src/init.test.ts +33 -13
  79. package/src/init.ts +25 -48
  80. package/src/linted-set.test.ts +11 -1
  81. package/src/new-paper.harness.mjs +1 -5
  82. package/src/package-dirs.ts +9 -0
  83. package/src/paper-files.ts +2 -2
  84. package/src/paper-includes.test.ts +161 -0
  85. package/src/paper-includes.ts +77 -0
  86. package/src/rule-cases.test.ts +0 -30
  87. package/src/rule-docs.test.ts +0 -1
  88. package/src/rules-config.ts +1 -0
  89. package/src/structure.harness.ts +1 -3
  90. package/src/structure.ts +1 -1
  91. package/src/tex-paper.test.ts +78 -61
  92. package/src/tex-paper.ts +57 -48
  93. package/src/tex-venue-rules.test.ts +1 -1
  94. package/templates/paper/PIPELINE-STATUS.md +0 -4
  95. package/dist/action-ref.d.ts +0 -12
  96. package/dist/action-ref.d.ts.map +0 -1
  97. package/dist/action-ref.js +0 -16
  98. package/dist/action-ref.js.map +0 -1
  99. package/dist/eslint-rules/paper-research-question.d.ts +0 -31
  100. package/dist/eslint-rules/paper-research-question.d.ts.map +0 -1
  101. package/dist/eslint-rules/paper-research-question.js +0 -188
  102. package/dist/eslint-rules/paper-research-question.js.map +0 -1
  103. package/docs/rules/tex/missing-input.md +0 -58
  104. package/eslint-rules/paper-research-question.harness.mjs +0 -212
  105. package/eslint-rules/paper-research-question.ts +0 -199
  106. package/fixtures/paper-research-question/comment-only/PIPELINE-STATUS.md +0 -9
  107. package/fixtures/paper-research-question/comment-only/paper.tex +0 -7
  108. package/fixtures/paper-research-question/declared-not-in-paper/PIPELINE-STATUS.md +0 -10
  109. package/fixtures/paper-research-question/declared-not-in-paper/paper.tex +0 -6
  110. package/fixtures/paper-research-question/draft/PIPELINE-STATUS.md +0 -6
  111. package/fixtures/paper-research-question/draft/paper.tex +0 -2
  112. package/fixtures/paper-research-question/markdown-no-rq/PIPELINE-STATUS.md +0 -9
  113. package/fixtures/paper-research-question/markdown-no-rq/paper.md +0 -4
  114. package/fixtures/paper-research-question/shipped-no-rq/PIPELINE-STATUS.md +0 -12
  115. package/fixtures/paper-research-question/shipped-no-rq/paper.tex +0 -3
  116. package/fixtures/paper-research-question/shipped-with-rq/PIPELINE-STATUS.md +0 -10
  117. package/fixtures/paper-research-question/shipped-with-rq/paper.tex +0 -2
  118. package/src/action-ref.test.ts +0 -26
  119. package/src/action-ref.ts +0 -15
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `paperlint lint` over a paper whose body is in other files: the rules over ESLint's LaTeX text read
3
+ * every file `paper.tex` includes from its document body, report at that file's own path and line,
4
+ * and fix that file. What a rule decides for the whole paper — the majority reference form and
5
+ * whether the build is in review mode — it decides over all of the paper's files, not over the one
6
+ * it is reporting in.
7
+ */
8
+ import {
9
+ mkdirSync,
10
+ mkdtempSync,
11
+ readFileSync,
12
+ realpathSync,
13
+ rmSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join, relative } from "node:path";
18
+ import { afterEach, describe, expect, it } from "vitest";
19
+ import { run } from "./cli.ts";
20
+ import { lintReport } from "../test/lint-report.ts";
21
+
22
+ const dirs: string[] = [];
23
+ afterEach(() => {
24
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
25
+ });
26
+
27
+ const doc = (cls: string, preamble: string, body: string): string =>
28
+ `\\documentclass${cls}\n${preamble}\n\\begin{document}\n${body}\n\\end{document}\n`;
29
+
30
+ const INTRO = "See §3 and p < .05.\nAs Fig.~\\ref{f3} shows.\n";
31
+
32
+ function project(files: Record<string, string>): string {
33
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-inc-")));
34
+ dirs.push(root);
35
+ const all: Record<string, string> = {
36
+ "package.json": JSON.stringify({ name: "c", private: true }),
37
+ "papers/a/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
38
+ "papers/a/paper.tex": doc(
39
+ "{article}",
40
+ "\\input{macros}",
41
+ "Figure~\\ref{f1} and Figure~\\ref{f2}.\n\\input{sections/intro}",
42
+ ),
43
+ // Prose in a preamble include is not the body: were it linted, `§2` would be reported.
44
+ "papers/a/macros.tex": "% See §2.\n\\newcommand{\\half}{0.5}\nSee §2.\n",
45
+ "papers/a/sections/intro.tex": INTRO,
46
+ // A frozen version beside the paper is not the paper.
47
+ "papers/a/versions/2026-01-01-submitted.tex": doc(
48
+ "{article}",
49
+ "",
50
+ "See §9.",
51
+ ),
52
+ ...files,
53
+ };
54
+ for (const [p, text] of Object.entries(all)) {
55
+ mkdirSync(join(root, p, ".."), { recursive: true });
56
+ writeFileSync(join(root, p), text);
57
+ }
58
+ return root;
59
+ }
60
+
61
+ /** `paperlint lint --json` → rule ids per linted file (relative to the project), and stderr. */
62
+ async function lint(
63
+ root: string,
64
+ args: string[] = [],
65
+ ): Promise<{ byFile: Record<string, string[]>; err: string }> {
66
+ const out: string[] = [];
67
+ const err: string[] = [];
68
+ await run(["lint", "--json", ...args], {
69
+ cwd: root,
70
+ log: (s: string) => out.push(s),
71
+ err: (s: string) => err.push(s),
72
+ });
73
+ const byFile = Object.fromEntries(
74
+ lintReport(out.join("\n")).map((r) => [
75
+ relative(root, r.filePath),
76
+ r.messages.map((m) => m.ruleId ?? "(fatal)").sort(),
77
+ ]),
78
+ );
79
+ return { byFile, err: err.join("\n") };
80
+ }
81
+
82
+ describe("lint reads the files paper.tex includes from its body", () => {
83
+ it("🔴 findings in an included file land at that file; the majority form is the paper's", async () => {
84
+ const { byFile } = await lint(project({}));
85
+ expect(byFile["papers/a/sections/intro.tex"]).toEqual([
86
+ "paper/figure-ref-style",
87
+ "paper/leading-zero",
88
+ "paper/section-word",
89
+ ]);
90
+ // Two `Figure` in paper.tex against one `Fig.` in the section: the section holds the minority.
91
+ expect(byFile["papers/a/paper.tex"]).not.toContain(
92
+ "paper/figure-ref-style",
93
+ );
94
+ });
95
+
96
+ it("a preamble include and a frozen version beside the paper are not linted", async () => {
97
+ const { byFile } = await lint(project({}));
98
+ expect(Object.keys(byFile).sort()).toEqual([
99
+ "papers/a/PIPELINE-STATUS.md",
100
+ "papers/a/paper.tex",
101
+ "papers/a/sections/intro.tex",
102
+ ]);
103
+ });
104
+
105
+ it("--fix rewrites the included file, to the paper's majority form", async () => {
106
+ const root = project({});
107
+ const before = readFileSync(join(root, "papers/a/paper.tex"), "utf8");
108
+ const { byFile } = await lint(root, ["--fix"]);
109
+ expect(byFile["papers/a/sections/intro.tex"]).toEqual([]);
110
+ expect(
111
+ readFileSync(join(root, "papers/a/sections/intro.tex"), "utf8"),
112
+ ).toBe("See Section 3 and p < 0.05.\nAs Figure~\\ref{f3} shows.\n");
113
+ expect(readFileSync(join(root, "papers/a/paper.tex"), "utf8")).toBe(before);
114
+ });
115
+ });
116
+
117
+ describe("what lint decides for the whole paper, it decides over every file of it", () => {
118
+ it("tex/future-promise in an included file follows the build mode paper.tex declares", async () => {
119
+ const promise = {
120
+ "papers/a/sections/intro.tex":
121
+ "The harness will be released at camera-ready.\n",
122
+ };
123
+ const final = await lint(project(promise));
124
+ expect(final.byFile["papers/a/sections/intro.tex"]).toEqual([
125
+ "tex/future-promise",
126
+ ]);
127
+ const review = await lint(
128
+ project({
129
+ ...promise,
130
+ "papers/a/paper.tex": doc(
131
+ "[review]{article}",
132
+ "",
133
+ "Text.\n\\input{sections/intro}",
134
+ ),
135
+ }),
136
+ );
137
+ expect(review.byFile["papers/a/sections/intro.tex"]).toEqual([]);
138
+ });
139
+
140
+ it("an include that resolves nowhere is named as a file lint did not read", async () => {
141
+ const { err } = await lint(
142
+ project({
143
+ "papers/a/paper.tex": doc("{article}", "", "\\input{sections/gone}"),
144
+ }),
145
+ );
146
+ expect(err).toMatch(
147
+ /papers\/a\/paper\.tex: lint did not read `sections\/gone`/,
148
+ );
149
+ });
150
+
151
+ it("the note is about the papers this run lints, not another paper of the project", async () => {
152
+ const root = project({
153
+ "papers/b/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
154
+ "papers/b/paper.tex": doc("{article}", "", "\\input{gone}"),
155
+ });
156
+ expect((await lint(root, ["papers/a"])).err).not.toMatch(/did not read/);
157
+ expect((await lint(root)).err).toMatch(
158
+ /papers\/b\/paper\.tex: lint did not read `gone`/,
159
+ );
160
+ });
161
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * THE FILES OF A PAPER'S BODY, handed to ESLint — so the rules over ESLint's LaTeX text read a paper
3
+ * whose body is in `sections/*.tex`, and report each finding at its own file and line (#144).
4
+ *
5
+ * The set is what TeX reads, resolved by the reader the parse-tree rules use (`bodyFiles`,
6
+ * src/tex-paper.ts): `paper.tex` and every file an include in its document body brings in, found in
7
+ * the paper's directory. Not a glob over every `.tex`: a frozen version under `versions/`, a macro
8
+ * file the preamble includes and a scratch file nothing includes are not the paper.
9
+ *
10
+ * Two blocks per paper, both relative to the paper's directory (`basePath`):
11
+ *
12
+ * 1. a global un-ignore of each body file by name. paperlint's scope ignores every file it does not
13
+ * claim (src/paper-files.ts); the fragment block (`buildConfig`) claims every `.tex` but
14
+ * `paper.tex`, so only the files named here are ever enumerated;
15
+ * 2. `settings.paperlint.paper` for `paper.tex` and its body files: the main file and the whole set,
16
+ * for the rules whose verdict is the paper's (eslint-rules/paper-context.ts).
17
+ */
18
+ import { join, relative } from "node:path";
19
+ import { PAPERLINT_SETTINGS } from "#eslint-rules/paper-context";
20
+ import { callerPath } from "./caller-path.ts";
21
+ import type { AbsolutePath } from "./domain/paths.ts";
22
+ import type { ConfigBlock } from "./rules-config.ts";
23
+ import { bodyFiles, type PaperDeps, type Unread } from "./tex-paper.ts";
24
+
25
+ /** One paper's main file and body files, and the includes that resolved nowhere. */
26
+ export interface PaperBody {
27
+ readonly dir: string;
28
+ readonly main: AbsolutePath;
29
+ readonly files: readonly AbsolutePath[];
30
+ readonly missing: readonly Unread[];
31
+ }
32
+
33
+ /** The body of each paper directory in `dirs` that has a `paper.tex`. */
34
+ export function paperBodies(
35
+ dirs: readonly string[],
36
+ deps: PaperDeps,
37
+ ): readonly PaperBody[] {
38
+ return dirs.flatMap((dir) => {
39
+ const main = callerPath(join(dir, "paper.tex"));
40
+ const bytes = deps.files.readBytes(main);
41
+ if (bytes === null) return [];
42
+ const body = bodyFiles(main, new TextDecoder().decode(bytes), deps);
43
+ return [{ dir, main, files: body.files, missing: body.missing }];
44
+ });
45
+ }
46
+
47
+ /** The config blocks that hand ESLint each paper's body files, and the paper to every one of them. */
48
+ export const includeBlocks = (
49
+ bodies: readonly PaperBody[],
50
+ ): readonly ConfigBlock[] =>
51
+ bodies.flatMap((b) => {
52
+ const rel = b.files.map((f) => relative(b.dir, f));
53
+ const paper = { main: b.main, files: [b.main, ...b.files] };
54
+ return [
55
+ ...(rel.length > 0
56
+ ? [{ basePath: b.dir, ignores: rel.map((r) => `!${r}`) }]
57
+ : []),
58
+ {
59
+ basePath: b.dir,
60
+ files: ["paper.tex", ...rel],
61
+ settings: { [PAPERLINT_SETTINGS]: { paper } },
62
+ },
63
+ ];
64
+ });
65
+
66
+ /** One line per include that resolved nowhere: which text lint did not read, and why. */
67
+ export const unreadLines = (
68
+ bodies: readonly PaperBody[],
69
+ shown: (path: string) => string,
70
+ ): readonly string[] =>
71
+ bodies.flatMap((b) =>
72
+ b.missing.map(
73
+ (m) =>
74
+ `${shown(b.main)}: lint did not read \`${m.target}\` (included by ${m.file}): ` +
75
+ "no such file in the paper's directory or paperlint's inputs",
76
+ ),
77
+ );
@@ -186,25 +186,6 @@ const CASES: Readonly<Record<string, RuleCases>> = {
186
186
  },
187
187
  silent: paper(tex("x")),
188
188
  },
189
- "paper/research-question": {
190
- reports: {
191
- tree: {
192
- [STATUS_FILE]: fixture(
193
- "paper-research-question/shipped-no-rq/PIPELINE-STATUS.md",
194
- ),
195
- [TEX_FILE]: fixture("paper-research-question/shipped-no-rq/paper.tex"),
196
- },
197
- file: TEX_FILE,
198
- severity: 1,
199
- line: 1,
200
- },
201
- silent: {
202
- [STATUS_FILE]: fixture(
203
- "paper-research-question/shipped-with-rq/PIPELINE-STATUS.md",
204
- ),
205
- [TEX_FILE]: fixture("paper-research-question/shipped-with-rq/paper.tex"),
206
- },
207
- },
208
189
  "paper/section-word": {
209
190
  reports: {
210
191
  tree: paper(tex("As in §2.")),
@@ -365,17 +346,6 @@ const CASES: Readonly<Record<string, RuleCases>> = {
365
346
  },
366
347
  silent: paper(tex(FORMAL)),
367
348
  },
368
- "tex/missing-input": {
369
- reports: {
370
- tree: paper(tex("Text.\n\\input{sections/gone}")),
371
- file: TEX_FILE,
372
- severity: 1,
373
- line: 4,
374
- },
375
- silent: paper(tex("Text.\n\\input{sections/here}"), {
376
- [`${P}/sections/here.tex`]: "More text.\n",
377
- }),
378
- },
379
349
  "bib/reachable-entry": {
380
350
  reports: {
381
351
  tree: paper(inlineBib("@book{a,\n title = {A},\n year = {2026}\n}")),
@@ -26,7 +26,6 @@ const AWAITING_PAGE: ReadonlySet<string> = new Set([
26
26
  "paper/leading-zero",
27
27
  "paper/refs-checked",
28
28
  "paper/refs-fresh",
29
- "paper/research-question",
30
29
  "paper/section-word",
31
30
  "paper/source",
32
31
  "paper/stages",
@@ -64,6 +64,7 @@ export interface ConfigBlock {
64
64
  readonly languageOptions?: Readonly<Record<string, unknown>>;
65
65
  readonly linterOptions?: Readonly<Record<string, unknown>>;
66
66
  readonly rules?: Readonly<Record<string, unknown>>;
67
+ readonly settings?: Readonly<Record<string, unknown>>;
67
68
  }
68
69
 
69
70
  export type Parsed<T> =
@@ -79,9 +79,7 @@ try {
79
79
  check(
80
80
  "and the message names the CONSEQUENCE, not a restatement of the condition",
81
81
  /are skipped/.test(at("no-scorecard")[0]?.message ?? "") &&
82
- /stage, source and research-question checks/.test(
83
- at("no-scorecard")[0]?.message ?? "",
84
- ),
82
+ /stage and source checks/.test(at("no-scorecard")[0]?.message ?? ""),
85
83
  );
86
84
  check(
87
85
  "and the consequence names THIS directory by name",
package/src/structure.ts CHANGED
@@ -122,7 +122,7 @@ export function checkStructure(
122
122
  */
123
123
  function whyMissingMatters(file: string, dirName: string): string {
124
124
  if (file === "PIPELINE-STATUS.md")
125
- return `the stage, source and research-question checks are skipped for \`${dirName}\``;
125
+ return `the stage and source checks are skipped for \`${dirName}\``;
126
126
  return `declared as required by your \`structure\` configuration`;
127
127
  }
128
128
 
@@ -1,81 +1,98 @@
1
1
  /**
2
- * `tex/missing-input` and the reading every parse-tree rule shares (`readPaper`): an include that
3
- * names no file is reported where it stands in `paper.tex`, once, whether `paper.tex` wrote it or a
4
- * file it includes did; an include that resolves says nothing.
2
+ * The reading every parse-tree rule shares (`readPaper`), the files of the paper's body that lint
3
+ * reads on their own (`bodyFiles`), and where a finding of the assembled text is reported
4
+ * (`reportInPaper`).
5
5
  */
6
6
  import { describe, expect, it } from "vitest";
7
7
  import { latexReader } from "./adapters/latex/index.ts";
8
8
  import { memoryFiles } from "./adapters/memory/index.ts";
9
- import { missingInputRule, readPaper, reportInPaper } from "./tex-paper.ts";
9
+ import { join } from "node:path";
10
+ import { texInputsDir } from "./package-dirs.ts";
11
+ import { bodyFiles, readPaper, reportInPaper } from "./tex-paper.ts";
10
12
 
11
- const doc = (body: string): string =>
12
- `\\documentclass{article}\n\\begin{document}\n${body}\n\\end{document}\n`;
13
+ describe("readPaper — the paper as the rules read it", () => {
14
+ it("reads includes from the paper's own directory", () => {
15
+ const p = readPaper("/p/paper.tex", "x\\input{s/a}y", {
16
+ files: memoryFiles({ "/p/s/a.tex": "AAA" }),
17
+ latex: latexReader,
18
+ });
19
+ expect(p.text).toBe("xAAAy");
20
+ expect(p.main).toBe("paper.tex");
21
+ });
13
22
 
14
- /** What the rule reports over `/p/paper.tex` holding `src`, with `files` on disk. */
15
- function run(src: string, files: Record<string, string> = {}) {
16
- const out: { line: number; messageId: string; data: unknown }[] = [];
17
- const rule = missingInputRule({
18
- files: memoryFiles(files),
19
- latex: latexReader,
23
+ it("an include the paper's directory lacks is found in paperlint's own inputs, as the build finds it", () => {
24
+ const p = readPaper("/p/paper.tex", "x\\input{guards}y", {
25
+ files: memoryFiles({ [join(texInputsDir(), "guards.tex")]: "G" }),
26
+ latex: latexReader,
27
+ });
28
+ expect({ text: p.text, missing: p.missing }).toEqual({
29
+ text: "xGy",
30
+ missing: [],
31
+ });
20
32
  });
21
- rule
22
- .create({
23
- filename: "/p/paper.tex",
24
- sourceCode: {
25
- text: src,
26
- getLocFromIndex: (i) => ({
27
- line: src.slice(0, i).split("\n").length,
28
- column: 0,
29
- }),
30
- },
31
- report: (d) => {
32
- if ("messageId" in d)
33
- out.push({
34
- line: d.loc.start.line,
35
- messageId: d.messageId,
36
- data: d.data,
37
- });
38
- },
39
- })
40
- .root?.();
41
- return out;
42
- }
43
33
 
44
- describe("tex/missing-input", () => {
45
- it("an \\input naming no file is reported at its line, naming the target", () => {
46
- expect(run(doc("Text.\n\\input{sections/gone}"))).toEqual([
47
- {
48
- line: 4,
49
- messageId: "missing",
50
- data: { target: "sections/gone", file: "paper.tex" },
51
- },
52
- ]);
34
+ it("the paper's directory comes first: its file wins over paperlint's of the same name", () => {
35
+ const p = readPaper("/p/paper.tex", "\\input{guards}", {
36
+ files: memoryFiles({
37
+ "/p/guards.tex": "mine",
38
+ [join(texInputsDir(), "guards.tex")]: "package",
39
+ }),
40
+ latex: latexReader,
41
+ });
42
+ expect(p.text).toBe("mine");
53
43
  });
44
+ });
54
45
 
55
- it("one missing in an included file is reported at the include that brought that file in", () => {
56
- expect(run(doc("\\input{a}"), { "/p/a.tex": "A\n\\input{b}\n" })).toEqual([
57
- { line: 3, messageId: "missingIn", data: { target: "b", file: "a.tex" } },
58
- ]);
46
+ describe("bodyFiles — the files of the body lint reads on their own", () => {
47
+ const doc = (preamble: string, body: string): string =>
48
+ `\\documentclass{article}\n${preamble}\n\\begin{document}\n${body}\n\\end{document}\n`;
49
+ const files = (extra: Record<string, string> = {}) =>
50
+ memoryFiles({
51
+ "/p/macros.tex": "\\newcommand{\\x}{y}",
52
+ "/p/sections/a.tex": "A\n\\input{sections/b}\n",
53
+ "/p/sections/b.tex": "B",
54
+ [join(texInputsDir(), "guards.tex")]: "G",
55
+ ...extra,
56
+ });
57
+
58
+ it("every file an include in the document body brings in, nested ones too, by absolute path", () => {
59
+ expect(
60
+ bodyFiles("/p/paper.tex", doc("", "\\input{sections/a}"), {
61
+ files: files(),
62
+ latex: latexReader,
63
+ }),
64
+ ).toEqual({
65
+ files: ["/p/sections/a.tex", "/p/sections/b.tex"],
66
+ missing: [],
67
+ });
59
68
  });
60
69
 
61
- it("silent when every include resolves, with `.tex` added or not", () => {
70
+ it("not a preamble include (macros), and not a file found only in paperlint's inputs", () => {
62
71
  expect(
63
- run(doc("\\input{a}\\include{b.tex}"), {
64
- "/p/a.tex": "A",
65
- "/p/b.tex": "B",
72
+ bodyFiles(
73
+ "/p/paper.tex",
74
+ doc("\\input{macros}", "\\input{guards}\nText."),
75
+ { files: files(), latex: latexReader },
76
+ ),
77
+ ).toEqual({ files: [], missing: [] });
78
+ });
79
+
80
+ it("an include that resolves nowhere is named, with the file that wrote it", () => {
81
+ expect(
82
+ bodyFiles("/p/paper.tex", doc("", "\\input{gone}"), {
83
+ files: files(),
84
+ latex: latexReader,
66
85
  }),
67
- ).toEqual([]);
86
+ ).toEqual({ files: [], missing: [{ file: "paper.tex", target: "gone" }] });
68
87
  });
69
- });
70
88
 
71
- describe("readPaper — the paper as the rules read it", () => {
72
- it("reads includes from the paper's own directory", () => {
73
- const p = readPaper("/p/paper.tex", "x\\input{s/a}y", {
74
- files: memoryFiles({ "/p/s/a.tex": "AAA" }),
75
- latex: latexReader,
76
- });
77
- expect(p.text).toBe("xAAAy");
78
- expect(p.main).toBe("paper.tex");
89
+ it("a main file with no document environment: every include is the body", () => {
90
+ expect(
91
+ bodyFiles("/p/paper.tex", "\\input{sections/b}", {
92
+ files: files(),
93
+ latex: latexReader,
94
+ }),
95
+ ).toEqual({ files: ["/p/sections/b.tex"], missing: [] });
79
96
  });
80
97
  });
81
98
 
package/src/tex-paper.ts CHANGED
@@ -7,11 +7,9 @@
7
7
  * file at the include in `paper.tex` that brought it in — the one place ESLint can point to in this
8
8
  * file — with the included file, line and column at the front of the message.
9
9
  *
10
- * tex/missing-input warn an `\input`, `\include` or `\subfile` names a file that is not there
11
- *
12
- * A missing file is a finding, not a silent skip: the rules would otherwise report a clean body for
13
- * text they never read, and TeX stops on it too. Warn, not error: a file a build step writes (a
14
- * table of generated numbers) is legitimately absent before that step has run.
10
+ * An include is looked for where the build tells TeX to look (`texSearchPath`): the paper's own
11
+ * directory, then paperlint's inputs. `bodyFiles` names the files of the paper's BODY the author
12
+ * wrote — what the rules over ESLint's own LaTeX text read one by one, each at its own path.
15
13
  */
16
14
  import { basename, dirname, join } from "node:path";
17
15
  import {
@@ -23,11 +21,9 @@ import type { Span } from "./domain/tex-document.ts";
23
21
  import type { LatexReader } from "./ports/latex.ts";
24
22
  import type { Files } from "./ports/files.ts";
25
23
  import { callerPath } from "./caller-path.ts";
26
- import type {
27
- Located,
28
- TexRuleContext,
29
- TexRuleModule,
30
- } from "./tex-venue-rules.ts";
24
+ import type { AbsolutePath } from "./domain/paths.ts";
25
+ import { texSearchPath } from "./package-dirs.ts";
26
+ import type { Located, TexRuleContext } from "./tex-venue-rules.ts";
31
27
 
32
28
  /** What reading a paper needs: the disk, and the LaTeX reader. */
33
29
  export interface PaperDeps {
@@ -46,12 +42,62 @@ export function readPaper(
46
42
  includes: deps.latex.includes,
47
43
  documentBody: deps.latex.documentBody,
48
44
  read: (rel) => {
49
- const b = deps.files.readBytes(callerPath(join(dir, rel)));
45
+ const at = located(dir, rel, deps.files);
46
+ const b = at === null ? null : deps.files.readBytes(at);
50
47
  return b === null ? null : new TextDecoder().decode(b);
51
48
  },
52
49
  });
53
50
  }
54
51
 
52
+ /** `rel` in the first directory of the paper's TeX search path that holds it, or null. */
53
+ const located = (dir: string, rel: string, files: Files): AbsolutePath | null =>
54
+ texSearchPath(dir)
55
+ .map((d) => callerPath(join(d, rel)))
56
+ .find((p) => files.isFile(p)) ?? null;
57
+
58
+ /** An include that resolved nowhere: the file that wrote it, and the path as written. */
59
+ export interface Unread {
60
+ readonly file: string;
61
+ readonly target: string;
62
+ }
63
+
64
+ /**
65
+ * The files of the body of the paper whose main file is `filename`: every file an include inside its
66
+ * `document` environment brings in, nested ones too, found in the paper's own directory. Not a
67
+ * preamble include (macros are not the body), and not a file found only in paperlint's inputs (not
68
+ * the author's text). `missing` is every include, anywhere, that resolved nowhere.
69
+ */
70
+ export function bodyFiles(
71
+ filename: string,
72
+ src: string,
73
+ deps: PaperDeps,
74
+ ): {
75
+ readonly files: readonly AbsolutePath[];
76
+ readonly missing: readonly Unread[];
77
+ } {
78
+ const dir = dirname(filename);
79
+ const paper = readPaper(filename, src, deps);
80
+ const body = deps.latex.documentBody(src);
81
+ const inBody = (via: Span | null): boolean =>
82
+ via !== null &&
83
+ (body === null || (via.start >= body.start && via.end <= body.end));
84
+ const own = (rel: string): AbsolutePath | null => {
85
+ const at = located(dir, rel, deps.files);
86
+ return at === callerPath(join(dir, rel)) ? at : null;
87
+ };
88
+ return {
89
+ files: [
90
+ ...new Set(
91
+ paper.segments
92
+ .filter((s) => s.file !== paper.main && inBody(s.via))
93
+ .map((s) => own(s.file))
94
+ .filter((f): f is AbsolutePath => f !== null),
95
+ ),
96
+ ],
97
+ missing: paper.missing.map(({ file, target }) => ({ file, target })),
98
+ };
99
+ }
100
+
55
101
  /** Line and column (1-based) of an offset in a text. */
56
102
  function lineColumn(text: string, at: number): string {
57
103
  const line = text.slice(0, at).split("\n").length;
@@ -113,40 +159,3 @@ export function reportInPaper(
113
159
  });
114
160
  });
115
161
  }
116
-
117
- const MISSING_META: TexRuleModule["meta"] = {
118
- type: "problem",
119
- docs: {
120
- description:
121
- "an \\input, \\include or \\subfile names a file that is not there, so no rule read its text",
122
- url: "https://github.com/zernie/paperlint/blob/main/docs/rules/tex/missing-input.md",
123
- },
124
- schema: [],
125
- messages: {
126
- missing:
127
- "`{{target}}` is not there (tried `{{target}}.tex` and `{{target}}`, from the paper's directory): TeX stops on it, and no rule read what it should hold",
128
- missingIn:
129
- "`{{target}}`, which {{file}} includes, is not there (tried `{{target}}.tex` and `{{target}}`, from the paper's directory): TeX stops on it, and no rule read what it should hold",
130
- },
131
- };
132
-
133
- /** The includes that name no file, each reported where it stands in the main file. */
134
- export const missingInputRule = (deps: PaperDeps): TexRuleModule => ({
135
- meta: MISSING_META,
136
- create(context) {
137
- return {
138
- root() {
139
- const sc = context.sourceCode;
140
- const paper = readPaper(context.filename, sc.raw ?? sc.text, deps);
141
- const loc = (i: number) => sc.getLocFromIndex(i);
142
- paper.missing.forEach((m) => {
143
- context.report({
144
- loc: { start: loc(m.via.start), end: loc(m.via.end) },
145
- messageId: m.file === paper.main ? "missing" : "missingIn",
146
- data: { target: m.target, file: m.file },
147
- });
148
- });
149
- },
150
- };
151
- },
152
- });
@@ -199,7 +199,7 @@ describe("the venue rules read the whole paper: the files it \\inputs, spliced w
199
199
  "\\section*{LLM Usage Statement}\nNone.\n",
200
200
  };
201
201
  expect(lint(tex, AIDC, { extra })).toEqual([]);
202
- // Without the file, the section is missing — and tex/missing-input (not a venue rule) says why.
202
+ // Without the file, the section is missing.
203
203
  expect(ids(lint(tex, AIDC))).toEqual(["tex/required-section:missing"]);
204
204
  });
205
205
 
@@ -1,10 +1,6 @@
1
1
  ---
2
2
  # Created by `paperlint new` — from <papers>/.template/ when the project keeps one, else from the package.
3
3
  #
4
- # researchQuestion — the paper's question, one sentence, written the way it appears in the paper.
5
- # `paper/research-question` reads it once the paper has shipped a stage. Empty until you have it.
6
- researchQuestion: ""
7
- #
8
4
  # There is no `stages` field yet, on purpose: a new paper has shipped nothing and owes nothing.
9
5
  # When it reaches a stage, add the record — its shape is in docs/rules.md of the package.
10
6
  ---
@@ -1,12 +0,0 @@
1
- /**
2
- * The ref `paperlint init` pins the GitHub Action to, decided from the version of the package that is
3
- * running. semantic-release tags every release `v<version>` on the commit it publishes, so the
4
- * action at that tag is the same code as the installed package.
5
- *
6
- * Only a plain `X.Y.Z` has such a tag. A git checkout or `npm link` carries the unreleased
7
- * placeholder `0.0.0-semantically-released`, a version that could not be read is `undefined`, and
8
- * anything else is not known to be tagged: those return `null` ("no pin known"), and the caller
9
- * keeps the placeholder. A wrong tag written confidently is worse than an obvious placeholder.
10
- */
11
- export declare function actionRef(version: string | undefined): string | null;
12
- //# sourceMappingURL=action-ref.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"action-ref.d.ts","sourceRoot":"","sources":["../src/action-ref.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAIpE"}