paperlint 4.1.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 (202) hide show
  1. package/CLAUDE.md +26 -2
  2. package/CONTRIBUTING.md +7 -0
  3. package/README.md +15 -4
  4. package/action.harness.mjs +54 -36
  5. package/action.yml +12 -27
  6. package/dist/adapters/latex/document-class.d.ts +6 -5
  7. package/dist/adapters/latex/document-class.d.ts.map +1 -1
  8. package/dist/adapters/latex/document-class.js +18 -10
  9. package/dist/adapters/latex/document-class.js.map +1 -1
  10. package/dist/adapters/latex/includes.d.ts +16 -0
  11. package/dist/adapters/latex/includes.d.ts.map +1 -0
  12. package/dist/adapters/latex/includes.js +48 -0
  13. package/dist/adapters/latex/includes.js.map +1 -0
  14. package/dist/adapters/latex/index.d.ts +9 -5
  15. package/dist/adapters/latex/index.d.ts.map +1 -1
  16. package/dist/adapters/latex/index.js +8 -1
  17. package/dist/adapters/latex/index.js.map +1 -1
  18. package/dist/adapters/latex/nodes.d.ts +5 -1
  19. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  20. package/dist/adapters/latex/nodes.js +6 -2
  21. package/dist/adapters/latex/nodes.js.map +1 -1
  22. package/dist/adapters/latex/parse.d.ts.map +1 -1
  23. package/dist/adapters/latex/parse.js +1 -0
  24. package/dist/adapters/latex/parse.js.map +1 -1
  25. package/dist/adapters/latex/prose.d.ts +8 -0
  26. package/dist/adapters/latex/prose.d.ts.map +1 -0
  27. package/dist/adapters/latex/prose.js +353 -0
  28. package/dist/adapters/latex/prose.js.map +1 -0
  29. package/dist/build.d.ts.map +1 -1
  30. package/dist/build.js +7 -3
  31. package/dist/build.js.map +1 -1
  32. package/dist/claim-provenance.d.ts +34 -0
  33. package/dist/claim-provenance.d.ts.map +1 -0
  34. package/dist/claim-provenance.js +146 -0
  35. package/dist/claim-provenance.js.map +1 -0
  36. package/dist/cli.d.ts +2 -8
  37. package/dist/cli.d.ts.map +1 -1
  38. package/dist/cli.js +81 -31
  39. package/dist/cli.js.map +1 -1
  40. package/dist/domain/paper-source.d.ts +58 -0
  41. package/dist/domain/paper-source.d.ts.map +1 -0
  42. package/dist/domain/paper-source.js +109 -0
  43. package/dist/domain/paper-source.js.map +1 -0
  44. package/dist/domain/sentences.d.ts +26 -0
  45. package/dist/domain/sentences.d.ts.map +1 -0
  46. package/dist/domain/sentences.js +48 -0
  47. package/dist/domain/sentences.js.map +1 -0
  48. package/dist/domain/tex-document.d.ts +39 -4
  49. package/dist/domain/tex-document.d.ts.map +1 -1
  50. package/dist/domain/tex-document.js.map +1 -1
  51. package/dist/eslint-rules/latex-language.d.ts +5 -1
  52. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  53. package/dist/eslint-rules/latex-language.js +6 -3
  54. package/dist/eslint-rules/latex-language.js.map +1 -1
  55. package/dist/eslint-rules/paper-context.d.ts +20 -0
  56. package/dist/eslint-rules/paper-context.d.ts.map +1 -0
  57. package/dist/eslint-rules/paper-context.js +44 -0
  58. package/dist/eslint-rules/paper-context.js.map +1 -0
  59. package/dist/eslint-rules/paper-typography.d.ts +8 -1
  60. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  61. package/dist/eslint-rules/paper-typography.js +85 -19
  62. package/dist/eslint-rules/paper-typography.js.map +1 -1
  63. package/dist/eslint-rules/tex-build.d.ts.map +1 -1
  64. package/dist/eslint-rules/tex-build.js +7 -1
  65. package/dist/eslint-rules/tex-build.js.map +1 -1
  66. package/dist/init.d.ts +4 -19
  67. package/dist/init.d.ts.map +1 -1
  68. package/dist/init.js +24 -29
  69. package/dist/init.js.map +1 -1
  70. package/dist/package-dirs.d.ts +2 -0
  71. package/dist/package-dirs.d.ts.map +1 -1
  72. package/dist/package-dirs.js +8 -0
  73. package/dist/package-dirs.js.map +1 -1
  74. package/dist/paper-files.d.ts +2 -2
  75. package/dist/paper-includes.d.ts +17 -0
  76. package/dist/paper-includes.d.ts.map +1 -0
  77. package/dist/paper-includes.js +51 -0
  78. package/dist/paper-includes.js.map +1 -0
  79. package/dist/ports/latex.d.ts +12 -1
  80. package/dist/ports/latex.d.ts.map +1 -1
  81. package/dist/register.d.ts +40 -0
  82. package/dist/register.d.ts.map +1 -0
  83. package/dist/register.js +134 -0
  84. package/dist/register.js.map +1 -0
  85. package/dist/rules-config.d.ts +1 -0
  86. package/dist/rules-config.d.ts.map +1 -1
  87. package/dist/rules-config.js.map +1 -1
  88. package/dist/structure.js +1 -1
  89. package/dist/structure.js.map +1 -1
  90. package/dist/tex-paper.d.ts +33 -0
  91. package/dist/tex-paper.d.ts.map +1 -0
  92. package/dist/tex-paper.js +103 -0
  93. package/dist/tex-paper.js.map +1 -0
  94. package/dist/tex-venue-rules.d.ts +7 -2
  95. package/dist/tex-venue-rules.d.ts.map +1 -1
  96. package/dist/tex-venue-rules.js +31 -17
  97. package/dist/tex-venue-rules.js.map +1 -1
  98. package/docs/configuration.md +5 -4
  99. package/docs/install.md +8 -8
  100. package/docs/prior-art/paper-folder-scaffolding.md +4 -7
  101. package/docs/rules/tex/claim-provenance.md +83 -0
  102. package/docs/rules/tex/register.md +77 -0
  103. package/docs/rules/tex/required-section.md +3 -1
  104. package/docs/rules/tex/template.md +12 -0
  105. package/docs/rules/tex/venue-leftover.md +4 -2
  106. package/docs/rules.md +17 -10
  107. package/eslint-rules/latex-language.ts +10 -4
  108. package/eslint-rules/paper-context.test.ts +47 -0
  109. package/eslint-rules/paper-context.ts +57 -0
  110. package/eslint-rules/paper-typography.test.mjs +48 -1
  111. package/eslint-rules/paper-typography.ts +139 -24
  112. package/eslint-rules/tex-build.ts +8 -1
  113. package/eslint.config.mjs +1 -0
  114. package/fixtures/real-markdown-paper/baseline.json +1 -2
  115. package/fixtures/real-markdown-paper/baseline.ts +8 -7
  116. package/package.json +2 -1
  117. package/scripts/check.ts +2 -2
  118. package/scripts/eslint-report-guard.mjs +5 -2
  119. package/scripts/harness-api.frozen.json +0 -1
  120. package/skills/cold-read-diff/SKILL.md +31 -10
  121. package/skills/cold-read-diff/SKILL.md.spec.ts +30 -9
  122. package/skills/draft-paper/SKILL.md +2 -2
  123. package/skills/draft-paper/SKILL.md.spec.ts +1 -1
  124. package/skills/grade-paper-writing/SKILL.md +109 -44
  125. package/skills/grade-paper-writing/SKILL.md.spec.ts +108 -43
  126. package/skills/harden-paper/SKILL.md +35 -36
  127. package/skills/harden-paper/SKILL.md.spec.ts +34 -35
  128. package/skills/paper-adversarial-review/SKILL.md +2 -2
  129. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
  130. package/skills/paper-pipeline/SKILL.md +3 -3
  131. package/skills/paper-pipeline/SKILL.md.spec.ts +2 -2
  132. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +6 -6
  133. package/skills/paper-pipeline/references/pipeline-status-template.md +2 -16
  134. package/skills/paper-pipeline/references/writing-craft.md +53 -18
  135. package/skills/pc-panel-review/SKILL.md +36 -11
  136. package/skills/pc-panel-review/SKILL.md.spec.ts +35 -10
  137. package/src/adapters/latex/document-class.ts +20 -12
  138. package/src/adapters/latex/includes.ts +74 -0
  139. package/src/adapters/latex/index.test.ts +183 -1
  140. package/src/adapters/latex/index.ts +14 -4
  141. package/src/adapters/latex/nodes.ts +11 -2
  142. package/src/adapters/latex/parse.ts +1 -0
  143. package/src/adapters/latex/prose.ts +484 -0
  144. package/src/build.harness.mjs +4 -2
  145. package/src/build.test.ts +18 -1
  146. package/src/build.ts +7 -3
  147. package/src/claim-provenance.test.ts +352 -0
  148. package/src/claim-provenance.ts +217 -0
  149. package/src/cli.harness.mjs +22 -54
  150. package/src/cli.test.ts +2 -18
  151. package/src/cli.ts +92 -36
  152. package/src/domain/paper-source.test.ts +156 -0
  153. package/src/domain/paper-source.ts +233 -0
  154. package/src/domain/sentences.ts +67 -0
  155. package/src/domain/tex-document.ts +36 -3
  156. package/src/init.test.ts +33 -13
  157. package/src/init.ts +25 -48
  158. package/src/linted-set.test.ts +11 -1
  159. package/src/new-paper.harness.mjs +1 -5
  160. package/src/package-dirs.ts +9 -0
  161. package/src/paper-files.ts +2 -2
  162. package/src/paper-includes.test.ts +161 -0
  163. package/src/paper-includes.ts +77 -0
  164. package/src/ports/latex.ts +13 -0
  165. package/src/register.test.ts +165 -0
  166. package/src/register.ts +177 -0
  167. package/src/rule-cases.test.ts +43 -23
  168. package/src/rule-docs.test.ts +0 -1
  169. package/src/rules-config.ts +1 -0
  170. package/src/structure.harness.ts +1 -3
  171. package/src/structure.ts +1 -1
  172. package/src/tex-paper.test.ts +148 -0
  173. package/src/tex-paper.ts +161 -0
  174. package/src/tex-venue-rules.test.ts +87 -5
  175. package/src/tex-venue-rules.ts +62 -30
  176. package/templates/paper/PIPELINE-STATUS.md +0 -4
  177. package/dist/action-ref.d.ts +0 -12
  178. package/dist/action-ref.d.ts.map +0 -1
  179. package/dist/action-ref.js +0 -16
  180. package/dist/action-ref.js.map +0 -1
  181. package/dist/eslint-rules/paper-research-question.d.ts +0 -31
  182. package/dist/eslint-rules/paper-research-question.d.ts.map +0 -1
  183. package/dist/eslint-rules/paper-research-question.js +0 -188
  184. package/dist/eslint-rules/paper-research-question.js.map +0 -1
  185. package/eslint-rules/paper-research-question.harness.mjs +0 -212
  186. package/eslint-rules/paper-research-question.ts +0 -199
  187. package/fixtures/paper-research-question/comment-only/PIPELINE-STATUS.md +0 -9
  188. package/fixtures/paper-research-question/comment-only/paper.tex +0 -7
  189. package/fixtures/paper-research-question/declared-not-in-paper/PIPELINE-STATUS.md +0 -10
  190. package/fixtures/paper-research-question/declared-not-in-paper/paper.tex +0 -6
  191. package/fixtures/paper-research-question/draft/PIPELINE-STATUS.md +0 -6
  192. package/fixtures/paper-research-question/draft/paper.tex +0 -2
  193. package/fixtures/paper-research-question/markdown-no-rq/PIPELINE-STATUS.md +0 -9
  194. package/fixtures/paper-research-question/markdown-no-rq/paper.md +0 -4
  195. package/fixtures/paper-research-question/shipped-no-rq/PIPELINE-STATUS.md +0 -12
  196. package/fixtures/paper-research-question/shipped-no-rq/paper.tex +0 -3
  197. package/fixtures/paper-research-question/shipped-with-rq/PIPELINE-STATUS.md +0 -10
  198. package/fixtures/paper-research-question/shipped-with-rq/paper.tex +0 -2
  199. package/skills/harden-paper/fixtures/uncited-assertions-sample.md +0 -43
  200. package/skills/harden-paper/fixtures/uncited-assertions-sample.tex +0 -77
  201. package/src/action-ref.test.ts +0 -26
  202. 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);
@@ -16,7 +16,10 @@ import { readFileSync } from "node:fs";
16
16
  import { dirname, join } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { texLanguage } from "./latex-language.ts";
19
- import typography, { texVisibleRuns } from "./paper-typography.ts";
19
+ import typography, {
20
+ crefDefinitionRanges,
21
+ texVisibleRuns,
22
+ } from "./paper-typography.ts";
20
23
  import bib from "./bib-reachable-entry.ts";
21
24
 
22
25
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -118,6 +121,50 @@ describe("paper/section-word", () => {
118
121
  expect(ids(await lint(src), "paper/section-word")).toEqual([]);
119
122
  });
120
123
 
124
+ it("🔴 a cleveref name definition is not prose: silent, and --fix leaves it as written", async () => {
125
+ const src =
126
+ "\\documentclass{article}\n\\usepackage{cleveref}\n\\crefname{section}{§}{§§}\n\\Crefname{section}{§}{§§}\n" +
127
+ "\\begin{document}\nText.\n\\end{document}\n";
128
+ expect(ids(await lint(src), "paper/section-word")).toEqual([]);
129
+ expect(await fixed(src)).toBe(src);
130
+ });
131
+
132
+ it("a definition's range: its attached arguments, or the groups after a macro the parser did not sign", () => {
133
+ const signed = "\\crefname{section}{}{§§}";
134
+ expect(crefDefinitionRanges(signed)).toEqual([[0, signed.length]]);
135
+ const at = (s, e) => ({ start: { offset: s }, end: { offset: e } });
136
+ const tree = (macro, after) => ({ content: [macro, ...after] });
137
+ expect(
138
+ crefDefinitionRanges("", {
139
+ parse: () =>
140
+ tree({ type: "macro", content: "Crefname", position: at(0, 9) }, [
141
+ { type: "group", content: [], position: at(9, 14) },
142
+ { type: "string", content: "x", position: at(14, 15) },
143
+ ]),
144
+ }),
145
+ ).toEqual([[0, 14]]);
146
+ // A node with no position has no range; an argument with no content ends nothing.
147
+ expect(
148
+ crefDefinitionRanges("", {
149
+ parse: () =>
150
+ tree(
151
+ {
152
+ type: "macro",
153
+ content: "crefname",
154
+ args: [{ type: "argument" }],
155
+ },
156
+ [],
157
+ ),
158
+ }),
159
+ ).toEqual([]);
160
+ });
161
+
162
+ it("…while a § in prose next to such a definition is still reported", async () => {
163
+ const src =
164
+ "\\documentclass{article}\n\\crefname{section}{§}{§§}\n\\begin{document}\nSee §5.\n\\end{document}\n";
165
+ expect(ids(await lint(src), "paper/section-word")).toHaveLength(1);
166
+ });
167
+
121
168
  it("markdown: `§5` → `Section 5`, code untouched", async () => {
122
169
  const src = "See §5 and `§3` here.\n";
123
170
  expect(ids(await lint(src, "paper.md"), "paper/section-word")).toHaveLength(
@@ -61,8 +61,15 @@
61
61
  */
62
62
  import assert from "node:assert/strict";
63
63
  import { getParser } from "@unified-latex/unified-latex-util-parse";
64
+ import { takeWhile } from "remeda";
64
65
  import type { Nodes } from "mdast";
65
- 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";
66
73
  import type {
67
74
  RuleContext,
68
75
  RuleFixer,
@@ -101,16 +108,27 @@ export function bibRange(text: string): BibRange | null {
101
108
  return { start: m.index, end: m.index + whole.length, bodyStart, body };
102
109
  }
103
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
+
104
122
  /**
105
123
  * The ranges a markup rule must not read: LaTeX comments (the language gives them as `html`
106
124
  * nodes, with positions) and the inline bibliography.
107
125
  */
108
126
  function skippedRanges(
109
- sourceCode: RuleSourceCode,
127
+ nodes: readonly ProjectedNode[],
110
128
  raw: string,
111
129
  ): [number, number][] {
112
130
  const out: [number, number][] = [];
113
- for (const n of sourceCode.ast?.children ?? [])
131
+ for (const n of nodes)
114
132
  if (n.type === "html" && n.position) {
115
133
  // An offset-less point compares as NaN: inside no range, as `undefined` always did.
116
134
  const {
@@ -121,17 +139,97 @@ function skippedRanges(
121
139
  }
122
140
  const bib = bibRange(raw);
123
141
  if (bib) out.push([bib.start, bib.end]);
124
- return out;
142
+ return [...out, ...crefDefinitionRanges(raw)];
143
+ }
144
+
145
+ /**
146
+ * cleveref's name and format definitions (`\crefname{section}{§}{§§}`): the label a reference
147
+ * prints is set there, not written as prose, so a sign in them is not a sign in the text.
148
+ */
149
+ const CREF_DEFINITIONS: ReadonlySet<string> = new Set([
150
+ "crefname",
151
+ "Crefname",
152
+ "crefformat",
153
+ "Crefformat",
154
+ "crefrangeformat",
155
+ "Crefrangeformat",
156
+ "crefmultiformat",
157
+ "Crefmultiformat",
158
+ "crefrangemultiformat",
159
+ "Crefrangemultiformat",
160
+ ]);
161
+
162
+ const isGroup = (n: TexNode): boolean => n.type === "group";
163
+
164
+ /**
165
+ * A macro's range with its arguments: the ones the parser attached (cleveref's are signed) or the
166
+ * `{…}` groups standing right after it when it was given none — from the first to the last offset
167
+ * any of them carries. An argument's closing brace is one character past its last node.
168
+ */
169
+ function withArguments(
170
+ m: TexNode & { readonly type: "macro" },
171
+ after: readonly TexNode[],
172
+ ): readonly [number, number][] {
173
+ const offsets = [
174
+ m.position?.start.offset,
175
+ m.position?.end.offset,
176
+ ...(m.args ?? []).map((a) => {
177
+ const last = a.content?.at(-1)?.position?.end.offset;
178
+ return last === undefined ? undefined : last + 1;
179
+ }),
180
+ takeWhile(after, isGroup).at(-1)?.position?.end.offset,
181
+ ].filter((o): o is number => o !== undefined);
182
+ return offsets.length === 0
183
+ ? []
184
+ : [[Math.min(...offsets), Math.max(...offsets)]];
185
+ }
186
+
187
+ /**
188
+ * The ranges of the cleveref definitions in the preamble and in the document body. `parse` is
189
+ * unified-latex's; a test hands in a tree with the fields the walk tolerates being absent.
190
+ */
191
+ export function crefDefinitionRanges(
192
+ raw: string,
193
+ {
194
+ parse = (s: string): TexRoot => getParser().parse(s),
195
+ }: { readonly parse?: (s: string) => TexRoot } = {},
196
+ ): readonly [number, number][] {
197
+ const root = parse(raw);
198
+ const doc = root.content.find(
199
+ (n): n is Extract<TexNode, { type: "environment" | "mathenv" }> =>
200
+ n.type === "environment" && n.env === "document",
201
+ );
202
+ const lists: readonly (readonly TexNode[])[] = [
203
+ root.content,
204
+ doc?.content ?? [],
205
+ ];
206
+ return lists.flatMap((list) =>
207
+ list.flatMap((n, i) =>
208
+ n.type === "macro" && CREF_DEFINITIONS.has(n.content)
209
+ ? withArguments(n, list.slice(i + 1))
210
+ : [],
211
+ ),
212
+ );
125
213
  }
126
214
  const inside = (ranges: readonly [number, number][], i: number) =>
127
215
  ranges.some(([s, e]) => i >= s && i < e);
128
216
 
129
- /** Every match of `re` in `raw` outside the skipped ranges. */
130
- function markupMatches(sourceCode: TexSourceCode, re: RegExp) {
131
- const raw = sourceCode.raw;
132
- 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);
133
224
  return [...raw.matchAll(re)].filter((m) => !inside(skip, m.index));
134
- }
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);
135
233
 
136
234
  const isTex = (sourceCode: RuleSourceCode): sourceCode is TexSourceCode =>
137
235
  typeof sourceCode.raw === "string";
@@ -439,25 +537,42 @@ function leadingZero(context: RuleContext) {
439
537
 
440
538
  const FIG_REF = /\b(Fig\.|Figure)(?=~?\\(?:ref|autoref)\b)/g;
441
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
+ */
442
551
  function figureRefStyle(context: RuleContext) {
443
552
  const sc = context.sourceCode;
444
553
  if (!isTex(sc)) return;
445
- const all = markupMatches(sc, FIG_REF);
446
- const short = all.filter((m) => m[1] === "Fig.");
447
- const long = all.filter((m) => m[1] === "Figure");
448
- if (!short.length || !long.length) return;
449
- // The minority form is rewritten to the majority one; on a tie, to the full word.
450
- const [minority, word] =
451
- short.length > long.length ? [long, "Fig."] : [short, "Figure"];
452
- for (const m of minority) {
453
- const [, form = ""] = m;
454
- const range: [number, number] = [m.index, m.index + form.length];
455
- at(context, range[0], range[1], {
456
- messageId: "mixed",
457
- data: { form, word, n: String(all.length - minority.length) },
458
- 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
+ });
459
575
  });
460
- }
461
576
  }
462
577
 
463
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
package/eslint.config.mjs CHANGED
@@ -428,6 +428,7 @@ const TYPESCRIPT_ONLY = [
428
428
  "test/*.{js,mjs,cjs}",
429
429
  "test/e2e/**/*.{js,mjs,cjs}",
430
430
  "test/fixtures/layers/src/**/*.{js,mjs,cjs}",
431
+ "fixtures/accepted-papers/**/*.{js,mjs,cjs}",
431
432
  "fixtures/real-markdown-paper/**/*.{js,mjs,cjs}",
432
433
  "skills/paper-pipeline/scripts/fixtures/**/*.{js,mjs,cjs}",
433
434
  "skills/plan-paper-timeline/fixtures/**/*.{js,mjs,cjs}",
@@ -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
  }
@@ -1,8 +1,9 @@
1
1
  /**
2
- * ONE reader of `baseline.json`, shared by the two runs that compare against it: the harness
3
- * beside this file (the repository's own `bin/paperlint.mjs`) and `test/e2e/install/install.e2e.ts` (the binary
4
- * a consumer actually got). Two copies of "growth fails, a drop never does" would drift the first
5
- * time one of them is tightened, and the two runs would then disagree about the same article.
2
+ * ONE reader of `baseline.json`, shared by every run that compares against one: the harness beside
3
+ * this file (the repository's own `bin/paperlint.mjs`), `test/e2e/install/install.e2e.ts` (the
4
+ * binary a consumer actually got), and `fixtures/accepted-papers/accepted-papers.test.ts` (one
5
+ * baseline per accepted paper). Two copies of "growth fails, a drop never does" would drift the
6
+ * first time one of them is tightened, and the runs would then disagree about the same rules.
6
7
  */
7
8
  import { readFileSync } from "node:fs";
8
9
  import { dirname, join } from "node:path";
@@ -21,10 +22,10 @@ const LintOutput = z.array(
21
22
 
22
23
  const HERE = dirname(fileURLToPath(import.meta.url));
23
24
 
24
- /** The recorded counts, `{ruleId: n}`. */
25
- export function recordedFindings(): Counts {
25
+ /** The counts recorded in `dir`'s `baseline.json` (this article's by default), `{ruleId: n}`. */
26
+ export function recordedFindings(dir: string = HERE): Counts {
26
27
  return Baseline.parse(
27
- JSON.parse(readFileSync(join(HERE, "baseline.json"), "utf8")),
28
+ JSON.parse(readFileSync(join(dir, "baseline.json"), "utf8")),
28
29
  ).findings;
29
30
  }
30
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "4.1.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",
@@ -110,6 +110,7 @@
110
110
  "eslint-rules",
111
111
  "eslint.config.mjs",
112
112
  "fixtures",
113
+ "!fixtures/accepted-papers",
113
114
  "!fixtures/real-markdown-paper",
114
115
  "fixtures/real-markdown-paper/baseline.*",
115
116
  "hooks",
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",
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: cold-read-diff
3
- description: Send just-changed paper prose to a reader with NO context and ask what each sentence claims — the only check that catches a sentence which is short, true, jargon-free and still meaningless to anyone who does not already know the idea. Run after every prose edit to a paper, before calling the edit done, and before reporting a section as fixed. Scoped to the diff, so it is cheap enough to run every time. Not a writing grade (grade-paper-writing), not a structural pass (tighten-paper), not a defect review.
3
+ description: Send just-changed paper prose to two readers with NO context — an engineer who asks what each sentence claims, and a skimming workshop reviewer who asks whose result it states and whether it reads like a paper — the only check that catches a sentence which is short, true, jargon-free and still meaningless to anyone who does not already know the idea. Run after every prose edit to a paper, before calling the edit done, and before reporting a section as fixed. Scoped to the diff, so it is cheap enough to run every time. Not a writing grade (grade-paper-writing), not a structural pass (tighten-paper), not a defect review.
4
4
  context: fork
5
5
  allowed-tools: [Bash, Read, Agent]
6
6
  ---
7
7
 
8
- <!-- vigiles:sha256:e45391841e689aa9 compiled from skills/cold-read-diff/SKILL.md.spec.ts -->
8
+ <!-- vigiles:sha256:293f0627e6873774 compiled from skills/cold-read-diff/SKILL.md.spec.ts -->
9
9
 
10
10
  # cold-read-diff — the reader who cannot fake understanding
11
11
 
@@ -57,21 +57,41 @@ one, and its confusion is the finding.
57
57
 
58
58
  1. **Take the diff.** `git diff -U0 -- <paper.md>` since the last commit, or the `new_string` of the
59
59
  edits just applied. Prose only — skip comments, tables, bibliography.
60
- 2. **Spawn ONE agent with no repository context.** Give it the changed paragraphs **as text in the
61
- prompt**, not as a file path. A path is an invitation to read the neighbours, and reading the
62
- neighbours is how the check fails silently. `sonnet` is the right model: competent, cheap,
63
- and not the one that wrote the sentence.
64
- 3. **Ask exactly two questions per sentence:**
60
+ 2. **Spawn TWO agents with no repository context, one per persona, in parallel.** Give each the
61
+ changed paragraphs **as text in the prompt**, not as a file path. A path is an invitation to read
62
+ the neighbours, and reading the neighbours is how the check fails silently. `sonnet` is the right
63
+ model: competent, cheap, and not the one that wrote the sentence.
64
+
65
+ | persona | who | what only it can report |
66
+ |---|---|---|
67
+ | **the engineer** | a strong practitioner outside academia, reading closely | a sentence that parses only for a reader who already holds the idea |
68
+ | **the workshop reviewer** | a PC member at the target venue with 40 minutes for the whole paper, skimming the abstract, the introduction and the conclusion, Confidence 3 | a sentence whose result has no owner, a relation between claims left to infer, a sentence that does not read like a paper at this venue |
69
+
70
+ The engineer cannot see the second column: a sentence with no stated owner reads to him as
71
+ confident, and he does not know the venue's register. Both run, every time; neither replaces the
72
+ other. The reviewer is told the venue's name and nothing else. When the diff touches none of the
73
+ abstract, the introduction and the conclusion, the reviewer still reads it — as the paragraph a
74
+ skimming reviewer lands on.
75
+ 3. **Ask the engineer exactly two questions per sentence:**
65
76
  - *What does this sentence claim? Restate it in your own words.* If restating requires guessing,
66
77
  the answer is `CANNOT PARSE` plus what stopped them — an undefined term, a pronoun with no
67
78
  referent, a number with no denominator, two clauses that do not connect.
68
79
  - *Would a reader who stops here understand it?* Yes / No / Only-if-they-know-X.
80
+
81
+ **Ask the reviewer two different ones:**
82
+ - *Whose result does this sentence state — the authors' measurement, a cited work, or a property
83
+ of the world?* If it cannot tell, the answer is `WHOSE?` with the sentence quoted.
84
+ - *Does this read like a paper at this venue?* If not, `REGISTER` with the sentence quoted and
85
+ what a paper there would write instead — an aphorism, a fragment, a colloquial turn, or two
86
+ claims side by side with the relation between them unstated.
69
87
  4. **Ask for the dangerous class explicitly:** *which sentences sound clever but say less than they
70
88
  appear to?* Those read fine and carry nothing, and they are the ones a knowing reader waves
71
89
  through.
72
90
  5. **Apply the fixes — as rewrites of the whole thought, never as excisions.** See the rule below.
73
- 6. **Re-run on the fix.** An edit is not done until the same kind of reader parses the replacement.
74
- One confirming round; do not loop past it.
91
+ 6. **Re-run on the fix, with both personas.** An edit is not done until the engineer parses the
92
+ replacement AND the reviewer finds its owner and its register. A fix for the engineer that goes
93
+ colloquial is a new `REGISTER` finding, and the reverse holds too. One confirming round; do not
94
+ loop past it.
75
95
 
76
96
  ## 🔴 Hand the reader WHOLE PARAGRAPHS, never `+` lines
77
97
 
@@ -115,7 +135,8 @@ findings:
115
135
 
116
136
  - 🔴 **No context in the prompt. Ever.** Not the title, not the abstract, not "this is a paper about
117
137
  agent rules files". Every sentence of background handed to the reader is a sentence of confusion
118
- they will no longer report.
138
+ they will no longer report. The one exception is the venue's name, given to the reviewer persona:
139
+ judging register without knowing the venue is guessing.
119
140
  - 🔴 **A fix that removes the flagged construction and keeps the compression is not a fix.** Proven
120
141
  twice in one hour: *"not through any defect in how we write it, but because that is what English
121
142
  is"* → *"that is what English is for"* (tic gone, meaning inverted into nonsense); *"the agent