paperlint 2.0.1 → 3.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 (226) hide show
  1. package/CLAUDE.md +6 -1
  2. package/CONTRIBUTING.md +21 -0
  3. package/README.md +231 -180
  4. package/action.yml +4 -6
  5. package/dist/adapters/references/index.d.ts +13 -0
  6. package/dist/adapters/references/index.d.ts.map +1 -0
  7. package/dist/adapters/references/index.js +55 -0
  8. package/dist/adapters/references/index.js.map +1 -0
  9. package/dist/adapters/references/reach.io.d.ts +3 -0
  10. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  11. package/dist/adapters/references/reach.io.js +14 -0
  12. package/dist/adapters/references/reach.io.js.map +1 -0
  13. package/dist/build-engine.d.ts.map +1 -1
  14. package/dist/build-engine.js +5 -2
  15. package/dist/build-engine.js.map +1 -1
  16. package/dist/build.d.ts +16 -1
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/build.js +40 -5
  19. package/dist/build.js.map +1 -1
  20. package/dist/cli.d.ts +37 -20
  21. package/dist/cli.d.ts.map +1 -1
  22. package/dist/cli.js +377 -251
  23. package/dist/cli.js.map +1 -1
  24. package/dist/doctor.d.ts.map +1 -1
  25. package/dist/doctor.js +37 -52
  26. package/dist/doctor.js.map +1 -1
  27. package/dist/facts-file.d.ts +33 -9
  28. package/dist/facts-file.d.ts.map +1 -1
  29. package/dist/facts-file.js +117 -17
  30. package/dist/facts-file.js.map +1 -1
  31. package/dist/hooks-settings.d.ts +0 -21
  32. package/dist/hooks-settings.d.ts.map +1 -1
  33. package/dist/hooks-settings.js +17 -135
  34. package/dist/hooks-settings.js.map +1 -1
  35. package/dist/init.d.ts +40 -19
  36. package/dist/init.d.ts.map +1 -1
  37. package/dist/init.js +144 -109
  38. package/dist/init.js.map +1 -1
  39. package/dist/link-skills.d.ts +1 -1
  40. package/dist/link-skills.d.ts.map +1 -1
  41. package/dist/link-skills.js +11 -29
  42. package/dist/link-skills.js.map +1 -1
  43. package/dist/new-paper.d.ts +2 -2
  44. package/dist/new-paper.d.ts.map +1 -1
  45. package/dist/new-paper.js +6 -2
  46. package/dist/new-paper.js.map +1 -1
  47. package/dist/paper-files.d.ts +63 -0
  48. package/dist/paper-files.d.ts.map +1 -0
  49. package/dist/paper-files.js +69 -0
  50. package/dist/paper-files.js.map +1 -0
  51. package/dist/paper-settings.d.ts +40 -0
  52. package/dist/paper-settings.d.ts.map +1 -0
  53. package/dist/paper-settings.js +144 -0
  54. package/dist/paper-settings.js.map +1 -0
  55. package/dist/ports/check-references.d.ts +31 -0
  56. package/dist/ports/check-references.d.ts.map +1 -0
  57. package/dist/ports/check-references.js +7 -0
  58. package/dist/ports/check-references.js.map +1 -0
  59. package/dist/presets.d.ts +84 -0
  60. package/dist/presets.d.ts.map +1 -0
  61. package/dist/presets.js +184 -0
  62. package/dist/presets.js.map +1 -0
  63. package/dist/reference-rules.d.ts +47 -0
  64. package/dist/reference-rules.d.ts.map +1 -0
  65. package/dist/reference-rules.js +156 -0
  66. package/dist/reference-rules.js.map +1 -0
  67. package/dist/references.d.ts +36 -0
  68. package/dist/references.d.ts.map +1 -0
  69. package/dist/references.js +91 -0
  70. package/dist/references.js.map +1 -0
  71. package/dist/rules-config.d.ts +11 -2
  72. package/dist/rules-config.d.ts.map +1 -1
  73. package/dist/rules-config.js +25 -11
  74. package/dist/rules-config.js.map +1 -1
  75. package/dist/structure.d.ts.map +1 -1
  76. package/dist/structure.js +3 -2
  77. package/dist/structure.js.map +1 -1
  78. package/dist/tex-requirements.d.ts +63 -7
  79. package/dist/tex-requirements.d.ts.map +1 -1
  80. package/dist/tex-requirements.js +91 -24
  81. package/dist/tex-requirements.js.map +1 -1
  82. package/dist/types.d.ts +20 -18
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/venue-rules.d.ts +109 -0
  85. package/dist/venue-rules.d.ts.map +1 -0
  86. package/dist/venue-rules.js +389 -0
  87. package/dist/venue-rules.js.map +1 -0
  88. package/docs/configuration.md +152 -77
  89. package/docs/e2e.md +1 -1
  90. package/docs/install.md +25 -40
  91. package/docs/optional-rules.md +26 -24
  92. package/docs/rules.md +181 -15
  93. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  94. package/eslint-rules/latex-language.mjs +29 -1
  95. package/eslint-rules/paper-stages.harness.mjs +1 -117
  96. package/eslint-rules/paper-stages.mjs +0 -120
  97. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  98. package/eslint-rules/paper-typography.mjs +267 -192
  99. package/eslint-rules/paper-typography.test.mjs +297 -0
  100. package/eslint-rules/papers.harness.mjs +8 -12
  101. package/eslint-rules/papers.mjs +17 -27
  102. package/eslint-rules/pdf-last-page-balance.mjs +23 -5
  103. package/eslint-rules/review-frontmatter.mjs +118 -0
  104. package/eslint-rules/review-frontmatter.schema.json +36 -0
  105. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  106. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  107. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  108. package/eslint.config.mjs +0 -15
  109. package/fixtures/build-e2e/acmart/paperlint.json +1 -0
  110. package/fixtures/real-markdown-paper/baseline.json +6 -7
  111. package/hooks/hooks.harness.mjs +39 -98
  112. package/hooks/hooks.mutations.mjs +6 -6
  113. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  114. package/hooks/paper-edit-guard.hook.mjs +31 -42
  115. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  116. package/hooks/paper-status-gates.hook.mjs +21 -19
  117. package/hooks/paper-status-gates.sh +8 -13
  118. package/lib/paper-config.d.mts +15 -18
  119. package/lib/paper-config.harness.mjs +18 -35
  120. package/lib/paper-config.mjs +90 -81
  121. package/lib/paper-config.mutations.mjs +2 -2
  122. package/lib/skill-checks.mjs +1 -1
  123. package/lib/skill-trigger-cases.harness.mjs +9 -8
  124. package/lib/skill-trigger-cases.mjs +7 -15
  125. package/package.json +1 -1
  126. package/scripts/eslint-report-guard.mjs +1 -1
  127. package/scripts/harness-api.frozen.json +0 -3
  128. package/scripts/mutation-batteries.frozen.json +4 -13
  129. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  130. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  131. package/skills/cold-read-diff/SKILL.md +20 -1
  132. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  133. package/skills/find-venue/SKILL.md +5 -1
  134. package/skills/find-venue/SKILL.md.spec.ts +4 -0
  135. package/skills/grade-paper-writing/SKILL.md +20 -1
  136. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  137. package/skills/paper-pipeline/PIPELINE-MAP.md +14 -4
  138. package/skills/paper-pipeline/SKILL.md +4 -3
  139. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -2
  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/extract-pdf-facts.harness.mjs +5 -5
  151. package/skills/render-paper/extract-pdf-facts.mjs +4 -4
  152. package/skills/render-paper/render-paper.harness.mjs +2 -2
  153. package/skills/study-accepted-papers/SKILL.md +2 -1
  154. package/skills/study-accepted-papers/SKILL.md.spec.ts +1 -0
  155. package/skills/submit-paper/SKILL.md +9 -1
  156. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  157. package/skills/submit-paper/references/venues/acm-sigconf.jsonc +86 -0
  158. package/skills/submit-paper/references/venues/agenticdev.jsonc +34 -102
  159. package/skills/submit-paper/references/venues/aisec.jsonc +21 -82
  160. package/skills/submit-paper/references/venues/realm.jsonc +45 -43
  161. package/skills/submit-paper/references/venues/tex-base.jsonc +1 -1
  162. package/skills/submit-paper/references/venues/venue-profile.schema.json +119 -29
  163. package/skills/verify-citations/SKILL.md +10 -1
  164. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  165. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  166. package/src/adapters/references/index.ts +102 -0
  167. package/src/adapters/references/reach.io.ts +12 -0
  168. package/src/build-engine.ts +10 -2
  169. package/src/build.harness.mjs +31 -22
  170. package/src/build.ts +65 -8
  171. package/src/cli.harness.mjs +151 -294
  172. package/src/cli.mutations.mjs +35 -44
  173. package/src/cli.ts +457 -288
  174. package/src/doctor.harness.mjs +41 -84
  175. package/src/doctor.mutations.mjs +9 -9
  176. package/src/doctor.ts +41 -61
  177. package/src/facts-file.test.ts +47 -7
  178. package/src/facts-file.ts +145 -24
  179. package/src/hooks-settings.harness.mjs +7 -92
  180. package/src/hooks-settings.mutations.mjs +2 -17
  181. package/src/hooks-settings.ts +18 -162
  182. package/src/init.test.ts +206 -0
  183. package/src/init.ts +193 -159
  184. package/src/link-skills.harness.mjs +1 -38
  185. package/src/link-skills.mutations.mjs +9 -5
  186. package/src/link-skills.ts +10 -34
  187. package/src/lint-fix.test.ts +120 -0
  188. package/src/new-paper.harness.mjs +9 -6
  189. package/src/new-paper.test.ts +133 -0
  190. package/src/new-paper.ts +7 -2
  191. package/src/paper-files.test.ts +290 -0
  192. package/src/paper-files.ts +144 -0
  193. package/src/paper-settings-commands.test.ts +274 -0
  194. package/src/paper-settings.test.ts +249 -0
  195. package/src/paper-settings.ts +208 -0
  196. package/src/ports/check-references.ts +28 -0
  197. package/src/presets.test.ts +236 -0
  198. package/src/presets.ts +297 -0
  199. package/src/reference-rules.ts +229 -0
  200. package/src/references.test.ts +209 -0
  201. package/src/references.ts +138 -0
  202. package/src/rules-config.harness.mjs +24 -8
  203. package/src/rules-config.ts +30 -13
  204. package/src/structure.harness.mjs +6 -4
  205. package/src/structure.ts +3 -2
  206. package/src/tex-requirements.harness.mjs +11 -17
  207. package/src/tex-requirements.ts +179 -29
  208. package/src/types.ts +30 -18
  209. package/src/venue-rules.test.ts +554 -0
  210. package/src/venue-rules.ts +571 -0
  211. package/templates/paper/paperlint.json +4 -0
  212. package/eslint-rules/doc-fields.harness.mjs +0 -336
  213. package/eslint-rules/doc-fields.mjs +0 -186
  214. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  215. package/eslint-rules/paper-typography.harness.mjs +0 -291
  216. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  217. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  218. package/eslint-rules/review-findings-cause.mjs +0 -135
  219. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  220. package/fixtures/build-e2e/acmart/venue.json +0 -1
  221. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  222. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  223. package/fixtures/review-findings-cause/clean.md +0 -17
  224. package/fixtures/review-findings-cause/defect.md +0 -14
  225. package/fixtures/review-findings-cause/old-debt.md +0 -14
  226. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -0,0 +1,290 @@
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
+ ruleOwners,
27
+ scopeToOwned,
28
+ } from "./paper-files.ts";
29
+
30
+ const dirs: string[] = [];
31
+ afterEach(() => {
32
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
33
+ });
34
+
35
+ const TEX = "\\documentclass{article}\n\\begin{document}x\\end{document}\n";
36
+
37
+ /** A project with one paper `a` (paper.tex + PIPELINE-STATUS.md), plus `files`. */
38
+ function project(files: Record<string, string> = {}): string {
39
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-scope-")));
40
+ dirs.push(root);
41
+ const all: Record<string, string> = {
42
+ "package.json": JSON.stringify({ name: "c", private: true }),
43
+ "papers/a/paper.tex": TEX,
44
+ "papers/a/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
45
+ ...files,
46
+ };
47
+ for (const [p, text] of Object.entries(all)) {
48
+ mkdirSync(join(root, p, ".."), { recursive: true });
49
+ writeFileSync(join(root, p), text);
50
+ }
51
+ return root;
52
+ }
53
+
54
+ async function lint(
55
+ root: string,
56
+ args: string[] = [],
57
+ ): Promise<{ code: number; out: string; err: string }> {
58
+ const out: string[] = [];
59
+ const err: string[] = [];
60
+ const code = await run(["lint", "--json", ...args], {
61
+ cwd: root,
62
+ log: (s: string) => out.push(s),
63
+ err: (s: string) => err.push(s),
64
+ });
65
+ return { code, out: out.join("\n"), err: err.join("\n") };
66
+ }
67
+
68
+ const linted = (stdout: string): string[] =>
69
+ (JSON.parse(stdout) as { filePath: string }[]).map((r) => r.filePath);
70
+
71
+ /** Vendored code under a paper: a syntax error, an unknown rule in a directive, plain CommonJS. */
72
+ const VENDORED = {
73
+ "papers/a/repro/x.js": "return 1;\n",
74
+ "papers/a/repro/y.mjs":
75
+ "// eslint-disable-next-line import/no-nodejs-modules\nimport fs from 'node:fs';\n",
76
+ "papers/a/repro/lib/z.cjs": "module.exports = 1;\n",
77
+ };
78
+
79
+ describe("paperlint lint — lints only the files paperlint owns", () => {
80
+ it("🔴 vendored JS under a paper is never linted, and the run stays green", async () => {
81
+ const r = await lint(project(VENDORED));
82
+ expect(r.err).not.toMatch(/Parsing error|was not found/);
83
+ const files = linted(r.out);
84
+ expect(files.filter((f) => /\.[cm]?js$/.test(f))).toEqual([]);
85
+ expect(r.code).toBe(0);
86
+ });
87
+
88
+ it("…while the paper's own files ARE linted (the other half)", async () => {
89
+ const root = project({
90
+ ...VENDORED,
91
+ "papers/a/reviews/r1.md": "# review\n",
92
+ });
93
+ const files = linted((await lint(root)).out);
94
+ for (const f of [
95
+ "papers/a/paper.tex",
96
+ "papers/a/PIPELINE-STATUS.md",
97
+ "papers/a/reviews/r1.md",
98
+ ])
99
+ expect(files).toContain(join(root, f));
100
+ });
101
+
102
+ it("a papers directory holding only vendored JS lints NOTHING — and says so", async () => {
103
+ const root = project({
104
+ "papers/a/paper.tex": "",
105
+ ...VENDORED,
106
+ });
107
+ rmSync(join(root, "papers/a/paper.tex"));
108
+ rmSync(join(root, "papers/a/PIPELINE-STATUS.md"));
109
+ // From the config: the directory holds no paper, which is said before ESLint runs.
110
+ const fromConfig = await lint(root);
111
+ expect(fromConfig.code).toBe(2);
112
+ expect(fromConfig.err).toMatch(/^no papers in papers\//);
113
+ // Named on the command line: ESLint gets it, lints nothing, and the run says so.
114
+ const named = await lint(root, ["papers"]);
115
+ expect(named.code).toBe(1);
116
+ expect(named.err).toMatch(/nothing was linted/);
117
+ });
118
+
119
+ it("a file named on the command line that paperlint does not lint is refused by name", async () => {
120
+ const root = project(VENDORED);
121
+ const r = await lint(root, ["papers/a/repro/x.js"]);
122
+ expect(r.code).toBe(2);
123
+ expect(r.err).toMatch(
124
+ /papers\/a\/repro\/x\.js is not a file paperlint lints/,
125
+ );
126
+ expect(r.err).toMatch(/PIPELINE-STATUS\.md/);
127
+ });
128
+
129
+ it("a paper file named on the command line is still linted", async () => {
130
+ const root = project(VENDORED);
131
+ const r = await lint(root, ["papers/a/paper.tex"]);
132
+ expect(linted(r.out)).toEqual([join(root, "papers/a/paper.tex")]);
133
+ });
134
+ });
135
+
136
+ describe("`rules` blocks reach only the files each rule is written for", () => {
137
+ it.each(["paper/source", "paper/section-word", "review/frontmatter"])(
138
+ "🔴 %s over papers/** runs, instead of crashing inside ESLint",
139
+ async (id) => {
140
+ const root = project({
141
+ "paperlint.json": JSON.stringify({
142
+ rules: [{ files: ["papers/**"], rules: { [id]: "warn" } }],
143
+ }),
144
+ });
145
+ const r = await lint(root);
146
+ expect(r.err).not.toMatch(/Could not find/);
147
+ expect(r.code).toBe(0);
148
+ },
149
+ );
150
+
151
+ it("the same, from a paper's own paperlint.json", async () => {
152
+ const root = project({
153
+ "papers/a/paperlint.json": JSON.stringify({
154
+ rules: { "paper/source": "warn", "paper/section-word": "off" },
155
+ }),
156
+ });
157
+ const r = await lint(root);
158
+ expect(r.err).not.toMatch(/Could not find/);
159
+ expect(r.code).toBe(0);
160
+ });
161
+ });
162
+
163
+ describe("`rules` blocks — where the rule lands", () => {
164
+ it("on its own files and on no other", async () => {
165
+ const root = project();
166
+ const eslint = new ESLint({
167
+ cwd: root,
168
+ overrideConfigFile: true,
169
+ overrideConfig: buildConfig(
170
+ {
171
+ rules: [
172
+ {
173
+ basePath: root,
174
+ files: ["papers/**"],
175
+ rules: { "paper/source": "warn", "paper/section-word": "off" },
176
+ },
177
+ ],
178
+ },
179
+ null,
180
+ ) as Linter.Config[],
181
+ });
182
+ const on = async (file: string) =>
183
+ (
184
+ (await eslint.calculateConfigForFile(join(root, file))) as {
185
+ rules: Record<string, unknown>;
186
+ }
187
+ ).rules;
188
+ const status = await on("papers/a/PIPELINE-STATUS.md");
189
+ expect(status["paper/source"]).toEqual([1]);
190
+ expect(status["paper/section-word"]).toBeUndefined();
191
+ const draft = await on("papers/a/paper.md");
192
+ expect(draft["paper/section-word"]).toEqual([0]);
193
+ expect(draft["paper/source"]).toBeUndefined();
194
+ });
195
+ });
196
+
197
+ describe("paper-files — the pieces", () => {
198
+ const own = [
199
+ { plugins: { g: { rules: { any: {} } } } },
200
+ { files: ["**/a.md"], plugins: { p: { rules: { one: {} } } } },
201
+ { files: ["**/b.md", "**/c.md"], plugins: { p: { rules: { two: {} } } } },
202
+ { files: ["**/c.md"], plugins: { p: { rules: { two: {} } } } },
203
+ ];
204
+
205
+ it("owned patterns are every own block's files, once", () => {
206
+ expect(ownedPatterns(own)).toEqual(["**/a.md", "**/b.md", "**/c.md"]);
207
+ });
208
+
209
+ it("a rule's owner is where its plugin is registered; a global plugin owns every file", () => {
210
+ const owners = ruleOwners(own);
211
+ expect(owners.get("g/any")).toBeNull();
212
+ expect(owners.get("p/one")).toEqual(["**/a.md"]);
213
+ expect(owners.get("p/two")).toEqual(["**/b.md", "**/c.md"]);
214
+ });
215
+ });
216
+
217
+ describe("paper-files — narrowing a block", () => {
218
+ const own = [
219
+ { plugins: { g: { rules: { any: {} } } } },
220
+ { files: ["**/a.md"], plugins: { p: { rules: { one: {} } } } },
221
+ { files: ["**/b.md", "**/c.md"], plugins: { p: { rules: { two: {} } } } },
222
+ ];
223
+
224
+ it("a block is split per owner, its files ANDed with the owner's", () => {
225
+ const blocks = narrowToOwners(
226
+ {
227
+ basePath: "/x",
228
+ files: ["papers/**"],
229
+ ignores: ["papers/old/**"],
230
+ rules: { "p/one": "warn", "p/two": "error", "g/any": "off" },
231
+ },
232
+ ruleOwners(own),
233
+ );
234
+ expect(blocks).toEqual([
235
+ {
236
+ basePath: "/x",
237
+ files: [["papers/**", "**/a.md"]],
238
+ ignores: ["papers/old/**"],
239
+ rules: { "p/one": "warn" },
240
+ },
241
+ {
242
+ basePath: "/x",
243
+ files: [
244
+ ["papers/**", "**/b.md"],
245
+ ["papers/**", "**/c.md"],
246
+ ],
247
+ ignores: ["papers/old/**"],
248
+ rules: { "p/two": "error" },
249
+ },
250
+ {
251
+ basePath: "/x",
252
+ files: ["papers/**"],
253
+ ignores: ["papers/old/**"],
254
+ rules: { "g/any": "off" },
255
+ },
256
+ ]);
257
+ });
258
+
259
+ it("without `files`, the owner's patterns are the block's files", () => {
260
+ expect(
261
+ narrowToOwners(
262
+ { basePath: "/x", rules: { "p/one": "warn" } },
263
+ ruleOwners(own),
264
+ ),
265
+ ).toEqual([
266
+ { basePath: "/x", files: ["**/a.md"], rules: { "p/one": "warn" } },
267
+ ]);
268
+ });
269
+
270
+ it("a rule registered nowhere in this config reaches nothing — no block, no crash", () => {
271
+ expect(
272
+ narrowToOwners(
273
+ { basePath: "/x", rules: { "tex/x": "warn" } },
274
+ ruleOwners(own),
275
+ ),
276
+ ).toEqual([]);
277
+ });
278
+ });
279
+
280
+ describe("paper-files — the scope", () => {
281
+ it("ignores everything, then un-ignores directories and owned files, then re-ignores ESLint's defaults", () => {
282
+ expect(scopeToOwned(["**/a.md"]).ignores).toEqual([
283
+ "**/*",
284
+ "!**/*/",
285
+ "!**/a.md",
286
+ "**/node_modules/",
287
+ "**/.git/",
288
+ ]);
289
+ });
290
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * THE FILES PAPERLINT OWNS — read off its own config, and made the only thing ESLint may reach.
3
+ *
4
+ * paperlint's blocks name the files they lint (`PIPELINE-STATUS.md`, `paper.md`, `paper.tex`,
5
+ * `reviews/*.md`, …). That list is the whole scope of `paperlint lint`, and this module turns it
6
+ * into two things ESLint understands:
7
+ *
8
+ * 1. **A global ignore of everything else** (`scopeToOwned`). Handing ESLint a directory is not
9
+ * enough: its built-in defaults lint every JavaScript file it finds, whatever our config says.
10
+ * Measured on a consumer project with vendored code under a paper's `repro/` folder: 1,877
11
+ * files, 74 errors ("'return' outside of function", "Definition for rule … was not found"),
12
+ * zero of them on a paper file, exit 1 — and nothing in the settings could turn it off. With
13
+ * the ignore, a stray script under the papers directory is never enumerated at all, and a file
14
+ * named on the command line can be asked about (`ESLint#isPathIgnored`) with ESLint's own
15
+ * matcher instead of a hand-written one.
16
+ *
17
+ * 2. **Each rule scoped to the files it was written for** (`ruleOwners` + `narrowToOwners`).
18
+ * One plugin NAME is bound to different plugin objects in different blocks: `paper` holds
19
+ * `stages`/`source`/`author-list` beside `PIPELINE-STATUS.md` and `research-question`/
20
+ * `typography` beside `paper.md`/`paper.tex`. So a consumer block that named `paper/source`
21
+ * for every file under the papers directory reached `paper.tex`, where `paper` has no `source`
22
+ * — and ESLint threw `Could not find "source" in plugin "paper"` from inside `lintFiles`,
23
+ * after paperlint's own validation had (correctly) accepted the id. A block is therefore split
24
+ * per owner and its `files` ANDed with the owner's (ESLint's nested-array form), so a rule can
25
+ * only land where its plugin is registered.
26
+ *
27
+ * ESLint's semantics that this leans on, each measured against ESLint 10 before writing it:
28
+ * - a global-ignore pattern that matches a DIRECTORY hides everything inside it, so directories
29
+ * must be un-ignored (`!` + a pattern ending in a slash) before owned files can be;
30
+ * - that un-ignores `node_modules/` and `.git/` too — ESLint's own defaults — so they are
31
+ * re-ignored after, or a `paper.md` inside a dependency would be linted;
32
+ * - later global-ignore patterns win, across blocks, in config order;
33
+ * - `files: [["a", "b"]]` matches a file only when BOTH patterns match.
34
+ */
35
+ import type { RuleBlock, RuleEntry } from "./rules-config.ts";
36
+
37
+ /** What this module reads of a config block. */
38
+ interface Block {
39
+ readonly files?: readonly unknown[];
40
+ readonly plugins?: Readonly<
41
+ Record<string, { readonly rules?: Readonly<Record<string, unknown>> }>
42
+ >;
43
+ }
44
+
45
+ /** A `files` entry: one glob, or several that must ALL match. */
46
+ export type FilesEntry = string | readonly string[];
47
+
48
+ /** A consumer block after narrowing: its `files` may carry ANDed pairs. */
49
+ export interface ScopedBlock {
50
+ readonly basePath: string;
51
+ readonly files?: readonly FilesEntry[];
52
+ readonly ignores?: readonly string[];
53
+ readonly rules: Readonly<Record<string, RuleEntry>>;
54
+ }
55
+
56
+ const globsOf = (b: Block): string[] =>
57
+ (b.files ?? []).filter((f): f is string => typeof f === "string");
58
+
59
+ /** Every glob paperlint's own blocks lint, once, in config order. */
60
+ export function ownedPatterns(own: readonly unknown[]): string[] {
61
+ return [...new Set(own.flatMap((b) => globsOf(b as Block)))];
62
+ }
63
+
64
+ /** ESLint's default ignores, re-applied after directories are un-ignored. */
65
+ const ESLINT_DEFAULT_IGNORES = ["**/node_modules/", "**/.git/"];
66
+
67
+ /** A global-ignore block that leaves ESLint nothing to lint but `owned`. */
68
+ export function scopeToOwned(owned: readonly string[]): {
69
+ readonly ignores: string[];
70
+ } {
71
+ return {
72
+ ignores: [
73
+ "**/*",
74
+ "!**/*/",
75
+ ...owned.map((p) => `!${p}`),
76
+ ...ESLINT_DEFAULT_IGNORES,
77
+ ],
78
+ };
79
+ }
80
+
81
+ /** Every `<plugin>/<rule>` a block registers. */
82
+ const ruleIdsOf = (b: Block): string[] =>
83
+ Object.entries(b.plugins ?? {}).flatMap(([name, plugin]) =>
84
+ Object.keys(plugin.rules ?? {}).map((rule) => `${name}/${rule}`),
85
+ );
86
+
87
+ /** An owner seen so far, widened by one more block's globs; `null` (every file) absorbs all. */
88
+ const widen = (
89
+ had: readonly string[] | null | undefined,
90
+ files: readonly string[] | null,
91
+ ): readonly string[] | null =>
92
+ had === null || files === null
93
+ ? null
94
+ : [...new Set([...(had ?? []), ...files])];
95
+
96
+ /**
97
+ * Rule id → the globs of the blocks that register its plugin with that rule. `null`: the plugin
98
+ * is registered for every file (a block without `files`), so the rule resolves anywhere.
99
+ */
100
+ export function ruleOwners(
101
+ own: readonly unknown[],
102
+ ): Map<string, readonly string[] | null> {
103
+ const owners = new Map<string, readonly string[] | null>();
104
+ for (const raw of own) {
105
+ const b = raw as Block;
106
+ const files = b.files ? globsOf(b) : null;
107
+ for (const id of ruleIdsOf(b)) owners.set(id, widen(owners.get(id), files));
108
+ }
109
+ return owners;
110
+ }
111
+
112
+ /**
113
+ * One consumer block → one block per owner, each carrying only that owner's rules, its `files`
114
+ * ANDed with the owner's globs. A rule no block of this config registers (the LaTeX block is
115
+ * absent when its parser did not load) gets no block: it has no file to reach in this run, and
116
+ * naming it to ESLint would only crash.
117
+ */
118
+ export function narrowToOwners(
119
+ block: RuleBlock,
120
+ owners: ReadonlyMap<string, readonly string[] | null>,
121
+ ): ScopedBlock[] {
122
+ const groups = new Map<string, Record<string, RuleEntry>>();
123
+ for (const [id, entry] of Object.entries(block.rules)) {
124
+ const owner = owners.get(id);
125
+ if (owner === undefined) continue;
126
+ const key = JSON.stringify(owner);
127
+ groups.set(key, { ...groups.get(key), [id]: entry });
128
+ }
129
+ return [...groups].map(([key, rules]) => {
130
+ const owner = JSON.parse(key) as string[] | null;
131
+ const files: readonly FilesEntry[] | undefined =
132
+ owner === null
133
+ ? block.files
134
+ : block.files
135
+ ? block.files.flatMap((f) => owner.map((o) => [f, o]))
136
+ : owner;
137
+ return {
138
+ basePath: block.basePath,
139
+ ...(files ? { files } : {}),
140
+ ...(block.ignores ? { ignores: block.ignores } : {}),
141
+ rules,
142
+ };
143
+ });
144
+ }