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