paperlint 3.0.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 (73) hide show
  1. package/README.md +179 -180
  2. package/dist/build-engine.d.ts.map +1 -1
  3. package/dist/build-engine.js +6 -2
  4. package/dist/build-engine.js.map +1 -1
  5. package/dist/build.d.ts.map +1 -1
  6. package/dist/build.js +22 -3
  7. package/dist/build.js.map +1 -1
  8. package/dist/cli.d.ts +66 -5
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +230 -30
  11. package/dist/cli.js.map +1 -1
  12. package/dist/engine.d.ts +9 -0
  13. package/dist/engine.d.ts.map +1 -1
  14. package/dist/engine.js +27 -0
  15. package/dist/engine.js.map +1 -1
  16. package/dist/init.js +1 -1
  17. package/dist/init.js.map +1 -1
  18. package/dist/new-paper.d.ts +8 -1
  19. package/dist/new-paper.d.ts.map +1 -1
  20. package/dist/new-paper.js +50 -15
  21. package/dist/new-paper.js.map +1 -1
  22. package/dist/paper-files.d.ts +30 -8
  23. package/dist/paper-files.d.ts.map +1 -1
  24. package/dist/paper-files.js +56 -29
  25. package/dist/paper-files.js.map +1 -1
  26. package/dist/structure.js +1 -1
  27. package/dist/structure.js.map +1 -1
  28. package/dist/toolchain.d.ts +5 -1
  29. package/dist/toolchain.d.ts.map +1 -1
  30. package/dist/toolchain.js +22 -5
  31. package/dist/toolchain.js.map +1 -1
  32. package/dist/types.d.ts +6 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/venue-rules.js +1 -1
  35. package/dist/venue-rules.js.map +1 -1
  36. package/docs/configuration.md +48 -4
  37. package/docs/rules.md +29 -25
  38. package/docs/skills.md +80 -0
  39. package/eslint-rules/paper-typography.mjs +47 -7
  40. package/eslint-rules/paper-typography.test.mjs +40 -0
  41. package/eslint-rules/review-frontmatter.schema.json +1 -1
  42. package/fixtures/toolchain-mirror/catalog.txt +1 -0
  43. package/fixtures/toolchain-mirror/install-tl +4 -1
  44. package/fixtures/toolchain-mirror/stub-fmtutil-sys +4 -0
  45. package/fixtures/toolchain-mirror/stub-tlmgr +15 -0
  46. package/package.json +1 -1
  47. package/scripts/mutation-batteries.frozen.json +1 -1
  48. package/src/build-engine.ts +6 -1
  49. package/src/build.harness.mjs +46 -2
  50. package/src/build.ts +27 -5
  51. package/src/cli.harness.mjs +2 -2
  52. package/src/cli.ts +301 -32
  53. package/src/engine.harness.mjs +50 -0
  54. package/src/engine.ts +35 -0
  55. package/src/init.ts +1 -1
  56. package/src/linted-set.test.ts +118 -0
  57. package/src/new-paper.harness.mjs +2 -0
  58. package/src/new-paper.mutations.mjs +0 -14
  59. package/src/new-paper.test.ts +172 -1
  60. package/src/new-paper.ts +84 -17
  61. package/src/paper-files.test.ts +110 -5
  62. package/src/paper-files.ts +93 -37
  63. package/src/paper-settings-commands.test.ts +41 -5
  64. package/src/parse-args.test.ts +48 -0
  65. package/src/rules-off.test.ts +111 -0
  66. package/src/structure.harness.mjs +2 -2
  67. package/src/structure.ts +1 -1
  68. package/src/toolchain.harness.mjs +41 -6
  69. package/src/toolchain.ts +29 -5
  70. package/src/types.ts +6 -0
  71. package/src/venue-rules.test.ts +3 -0
  72. package/src/venue-rules.ts +1 -1
  73. package/templates/paper/paperlint.json +1 -1
@@ -0,0 +1,118 @@
1
+ /**
2
+ * THE LINTED SET IS EXACTLY THE OWNED FILES — checked on the whole result, not one route in.
3
+ *
4
+ * Each test in `paper-files.test.ts` closes one way a stray file got into `paperlint lint`: vendored
5
+ * JavaScript under a paper (#99), a rule source that reached the siblings index (#101). This one
6
+ * does not care about the route. A paper with every kind of neighbour a real one has, settings from
7
+ * every level that generates blocks (a venue preset with `rules`, the root `rules`), and one
8
+ * assertion: the files ESLint returned are exactly the four paperlint owns — set equality, so a
9
+ * route nobody has thought of yet shows up as an extra member.
10
+ */
11
+ import {
12
+ mkdirSync,
13
+ mkdtempSync,
14
+ realpathSync,
15
+ rmSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join, relative } from "node:path";
20
+ import { afterEach, describe, expect, it } from "vitest";
21
+ import { run } from "./cli.ts";
22
+
23
+ const dirs: string[] = [];
24
+ afterEach(() => {
25
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
26
+ });
27
+
28
+ const TEX = "\\documentclass{article}\n\\begin{document}x\\end{document}\n";
29
+ const JS = "export const x = 1;\n";
30
+
31
+ /** The files paperlint owns in the fixture paper. */
32
+ const OWNED = [
33
+ "papers/a/paper.tex",
34
+ "papers/a/PIPELINE-STATUS.md",
35
+ "papers/a/reviews/r1.md",
36
+ "papers/a/siblings/smith2025.md",
37
+ ];
38
+
39
+ /** Everything else a real paper folder and project carry. None of it may be linted. */
40
+ const NEIGHBOURS = {
41
+ "papers/a/siblings/README.md": "# Siblings\n\n- [Smith](smith2025.md)\n",
42
+ "papers/a/README.md": "# Paper a\n",
43
+ "papers/a/notes/idea.md": "# An idea\n",
44
+ "papers/a/repro/x.js": "return 1;\n",
45
+ "papers/a/repro/lib/y.mjs": JS,
46
+ "papers/a/repro/z.cjs": "module.exports = 1;\n",
47
+ "papers/a/repro/t.ts": "const t: number = 1;\n",
48
+ "script.js": JS,
49
+ "node_modules/pkg/index.js": JS,
50
+ };
51
+
52
+ function project(): string {
53
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-set-")));
54
+ dirs.push(root);
55
+ const all: Record<string, string> = {
56
+ "package.json": JSON.stringify({ name: "c", private: true }),
57
+ // A root `rules` block over every file of the project: the widest glob a consumer can write.
58
+ "paperlint.json": JSON.stringify({
59
+ rules: [
60
+ {
61
+ files: ["**/*.md", "**/*.tex", "**/*.js"],
62
+ rules: {
63
+ "pdf/last-page-balance": "warn",
64
+ "paper/section-word": "warn",
65
+ },
66
+ },
67
+ ],
68
+ }),
69
+ // A venue preset that carries `rules` of its own.
70
+ "papers/a/paperlint.json": JSON.stringify({
71
+ extends: "paperlint:agenticdev",
72
+ }),
73
+ "papers/a/paper.tex": TEX,
74
+ "papers/a/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
75
+ "papers/a/reviews/r1.md": "# review\n",
76
+ "papers/a/siblings/smith2025.md": "---\nread: full\n---\n# Smith 2025\n",
77
+ ...NEIGHBOURS,
78
+ };
79
+ for (const [p, text] of Object.entries(all)) {
80
+ mkdirSync(join(root, p, ".."), { recursive: true });
81
+ writeFileSync(join(root, p), text);
82
+ }
83
+ return root;
84
+ }
85
+
86
+ /** `paperlint lint --json <args>` → the files it linted, relative to the project, sorted. */
87
+ async function lintedSet(root: string, args: string[]): Promise<string[]> {
88
+ const out: string[] = [];
89
+ await run(["lint", "--json", ...args], {
90
+ cwd: root,
91
+ log: (s: string) => out.push(s),
92
+ err: () => {},
93
+ });
94
+ type Result = { filePath: string; messages: { ruleId: string | null }[] };
95
+ return (
96
+ (JSON.parse(out.join("\n")) as Result[])
97
+ // A structure finding reports a MISSING file in the same shape; it was not linted.
98
+ .filter(
99
+ (r) => !r.messages.some((m) => m.ruleId === "structure/required-file"),
100
+ )
101
+ .map((r) => relative(root, r.filePath))
102
+ .sort()
103
+ );
104
+ }
105
+
106
+ describe("🔴 paperlint lint — the linted set is EXACTLY the owned files", () => {
107
+ it.each([
108
+ ["the papers directory from the config", []],
109
+ ["the paper named on the command line", ["papers/a"]],
110
+ // The widest scope: ESLint is handed the project root, so it enumerates `script.js` and
111
+ // `node_modules/` too, and only the scope keeps them out.
112
+ ["the whole project named on the command line", ["."]],
113
+ ])("%s", async (_, args) => {
114
+ // Guards: every route in at once — vendored JS/TS, a root script, node_modules, a README next
115
+ // to the paper, the siblings index, notes. An extra member names the file that got through.
116
+ expect(await lintedSet(project(), args)).toEqual([...OWNED].sort());
117
+ });
118
+ });
@@ -191,6 +191,8 @@ try {
191
191
  "---\n---\n# House scorecard for {{name}}\n",
192
192
  );
193
193
  const own = newPaper(papers, "house", "md");
194
+ // Guards: the override slot — `fromTemplate` in new-paper.ts reads `<papers>/.template/<file>`
195
+ // before the package's copy. Replace that lookup with the package's alone and this goes red.
194
196
  check(
195
197
  "🔴 <papers>/.template/ is preferred for the file it holds, the package fills the rest",
196
198
  own.ok &&
@@ -51,20 +51,6 @@ process.exit(
51
51
  ],
52
52
  ],
53
53
  },
54
- {
55
- name: "the project's template is ignored",
56
- harness: HARNESS,
57
- expect: "is preferred for the file it holds",
58
- disables:
59
- "the override slot. A project with its own richer scorecard gets the package's every time",
60
- edits: [
61
- [
62
- SRC,
63
- " const [src, from] = existsSync(project)",
64
- " const [src, from] = false",
65
- ],
66
- ],
67
- },
68
54
  {
69
55
  name: "a leading dot is accepted",
70
56
  harness: HARNESS,
@@ -14,9 +14,12 @@ import {
14
14
  import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { afterEach, describe, expect, it } from "vitest";
17
+ import { existsSync } from "node:fs";
17
18
  import { newPaper, OVERRIDE_DIR } from "./new-paper.ts";
18
19
  import { parsePaperSettings } from "./paper-settings.ts";
19
- import { run } from "./cli.ts";
20
+ import { chooseVenue, run } from "./cli.ts";
21
+ import { shippedPresets } from "./presets.ts";
22
+ import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
20
23
 
21
24
  const dirs: string[] = [];
22
25
  afterEach(() => {
@@ -131,3 +134,171 @@ describe("paperlint lint — a paper with no venue preset chosen", () => {
131
134
  );
132
135
  });
133
136
  });
137
+
138
+ /** `paperlint new <args>` in a fresh project (not a terminal: vitest's stdin is not a TTY). */
139
+ async function newIn(args: string[], files: Record<string, string> = {}) {
140
+ const root = tmp();
141
+ const all = {
142
+ "package.json": JSON.stringify({ name: "c", private: true }),
143
+ ...files,
144
+ };
145
+ for (const [p, text] of Object.entries(all)) {
146
+ mkdirSync(join(root, p, ".."), { recursive: true });
147
+ writeFileSync(join(root, p), text);
148
+ }
149
+ const out: string[] = [];
150
+ const err: string[] = [];
151
+ const code = await run(["new", "demo", ...args], {
152
+ cwd: root,
153
+ log: (s: string) => out.push(s),
154
+ err: (s: string) => err.push(s),
155
+ });
156
+ const dir = join(root, "papers", "demo");
157
+ return {
158
+ code,
159
+ out: out.join("\n"),
160
+ err: err.join("\n"),
161
+ dir,
162
+ settings: () => settingsOf(dir),
163
+ };
164
+ }
165
+
166
+ describe("paperlint new --venue / --kind", () => {
167
+ const PRESET = `{
168
+ "extends": "paperlint:acm-sigconf",
169
+ "format": { "kinds": { "short": { "body_pages_max": 4 } } },
170
+ }
171
+ `;
172
+
173
+ it("a shipped venue and its kind → extends and kind in paperlint.json, lint resolves it", async () => {
174
+ const r = await newIn(["--venue", "agenticdev", "--kind", "short"]);
175
+ expect(r.settings()).toEqual({
176
+ extends: "paperlint:agenticdev",
177
+ kind: "short",
178
+ });
179
+ expect(r.out).not.toMatch(/pdf\/profile/);
180
+ expect(r.code).toBe(0);
181
+ });
182
+
183
+ it("🔴 a path is relative to where you run it, and written relative to the paper's file", async () => {
184
+ const r = await newIn(
185
+ ["--venue", "./venues/my-workshop.jsonc", "--kind", "short"],
186
+ {
187
+ "venues/my-workshop.jsonc": PRESET,
188
+ },
189
+ );
190
+ // Guards: written as typed, `./venues/…` would name papers/demo/venues/…, which does not exist.
191
+ expect(r.settings()).toEqual({
192
+ extends: "../../venues/my-workshop.jsonc",
193
+ kind: "short",
194
+ });
195
+ expect(r.out).not.toMatch(/pdf\/profile/);
196
+ expect(r.code).toBe(0);
197
+ });
198
+
199
+ it("an unknown venue exits 2, lists the shipped presets, and creates nothing", async () => {
200
+ const r = await newIn(["--venue", "icse"]);
201
+ expect(r.code).toBe(2);
202
+ // Guards: the list is read from the shipped presets, the same list lint prints.
203
+ expect(r.err).toContain(shippedPresets(packageVenuesDir()).join(", "));
204
+ expect(r.err).toMatch(/--venue icse: no such venue preset/);
205
+ expect(existsSync(r.dir)).toBe(false);
206
+ });
207
+ });
208
+
209
+ describe("paperlint new --venue / --kind — refusals and defaults", () => {
210
+ it.each([
211
+ [
212
+ "a kind the preset lacks",
213
+ ["--venue", "agenticdev", "--kind", "long"],
214
+ /`agenticdev` has no kind `long`; its kinds: short, full, demo/,
215
+ ],
216
+ ["a kind without a venue", ["--kind", "short"], /--kind needs --venue/],
217
+ [
218
+ "a kind for a preset with no kinds",
219
+ ["--venue", "acm-sigconf", "--kind", "short"],
220
+ /`acm-sigconf` has no kinds/,
221
+ ],
222
+ [
223
+ "a path that does not exist",
224
+ ["--venue", "./venues/nope.jsonc"],
225
+ /no such file .* relative to where you run the command/,
226
+ ],
227
+ ])("%s → exit 2, nothing created", async (_, args, message) => {
228
+ const r = await newIn(args);
229
+ expect(r.err).toMatch(message);
230
+ expect(r.code).toBe(2);
231
+ expect(existsSync(r.dir)).toBe(false);
232
+ });
233
+
234
+ it("a venue with kinds and no --kind: written, and new says what lint will report", async () => {
235
+ const r = await newIn(["--venue", "agenticdev"]);
236
+ expect(r.settings()).toEqual({ extends: "paperlint:agenticdev" });
237
+ expect(r.out).toMatch(
238
+ /kind: not set — `agenticdev` sets a page limit per kind/,
239
+ );
240
+ expect(r.out).toMatch(/pdf\/profile/);
241
+ });
242
+
243
+ it("without a terminal and no --venue: as before, and the hint names --venue", async () => {
244
+ const r = await newIn([]);
245
+ expect(r.settings()["extends"]).toBeNull();
246
+ expect(r.out).toMatch(/venue: none yet .*--venue <preset>/);
247
+ expect(r.code).toBe(0);
248
+ });
249
+
250
+ it("an existing paperlint.json is never overwritten: --venue is refused", async () => {
251
+ const r = await newIn(["--venue", "aisec"], {
252
+ "papers/demo/paperlint.json": '{ "extends": null }\n',
253
+ });
254
+ expect(r.code).toBe(2);
255
+ expect(r.err).toMatch(/already exists and is never overwritten/);
256
+ expect(r.settings()).toEqual({ extends: null });
257
+ });
258
+ });
259
+
260
+ describe("chooseVenue — on a terminal", () => {
261
+ const at = (answers: string[]) => {
262
+ const asked: string[] = [];
263
+ return {
264
+ asked,
265
+ opts: {
266
+ paperDir: "/p/papers/demo",
267
+ cwd: "/p",
268
+ interactive: true,
269
+ ask: async (q: string) => (asked.push(q), answers.shift() ?? ""),
270
+ },
271
+ };
272
+ };
273
+
274
+ it("asks the venue from the shipped list, then its kind", async () => {
275
+ const t = at(["agenticdev", "full"]);
276
+ const r = await chooseVenue({ venue: null, kind: null }, t.opts);
277
+ expect(r).toMatchObject({
278
+ ok: true,
279
+ value: { extends: "paperlint:agenticdev", kind: "full" },
280
+ });
281
+ expect(t.asked[0]).toContain(
282
+ [...shippedPresets(packageVenuesDir()), "none"].join(" / "),
283
+ );
284
+ expect(t.asked[1]).toMatch(/kind: short \/ full \/ demo \/ later/);
285
+ });
286
+
287
+ it("the default answer is none: no venue written", async () => {
288
+ const t = at([""]);
289
+ expect(await chooseVenue({ venue: null, kind: null }, t.opts)).toEqual({
290
+ ok: true,
291
+ value: null,
292
+ });
293
+ });
294
+
295
+ it("without a terminal nothing is asked", async () => {
296
+ const t = at(["agenticdev"]);
297
+ const r = await chooseVenue(
298
+ { venue: null, kind: null },
299
+ { ...t.opts, interactive: false },
300
+ );
301
+ expect(r).toEqual({ ok: true, value: null });
302
+ expect(t.asked).toEqual([]);
303
+ });
304
+ });
package/src/new-paper.ts CHANGED
@@ -98,12 +98,85 @@ function scorecardAndSource(dir: string, format: PaperFormat): string[] {
98
98
  return hasSource ? [STATUS_FILE] : [STATUS_FILE, SOURCE_FILE[format]];
99
99
  }
100
100
 
101
- // Documented in README.md#starting-a-paper — update it when this changes.
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
+
108
+ /**
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.
112
+ */
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;
169
+ }
170
+
171
+ // Documented in README.md#getting-started — update it when this changes.
102
172
  export function newPaper(
103
173
  papersRoot: string,
104
174
  name: string,
105
175
  format: PaperFormat,
106
- { packageTemplates = PACKAGE_TEMPLATES }: { packageTemplates?: string } = {},
176
+ {
177
+ packageTemplates = PACKAGE_TEMPLATES,
178
+ venue = null,
179
+ }: { packageTemplates?: string; venue?: VenueSetting | null } = {},
107
180
  ): NewPaperResult {
108
181
  const problem = nameProblem(name);
109
182
  if (problem) return { ok: false, reason: problem };
@@ -113,26 +186,20 @@ export function newPaper(
113
186
  return { ok: false, reason: `${dir} exists and is not a directory` };
114
187
 
115
188
  const files: FileOutcome[] = [];
116
- const toWrite: { file: string; text: string; from: "project" | "package" }[] =
117
- [];
189
+ const toWrite: Planned[] = [];
118
190
  for (const file of wantedFiles(dir, format)) {
119
191
  if (existsSync(join(dir, file))) {
120
192
  files.push({ file, status: "kept" });
121
193
  continue;
122
194
  }
123
- const project = join(papersRoot, OVERRIDE_DIR, file);
124
- const fromPackage = join(packageTemplates, file);
125
- const [src, from] = existsSync(project)
126
- ? [project, "project" as const]
127
- : [fromPackage, "package" as const];
128
- // A missing package template is a broken install, not a reason to write an empty file.
129
- if (!existsSync(src))
130
- return {
131
- ok: false,
132
- reason: `no template for ${file}: ${src} is missing`,
133
- };
134
- const text = readFileSync(src, "utf8").split("{{name}}").join(name);
135
- 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);
136
203
  }
137
204
  // Read every template before writing any file, so a missing one leaves no half-made folder.
138
205
  mkdirSync(dir, { recursive: true });
@@ -23,6 +23,7 @@ import { buildConfig, run } from "./cli.ts";
23
23
  import {
24
24
  narrowToOwners,
25
25
  ownedPatterns,
26
+ ownedScopes,
26
27
  ruleOwners,
27
28
  scopeToOwned,
28
29
  } from "./paper-files.ts";
@@ -160,6 +161,73 @@ describe("`rules` blocks reach only the files each rule is written for", () => {
160
161
  });
161
162
  });
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
+
163
231
  describe("`rules` blocks — where the rule lands", () => {
164
232
  it("on its own files and on no other", async () => {
165
233
  const root = project();
@@ -206,11 +274,48 @@ describe("paper-files — the pieces", () => {
206
274
  expect(ownedPatterns(own)).toEqual(["**/a.md", "**/b.md", "**/c.md"]);
207
275
  });
208
276
 
209
- it("a rule's owner is where its plugin is registered; a global plugin owns every file", () => {
277
+ it("a rule's owner is where its plugin is registered; a global plugin owns every OWNED file", () => {
210
278
  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"]);
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
+ });
214
319
  });
215
320
  });
216
321
 
@@ -249,7 +354,7 @@ describe("paper-files — narrowing a block", () => {
249
354
  },
250
355
  {
251
356
  basePath: "/x",
252
- files: ["papers/**"],
357
+ files: ["**/a.md", "**/b.md", "**/c.md"].map((o) => ["papers/**", o]),
253
358
  ignores: ["papers/old/**"],
254
359
  rules: { "g/any": "off" },
255
360
  },