paperlint 2.1.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/CLAUDE.md +6 -1
  2. package/README.md +243 -235
  3. package/action.yml +4 -6
  4. package/dist/adapters/references/index.d.ts +13 -0
  5. package/dist/adapters/references/index.d.ts.map +1 -0
  6. package/dist/adapters/references/index.js +55 -0
  7. package/dist/adapters/references/index.js.map +1 -0
  8. package/dist/adapters/references/reach.io.d.ts +3 -0
  9. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  10. package/dist/adapters/references/reach.io.js +14 -0
  11. package/dist/adapters/references/reach.io.js.map +1 -0
  12. package/dist/build-engine.d.ts.map +1 -1
  13. package/dist/build-engine.js +6 -2
  14. package/dist/build-engine.js.map +1 -1
  15. package/dist/build.d.ts +15 -0
  16. package/dist/build.d.ts.map +1 -1
  17. package/dist/build.js +60 -7
  18. package/dist/build.js.map +1 -1
  19. package/dist/cli.d.ts +94 -30
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +500 -288
  22. package/dist/cli.js.map +1 -1
  23. package/dist/doctor.d.ts.map +1 -1
  24. package/dist/doctor.js +40 -72
  25. package/dist/doctor.js.map +1 -1
  26. package/dist/engine.d.ts +9 -0
  27. package/dist/engine.d.ts.map +1 -1
  28. package/dist/engine.js +27 -0
  29. package/dist/engine.js.map +1 -1
  30. package/dist/facts-file.d.ts +2 -2
  31. package/dist/facts-file.js +2 -2
  32. package/dist/hooks-settings.d.ts +0 -21
  33. package/dist/hooks-settings.d.ts.map +1 -1
  34. package/dist/hooks-settings.js +17 -135
  35. package/dist/hooks-settings.js.map +1 -1
  36. package/dist/init.d.ts +39 -29
  37. package/dist/init.d.ts.map +1 -1
  38. package/dist/init.js +144 -173
  39. package/dist/init.js.map +1 -1
  40. package/dist/link-skills.d.ts +1 -1
  41. package/dist/link-skills.d.ts.map +1 -1
  42. package/dist/link-skills.js +11 -29
  43. package/dist/link-skills.js.map +1 -1
  44. package/dist/new-paper.d.ts +8 -1
  45. package/dist/new-paper.d.ts.map +1 -1
  46. package/dist/new-paper.js +49 -25
  47. package/dist/new-paper.js.map +1 -1
  48. package/dist/paper-files.d.ts +85 -0
  49. package/dist/paper-files.d.ts.map +1 -0
  50. package/dist/paper-files.js +96 -0
  51. package/dist/paper-files.js.map +1 -0
  52. package/dist/paper-settings.d.ts +13 -41
  53. package/dist/paper-settings.d.ts.map +1 -1
  54. package/dist/paper-settings.js +71 -71
  55. package/dist/paper-settings.js.map +1 -1
  56. package/dist/ports/check-references.d.ts +31 -0
  57. package/dist/ports/check-references.d.ts.map +1 -0
  58. package/dist/ports/check-references.js +7 -0
  59. package/dist/ports/check-references.js.map +1 -0
  60. package/dist/presets.d.ts +1 -1
  61. package/dist/presets.d.ts.map +1 -1
  62. package/dist/presets.js +4 -6
  63. package/dist/presets.js.map +1 -1
  64. package/dist/reference-rules.d.ts +47 -0
  65. package/dist/reference-rules.d.ts.map +1 -0
  66. package/dist/reference-rules.js +156 -0
  67. package/dist/reference-rules.js.map +1 -0
  68. package/dist/references.d.ts +36 -0
  69. package/dist/references.d.ts.map +1 -0
  70. package/dist/references.js +91 -0
  71. package/dist/references.js.map +1 -0
  72. package/dist/rules-config.d.ts +5 -3
  73. package/dist/rules-config.d.ts.map +1 -1
  74. package/dist/rules-config.js +15 -6
  75. package/dist/rules-config.js.map +1 -1
  76. package/dist/structure.js +3 -3
  77. package/dist/structure.js.map +1 -1
  78. package/dist/tex-requirements.js +2 -2
  79. package/dist/tex-requirements.js.map +1 -1
  80. package/dist/toolchain.d.ts +5 -1
  81. package/dist/toolchain.d.ts.map +1 -1
  82. package/dist/toolchain.js +22 -5
  83. package/dist/toolchain.js.map +1 -1
  84. package/dist/types.d.ts +26 -18
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/venue-rules.d.ts.map +1 -1
  87. package/dist/venue-rules.js +12 -10
  88. package/dist/venue-rules.js.map +1 -1
  89. package/docs/configuration.md +163 -104
  90. package/docs/e2e.md +1 -1
  91. package/docs/install.md +25 -40
  92. package/docs/optional-rules.md +5 -4
  93. package/docs/rules.md +65 -24
  94. package/docs/skills.md +80 -0
  95. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  96. package/eslint-rules/latex-language.mjs +29 -1
  97. package/eslint-rules/paper-stages.harness.mjs +1 -117
  98. package/eslint-rules/paper-stages.mjs +0 -120
  99. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  100. package/eslint-rules/paper-typography.mjs +306 -191
  101. package/eslint-rules/paper-typography.test.mjs +337 -0
  102. package/eslint-rules/papers.harness.mjs +8 -12
  103. package/eslint-rules/papers.mjs +17 -31
  104. package/eslint-rules/pdf-last-page-balance.mjs +4 -7
  105. package/eslint-rules/review-frontmatter.mjs +118 -0
  106. package/eslint-rules/review-frontmatter.schema.json +36 -0
  107. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  108. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  109. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  110. package/eslint.config.mjs +0 -15
  111. package/fixtures/real-markdown-paper/baseline.json +6 -7
  112. package/fixtures/toolchain-mirror/catalog.txt +1 -0
  113. package/fixtures/toolchain-mirror/install-tl +4 -1
  114. package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
  115. package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
  116. package/hooks/hooks.harness.mjs +39 -98
  117. package/hooks/hooks.mutations.mjs +6 -6
  118. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  119. package/hooks/paper-edit-guard.hook.mjs +31 -42
  120. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  121. package/hooks/paper-status-gates.hook.mjs +21 -19
  122. package/hooks/paper-status-gates.sh +8 -13
  123. package/lib/paper-config.d.mts +15 -25
  124. package/lib/paper-config.harness.mjs +18 -35
  125. package/lib/paper-config.mjs +83 -105
  126. package/lib/paper-config.mutations.mjs +2 -2
  127. package/lib/skill-checks.mjs +1 -1
  128. package/lib/skill-trigger-cases.harness.mjs +9 -8
  129. package/lib/skill-trigger-cases.mjs +7 -15
  130. package/package.json +1 -1
  131. package/scripts/eslint-report-guard.mjs +1 -1
  132. package/scripts/harness-api.frozen.json +0 -3
  133. package/scripts/mutation-batteries.frozen.json +5 -14
  134. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  135. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  136. package/skills/cold-read-diff/SKILL.md +20 -1
  137. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  138. package/skills/grade-paper-writing/SKILL.md +20 -1
  139. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  140. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  141. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  142. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  144. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  145. package/skills/plan-paper-timeline/SKILL.md +2 -2
  146. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  147. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  148. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  149. package/skills/render-paper/check-render.sh +2 -3
  150. package/skills/render-paper/render-paper.harness.mjs +2 -2
  151. package/skills/verify-citations/SKILL.md +10 -1
  152. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  153. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  154. package/src/adapters/references/index.ts +102 -0
  155. package/src/adapters/references/reach.io.ts +12 -0
  156. package/src/build-engine.ts +6 -1
  157. package/src/build.harness.mjs +54 -6
  158. package/src/build.ts +89 -11
  159. package/src/cli.harness.mjs +149 -294
  160. package/src/cli.mutations.mjs +35 -44
  161. package/src/cli.ts +637 -328
  162. package/src/doctor.harness.mjs +41 -84
  163. package/src/doctor.mutations.mjs +9 -9
  164. package/src/doctor.ts +44 -89
  165. package/src/engine.harness.mjs +50 -0
  166. package/src/engine.ts +35 -0
  167. package/src/facts-file.test.ts +0 -7
  168. package/src/facts-file.ts +2 -2
  169. package/src/hooks-settings.harness.mjs +7 -92
  170. package/src/hooks-settings.mutations.mjs +2 -17
  171. package/src/hooks-settings.ts +18 -162
  172. package/src/init.test.ts +206 -0
  173. package/src/init.ts +187 -231
  174. package/src/link-skills.harness.mjs +1 -38
  175. package/src/link-skills.mutations.mjs +9 -5
  176. package/src/link-skills.ts +10 -34
  177. package/src/lint-fix.test.ts +120 -0
  178. package/src/linted-set.test.ts +118 -0
  179. package/src/new-paper.harness.mjs +2 -0
  180. package/src/new-paper.mutations.mjs +0 -14
  181. package/src/new-paper.test.ts +173 -14
  182. package/src/new-paper.ts +82 -30
  183. package/src/paper-files.test.ts +395 -0
  184. package/src/paper-files.ts +200 -0
  185. package/src/paper-settings-commands.test.ts +121 -122
  186. package/src/paper-settings.test.ts +93 -64
  187. package/src/paper-settings.ts +99 -97
  188. package/src/parse-args.test.ts +48 -0
  189. package/src/ports/check-references.ts +28 -0
  190. package/src/presets.ts +5 -15
  191. package/src/reference-rules.ts +229 -0
  192. package/src/references.test.ts +209 -0
  193. package/src/references.ts +138 -0
  194. package/src/rules-config.harness.mjs +24 -8
  195. package/src/rules-config.ts +19 -7
  196. package/src/rules-off.test.ts +111 -0
  197. package/src/structure.harness.mjs +4 -2
  198. package/src/structure.ts +3 -3
  199. package/src/tex-requirements.ts +2 -2
  200. package/src/toolchain.harness.mjs +41 -6
  201. package/src/toolchain.ts +29 -5
  202. package/src/types.ts +36 -18
  203. package/src/venue-rules.test.ts +23 -11
  204. package/src/venue-rules.ts +12 -14
  205. package/templates/paper/paperlint.json +1 -1
  206. package/eslint-rules/doc-fields.harness.mjs +0 -336
  207. package/eslint-rules/doc-fields.mjs +0 -186
  208. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  209. package/eslint-rules/paper-typography.harness.mjs +0 -291
  210. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  211. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  212. package/eslint-rules/review-findings-cause.mjs +0 -135
  213. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  214. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  215. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  216. package/fixtures/review-findings-cause/clean.md +0 -17
  217. package/fixtures/review-findings-cause/defect.md +0 -14
  218. package/fixtures/review-findings-cause/old-debt.md +0 -14
  219. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
package/src/new-paper.ts CHANGED
@@ -25,10 +25,7 @@ import {
25
25
  /* eslint-enable boundaries/dependencies */
26
26
  import { join } from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
- import {
29
- LEGACY_PAPER_SETTINGS_FILE,
30
- PAPER_SETTINGS_FILE,
31
- } from "../lib/paper-config.mjs";
28
+ import { CONFIG_FILE } from "../lib/paper-config.mjs";
32
29
 
33
30
  export type PaperFormat = "tex" | "md";
34
31
  export const FORMATS: readonly PaperFormat[] = ["tex", "md"];
@@ -91,7 +88,7 @@ export type NewPaperResult =
91
88
  * already has a source in EITHER format, in which case that one stands — and `paperlint.json`.
92
89
  */
93
90
  export function wantedFiles(dir: string, format: PaperFormat): string[] {
94
- return [...scorecardAndSource(dir, format), ...settingsFile(dir)];
91
+ return [...scorecardAndSource(dir, format), CONFIG_FILE];
95
92
  }
96
93
 
97
94
  function scorecardAndSource(dir: string, format: PaperFormat): string[] {
@@ -101,24 +98,85 @@ function scorecardAndSource(dir: string, format: PaperFormat): string[] {
101
98
  return hasSource ? [STATUS_FILE] : [STATUS_FILE, SOURCE_FILE[format]];
102
99
  }
103
100
 
101
+ /** The venue `paperlint new --venue` chose: what goes into the new paper's `paperlint.json`. */
102
+ export interface VenueSetting {
103
+ /** `paperlint:<name>`, or a path relative to the paper's `paperlint.json`. */
104
+ readonly extends: string;
105
+ readonly kind: string | null;
106
+ }
107
+
104
108
  /**
105
- * `paperlint.json`, so that nobody has to know the file exists: its template says what to put in
106
- * it, and until a venue preset is named lint says so in one warning. Not beside a pre-2.1.0
107
- * `venue.json`: `paperlint init` moves that file, and a second, different one would make the move
108
- * refuse.
109
+ * The template's `paperlint.json` with the chosen venue written in. The template's `$comment`
110
+ * explains how to choose one, so it goes once one is chosen. A template that is not plain JSON (a
111
+ * project's own, with comments) cannot be edited safely and is refused by name.
109
112
  */
110
- function settingsFile(dir: string): string[] {
111
- return existsSync(join(dir, LEGACY_PAPER_SETTINGS_FILE))
112
- ? []
113
- : [PAPER_SETTINGS_FILE];
113
+ function withVenue(
114
+ text: string,
115
+ src: string,
116
+ venue: VenueSetting,
117
+ ): { ok: true; text: string } | { ok: false; reason: string } {
118
+ let obj: Record<string, unknown>;
119
+ try {
120
+ obj = JSON.parse(text) as Record<string, unknown>;
121
+ } catch {
122
+ return {
123
+ ok: false,
124
+ reason: `--venue cannot be written into ${src}: it is not plain JSON — set "extends" by hand`,
125
+ };
126
+ }
127
+ const out: Record<string, unknown> = { ...obj, extends: venue.extends };
128
+ delete out["$comment"];
129
+ if (venue.kind !== null) out["kind"] = venue.kind;
130
+ return { ok: true, text: `${JSON.stringify(out, null, 2)}\n` };
131
+ }
132
+
133
+ /** A file `newPaper` will write: its text, and which template it came from. */
134
+ interface Planned {
135
+ readonly file: string;
136
+ readonly text: string;
137
+ readonly from: "project" | "package";
138
+ }
139
+
140
+ /** One file's text from its template — the project's override first, the package's second. */
141
+ function fromTemplate(
142
+ file: string,
143
+ {
144
+ papersRoot,
145
+ packageTemplates,
146
+ name,
147
+ venue,
148
+ }: {
149
+ papersRoot: string;
150
+ packageTemplates: string;
151
+ name: string;
152
+ venue: VenueSetting | null;
153
+ },
154
+ ): { ok: true; value: Planned } | { ok: false; reason: string } {
155
+ const project = join(papersRoot, OVERRIDE_DIR, file);
156
+ const [src, from] = existsSync(project)
157
+ ? [project, "project" as const]
158
+ : [join(packageTemplates, file), "package" as const];
159
+ // A missing package template is a broken install, not a reason to write an empty file.
160
+ if (!existsSync(src))
161
+ return { ok: false, reason: `no template for ${file}: ${src} is missing` };
162
+ const text = readFileSync(src, "utf8").split("{{name}}").join(name);
163
+ if (file !== CONFIG_FILE || venue === null)
164
+ return { ok: true, value: { file, text, from } };
165
+ const edited = withVenue(text, src, venue);
166
+ return edited.ok
167
+ ? { ok: true, value: { file, text: edited.text, from } }
168
+ : edited;
114
169
  }
115
170
 
116
- // Documented in README.md#starting-a-paper — update it when this changes.
171
+ // Documented in README.md#getting-started — update it when this changes.
117
172
  export function newPaper(
118
173
  papersRoot: string,
119
174
  name: string,
120
175
  format: PaperFormat,
121
- { packageTemplates = PACKAGE_TEMPLATES }: { packageTemplates?: string } = {},
176
+ {
177
+ packageTemplates = PACKAGE_TEMPLATES,
178
+ venue = null,
179
+ }: { packageTemplates?: string; venue?: VenueSetting | null } = {},
122
180
  ): NewPaperResult {
123
181
  const problem = nameProblem(name);
124
182
  if (problem) return { ok: false, reason: problem };
@@ -128,26 +186,20 @@ export function newPaper(
128
186
  return { ok: false, reason: `${dir} exists and is not a directory` };
129
187
 
130
188
  const files: FileOutcome[] = [];
131
- const toWrite: { file: string; text: string; from: "project" | "package" }[] =
132
- [];
189
+ const toWrite: Planned[] = [];
133
190
  for (const file of wantedFiles(dir, format)) {
134
191
  if (existsSync(join(dir, file))) {
135
192
  files.push({ file, status: "kept" });
136
193
  continue;
137
194
  }
138
- const project = join(papersRoot, OVERRIDE_DIR, file);
139
- const fromPackage = join(packageTemplates, file);
140
- const [src, from] = existsSync(project)
141
- ? [project, "project" as const]
142
- : [fromPackage, "package" as const];
143
- // A missing package template is a broken install, not a reason to write an empty file.
144
- if (!existsSync(src))
145
- return {
146
- ok: false,
147
- reason: `no template for ${file}: ${src} is missing`,
148
- };
149
- const text = readFileSync(src, "utf8").split("{{name}}").join(name);
150
- toWrite.push({ file, text, from });
195
+ const planned = fromTemplate(file, {
196
+ papersRoot,
197
+ packageTemplates,
198
+ name,
199
+ venue,
200
+ });
201
+ if (!planned.ok) return planned;
202
+ toWrite.push(planned.value);
151
203
  }
152
204
  // Read every template before writing any file, so a missing one leaves no half-made folder.
153
205
  mkdirSync(dir, { recursive: true });
@@ -0,0 +1,395 @@
1
+ /**
2
+ * WHICH FILES `paperlint lint` LINTS — only the ones its own blocks claim, and a rule named in
3
+ * `rules` reaches only the files it was written for.
4
+ *
5
+ * The defect this closes (found migrating a consumer project with vendored JS under a paper's
6
+ * `repro/` folder): paperlint handed whole directories to ESLint, and ESLint's built-in defaults
7
+ * then linted every `.js`/`.mjs`/`.cjs` under the papers directory. 1,877 vendored files produced
8
+ * 74 errors — parse errors and "Definition for rule … was not found" — none of them on a paper
9
+ * file, and the run failed. Nothing in the settings could turn that off.
10
+ */
11
+ import { ESLint, type Linter } from "eslint";
12
+ import {
13
+ mkdirSync,
14
+ mkdtempSync,
15
+ realpathSync,
16
+ rmSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { afterEach, describe, expect, it } from "vitest";
22
+ import { buildConfig, run } from "./cli.ts";
23
+ import {
24
+ narrowToOwners,
25
+ ownedPatterns,
26
+ ownedScopes,
27
+ ruleOwners,
28
+ scopeToOwned,
29
+ } from "./paper-files.ts";
30
+
31
+ const dirs: string[] = [];
32
+ afterEach(() => {
33
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
34
+ });
35
+
36
+ const TEX = "\\documentclass{article}\n\\begin{document}x\\end{document}\n";
37
+
38
+ /** A project with one paper `a` (paper.tex + PIPELINE-STATUS.md), plus `files`. */
39
+ function project(files: Record<string, string> = {}): string {
40
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-scope-")));
41
+ dirs.push(root);
42
+ const all: Record<string, string> = {
43
+ "package.json": JSON.stringify({ name: "c", private: true }),
44
+ "papers/a/paper.tex": TEX,
45
+ "papers/a/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
46
+ ...files,
47
+ };
48
+ for (const [p, text] of Object.entries(all)) {
49
+ mkdirSync(join(root, p, ".."), { recursive: true });
50
+ writeFileSync(join(root, p), text);
51
+ }
52
+ return root;
53
+ }
54
+
55
+ async function lint(
56
+ root: string,
57
+ args: string[] = [],
58
+ ): Promise<{ code: number; out: string; err: string }> {
59
+ const out: string[] = [];
60
+ const err: string[] = [];
61
+ const code = await run(["lint", "--json", ...args], {
62
+ cwd: root,
63
+ log: (s: string) => out.push(s),
64
+ err: (s: string) => err.push(s),
65
+ });
66
+ return { code, out: out.join("\n"), err: err.join("\n") };
67
+ }
68
+
69
+ const linted = (stdout: string): string[] =>
70
+ (JSON.parse(stdout) as { filePath: string }[]).map((r) => r.filePath);
71
+
72
+ /** Vendored code under a paper: a syntax error, an unknown rule in a directive, plain CommonJS. */
73
+ const VENDORED = {
74
+ "papers/a/repro/x.js": "return 1;\n",
75
+ "papers/a/repro/y.mjs":
76
+ "// eslint-disable-next-line import/no-nodejs-modules\nimport fs from 'node:fs';\n",
77
+ "papers/a/repro/lib/z.cjs": "module.exports = 1;\n",
78
+ };
79
+
80
+ describe("paperlint lint — lints only the files paperlint owns", () => {
81
+ it("🔴 vendored JS under a paper is never linted, and the run stays green", async () => {
82
+ const r = await lint(project(VENDORED));
83
+ expect(r.err).not.toMatch(/Parsing error|was not found/);
84
+ const files = linted(r.out);
85
+ expect(files.filter((f) => /\.[cm]?js$/.test(f))).toEqual([]);
86
+ expect(r.code).toBe(0);
87
+ });
88
+
89
+ it("…while the paper's own files ARE linted (the other half)", async () => {
90
+ const root = project({
91
+ ...VENDORED,
92
+ "papers/a/reviews/r1.md": "# review\n",
93
+ });
94
+ const files = linted((await lint(root)).out);
95
+ for (const f of [
96
+ "papers/a/paper.tex",
97
+ "papers/a/PIPELINE-STATUS.md",
98
+ "papers/a/reviews/r1.md",
99
+ ])
100
+ expect(files).toContain(join(root, f));
101
+ });
102
+
103
+ it("a papers directory holding only vendored JS lints NOTHING — and says so", async () => {
104
+ const root = project({
105
+ "papers/a/paper.tex": "",
106
+ ...VENDORED,
107
+ });
108
+ rmSync(join(root, "papers/a/paper.tex"));
109
+ rmSync(join(root, "papers/a/PIPELINE-STATUS.md"));
110
+ // From the config: the directory holds no paper, which is said before ESLint runs.
111
+ const fromConfig = await lint(root);
112
+ expect(fromConfig.code).toBe(2);
113
+ expect(fromConfig.err).toMatch(/^no papers in papers\//);
114
+ // Named on the command line: ESLint gets it, lints nothing, and the run says so.
115
+ const named = await lint(root, ["papers"]);
116
+ expect(named.code).toBe(1);
117
+ expect(named.err).toMatch(/nothing was linted/);
118
+ });
119
+
120
+ it("a file named on the command line that paperlint does not lint is refused by name", async () => {
121
+ const root = project(VENDORED);
122
+ const r = await lint(root, ["papers/a/repro/x.js"]);
123
+ expect(r.code).toBe(2);
124
+ expect(r.err).toMatch(
125
+ /papers\/a\/repro\/x\.js is not a file paperlint lints/,
126
+ );
127
+ expect(r.err).toMatch(/PIPELINE-STATUS\.md/);
128
+ });
129
+
130
+ it("a paper file named on the command line is still linted", async () => {
131
+ const root = project(VENDORED);
132
+ const r = await lint(root, ["papers/a/paper.tex"]);
133
+ expect(linted(r.out)).toEqual([join(root, "papers/a/paper.tex")]);
134
+ });
135
+ });
136
+
137
+ describe("`rules` blocks reach only the files each rule is written for", () => {
138
+ it.each(["paper/source", "paper/section-word", "review/frontmatter"])(
139
+ "🔴 %s over papers/** runs, instead of crashing inside ESLint",
140
+ async (id) => {
141
+ const root = project({
142
+ "paperlint.json": JSON.stringify({
143
+ rules: [{ files: ["papers/**"], rules: { [id]: "warn" } }],
144
+ }),
145
+ });
146
+ const r = await lint(root);
147
+ expect(r.err).not.toMatch(/Could not find/);
148
+ expect(r.code).toBe(0);
149
+ },
150
+ );
151
+
152
+ it("the same, from a paper's own paperlint.json", async () => {
153
+ const root = project({
154
+ "papers/a/paperlint.json": JSON.stringify({
155
+ rules: { "paper/source": "warn", "paper/section-word": "off" },
156
+ }),
157
+ });
158
+ const r = await lint(root);
159
+ expect(r.err).not.toMatch(/Could not find/);
160
+ expect(r.code).toBe(0);
161
+ });
162
+ });
163
+
164
+ describe("🔴 a generated block never reaches a file paperlint does not lint (#101)", () => {
165
+ // A venue preset with `rules` (agenticdev turns on pdf/last-page-balance) became a block over
166
+ // every paper file. `pdf` is registered for every file, so the block was not narrowed, and the
167
+ // siblings index — which the sibling-card block excludes — matched a block with no language:
168
+ // ESLint parsed it as JavaScript ("Parsing error: Assigning to rvalue").
169
+ const SIBLINGS = {
170
+ "papers/a/siblings/README.md": "# Siblings\n\n- [x](smith2025.md)\n",
171
+ "papers/a/siblings/smith2025.md": "---\nread: full\n---\n# Smith 2025\n",
172
+ };
173
+
174
+ it.each([
175
+ [
176
+ "a venue preset's rules",
177
+ {
178
+ "papers/a/paperlint.json": JSON.stringify({
179
+ extends: "paperlint:agenticdev",
180
+ }),
181
+ },
182
+ ],
183
+ [
184
+ // `papers/**` alone would not show it: ESLint does not lint a file on a pattern ending in
185
+ // `/**`, it only applies such a block to files something else claims.
186
+ "a root block over every markdown file",
187
+ {
188
+ "paperlint.json": JSON.stringify({
189
+ rules: [
190
+ {
191
+ files: ["papers/**/*.md", "papers/**/*.tex"],
192
+ rules: { "pdf/last-page-balance": "warn" },
193
+ },
194
+ ],
195
+ }),
196
+ },
197
+ ],
198
+ [
199
+ "a paper's own { id: severity }",
200
+ {
201
+ "papers/a/paperlint.json": JSON.stringify({
202
+ rules: { "pdf/last-page-balance": "warn" },
203
+ }),
204
+ },
205
+ ],
206
+ ])(
207
+ "%s: the siblings index is not parsed, the cards still are",
208
+ async (_, settings) => {
209
+ const root = project({ ...SIBLINGS, ...settings });
210
+ const r = await lint(root);
211
+ const results = JSON.parse(r.out) as {
212
+ filePath: string;
213
+ messages: { ruleId: string | null; message: string }[];
214
+ }[];
215
+ // Guards: the class — no file reaches ESLint's default JavaScript parser.
216
+ expect(
217
+ results.flatMap((x) => x.messages).filter((m) => m.ruleId === null),
218
+ ).toEqual([]);
219
+ expect(linted(r.out)).not.toContain(
220
+ join(root, "papers/a/siblings/README.md"),
221
+ );
222
+ // Guards: the other half — narrowing did not drop the files the block is for.
223
+ expect(linted(r.out)).toContain(
224
+ join(root, "papers/a/siblings/smith2025.md"),
225
+ );
226
+ expect(linted(r.out)).toContain(join(root, "papers/a/paper.tex"));
227
+ },
228
+ );
229
+ });
230
+
231
+ describe("`rules` blocks — where the rule lands", () => {
232
+ it("on its own files and on no other", async () => {
233
+ const root = project();
234
+ const eslint = new ESLint({
235
+ cwd: root,
236
+ overrideConfigFile: true,
237
+ overrideConfig: buildConfig(
238
+ {
239
+ rules: [
240
+ {
241
+ basePath: root,
242
+ files: ["papers/**"],
243
+ rules: { "paper/source": "warn", "paper/section-word": "off" },
244
+ },
245
+ ],
246
+ },
247
+ null,
248
+ ) as Linter.Config[],
249
+ });
250
+ const on = async (file: string) =>
251
+ (
252
+ (await eslint.calculateConfigForFile(join(root, file))) as {
253
+ rules: Record<string, unknown>;
254
+ }
255
+ ).rules;
256
+ const status = await on("papers/a/PIPELINE-STATUS.md");
257
+ expect(status["paper/source"]).toEqual([1]);
258
+ expect(status["paper/section-word"]).toBeUndefined();
259
+ const draft = await on("papers/a/paper.md");
260
+ expect(draft["paper/section-word"]).toEqual([0]);
261
+ expect(draft["paper/source"]).toBeUndefined();
262
+ });
263
+ });
264
+
265
+ describe("paper-files — the pieces", () => {
266
+ const own = [
267
+ { plugins: { g: { rules: { any: {} } } } },
268
+ { files: ["**/a.md"], plugins: { p: { rules: { one: {} } } } },
269
+ { files: ["**/b.md", "**/c.md"], plugins: { p: { rules: { two: {} } } } },
270
+ { files: ["**/c.md"], plugins: { p: { rules: { two: {} } } } },
271
+ ];
272
+
273
+ it("owned patterns are every own block's files, once", () => {
274
+ expect(ownedPatterns(own)).toEqual(["**/a.md", "**/b.md", "**/c.md"]);
275
+ });
276
+
277
+ it("a rule's owner is where its plugin is registered; a global plugin owns every OWNED file", () => {
278
+ const owners = ruleOwners(own);
279
+ expect(owners.get("g/any")).toEqual([
280
+ { files: ["**/a.md", "**/b.md", "**/c.md"], ignores: [] },
281
+ ]);
282
+ expect(owners.get("p/one")).toEqual([{ files: ["**/a.md"], ignores: [] }]);
283
+ expect(owners.get("p/two")).toEqual([
284
+ { files: ["**/b.md", "**/c.md"], ignores: [] },
285
+ ]);
286
+ });
287
+
288
+ it("🔴 a scope keeps its block's exceptions, and a global plugin inherits them (#101)", () => {
289
+ const withIndex = [
290
+ ...own,
291
+ {
292
+ files: ["**/s/*.md"],
293
+ ignores: ["**/s/README.md"],
294
+ plugins: { s: { rules: { card: {} } } },
295
+ },
296
+ ];
297
+ expect(ownedScopes(withIndex)).toEqual([
298
+ { files: ["**/a.md", "**/b.md", "**/c.md"], ignores: [] },
299
+ { files: ["**/s/*.md"], ignores: ["**/s/README.md"] },
300
+ ]);
301
+ // Guards: the defect — the plugin registered for every file used to keep any glob, and so
302
+ // reached the index the card block excludes.
303
+ expect(ruleOwners(withIndex).get("g/any")).toEqual(ownedScopes(withIndex));
304
+ expect(
305
+ narrowToOwners(
306
+ {
307
+ basePath: "/x",
308
+ files: ["papers/**/*.md"],
309
+ rules: { "g/any": "warn" },
310
+ },
311
+ ruleOwners(withIndex),
312
+ ).at(-1),
313
+ ).toEqual({
314
+ basePath: "/x",
315
+ files: [["papers/**/*.md", "**/s/*.md"]],
316
+ ignores: ["**/s/README.md"],
317
+ rules: { "g/any": "warn" },
318
+ });
319
+ });
320
+ });
321
+
322
+ describe("paper-files — narrowing a block", () => {
323
+ const own = [
324
+ { plugins: { g: { rules: { any: {} } } } },
325
+ { files: ["**/a.md"], plugins: { p: { rules: { one: {} } } } },
326
+ { files: ["**/b.md", "**/c.md"], plugins: { p: { rules: { two: {} } } } },
327
+ ];
328
+
329
+ it("a block is split per owner, its files ANDed with the owner's", () => {
330
+ const blocks = narrowToOwners(
331
+ {
332
+ basePath: "/x",
333
+ files: ["papers/**"],
334
+ ignores: ["papers/old/**"],
335
+ rules: { "p/one": "warn", "p/two": "error", "g/any": "off" },
336
+ },
337
+ ruleOwners(own),
338
+ );
339
+ expect(blocks).toEqual([
340
+ {
341
+ basePath: "/x",
342
+ files: [["papers/**", "**/a.md"]],
343
+ ignores: ["papers/old/**"],
344
+ rules: { "p/one": "warn" },
345
+ },
346
+ {
347
+ basePath: "/x",
348
+ files: [
349
+ ["papers/**", "**/b.md"],
350
+ ["papers/**", "**/c.md"],
351
+ ],
352
+ ignores: ["papers/old/**"],
353
+ rules: { "p/two": "error" },
354
+ },
355
+ {
356
+ basePath: "/x",
357
+ files: ["**/a.md", "**/b.md", "**/c.md"].map((o) => ["papers/**", o]),
358
+ ignores: ["papers/old/**"],
359
+ rules: { "g/any": "off" },
360
+ },
361
+ ]);
362
+ });
363
+
364
+ it("without `files`, the owner's patterns are the block's files", () => {
365
+ expect(
366
+ narrowToOwners(
367
+ { basePath: "/x", rules: { "p/one": "warn" } },
368
+ ruleOwners(own),
369
+ ),
370
+ ).toEqual([
371
+ { basePath: "/x", files: ["**/a.md"], rules: { "p/one": "warn" } },
372
+ ]);
373
+ });
374
+
375
+ it("a rule registered nowhere in this config reaches nothing — no block, no crash", () => {
376
+ expect(
377
+ narrowToOwners(
378
+ { basePath: "/x", rules: { "tex/x": "warn" } },
379
+ ruleOwners(own),
380
+ ),
381
+ ).toEqual([]);
382
+ });
383
+ });
384
+
385
+ describe("paper-files — the scope", () => {
386
+ it("ignores everything, then un-ignores directories and owned files, then re-ignores ESLint's defaults", () => {
387
+ expect(scopeToOwned(["**/a.md"]).ignores).toEqual([
388
+ "**/*",
389
+ "!**/*/",
390
+ "!**/a.md",
391
+ "**/node_modules/",
392
+ "**/.git/",
393
+ ]);
394
+ });
395
+ });