paperlint 2.1.0 → 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 (191) hide show
  1. package/CLAUDE.md +6 -1
  2. package/README.md +233 -224
  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.d.ts +15 -0
  13. package/dist/build.d.ts.map +1 -1
  14. package/dist/build.js +38 -4
  15. package/dist/build.js.map +1 -1
  16. package/dist/cli.d.ts +28 -25
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +277 -265
  19. package/dist/cli.js.map +1 -1
  20. package/dist/doctor.d.ts.map +1 -1
  21. package/dist/doctor.js +40 -72
  22. package/dist/doctor.js.map +1 -1
  23. package/dist/facts-file.d.ts +2 -2
  24. package/dist/facts-file.js +2 -2
  25. package/dist/hooks-settings.d.ts +0 -21
  26. package/dist/hooks-settings.d.ts.map +1 -1
  27. package/dist/hooks-settings.js +17 -135
  28. package/dist/hooks-settings.js.map +1 -1
  29. package/dist/init.d.ts +39 -29
  30. package/dist/init.d.ts.map +1 -1
  31. package/dist/init.js +143 -172
  32. package/dist/init.js.map +1 -1
  33. package/dist/link-skills.d.ts +1 -1
  34. package/dist/link-skills.d.ts.map +1 -1
  35. package/dist/link-skills.js +11 -29
  36. package/dist/link-skills.js.map +1 -1
  37. package/dist/new-paper.d.ts.map +1 -1
  38. package/dist/new-paper.js +2 -13
  39. package/dist/new-paper.js.map +1 -1
  40. package/dist/paper-files.d.ts +63 -0
  41. package/dist/paper-files.d.ts.map +1 -0
  42. package/dist/paper-files.js +69 -0
  43. package/dist/paper-files.js.map +1 -0
  44. package/dist/paper-settings.d.ts +13 -41
  45. package/dist/paper-settings.d.ts.map +1 -1
  46. package/dist/paper-settings.js +71 -71
  47. package/dist/paper-settings.js.map +1 -1
  48. package/dist/ports/check-references.d.ts +31 -0
  49. package/dist/ports/check-references.d.ts.map +1 -0
  50. package/dist/ports/check-references.js +7 -0
  51. package/dist/ports/check-references.js.map +1 -0
  52. package/dist/presets.d.ts +1 -1
  53. package/dist/presets.d.ts.map +1 -1
  54. package/dist/presets.js +4 -6
  55. package/dist/presets.js.map +1 -1
  56. package/dist/reference-rules.d.ts +47 -0
  57. package/dist/reference-rules.d.ts.map +1 -0
  58. package/dist/reference-rules.js +156 -0
  59. package/dist/reference-rules.js.map +1 -0
  60. package/dist/references.d.ts +36 -0
  61. package/dist/references.d.ts.map +1 -0
  62. package/dist/references.js +91 -0
  63. package/dist/references.js.map +1 -0
  64. package/dist/rules-config.d.ts +5 -3
  65. package/dist/rules-config.d.ts.map +1 -1
  66. package/dist/rules-config.js +15 -6
  67. package/dist/rules-config.js.map +1 -1
  68. package/dist/structure.js +3 -3
  69. package/dist/structure.js.map +1 -1
  70. package/dist/tex-requirements.js +2 -2
  71. package/dist/tex-requirements.js.map +1 -1
  72. package/dist/types.d.ts +20 -18
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/venue-rules.d.ts.map +1 -1
  75. package/dist/venue-rules.js +11 -9
  76. package/dist/venue-rules.js.map +1 -1
  77. package/docs/configuration.md +119 -104
  78. package/docs/e2e.md +1 -1
  79. package/docs/install.md +25 -40
  80. package/docs/optional-rules.md +5 -4
  81. package/docs/rules.md +60 -23
  82. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  83. package/eslint-rules/latex-language.mjs +29 -1
  84. package/eslint-rules/paper-stages.harness.mjs +1 -117
  85. package/eslint-rules/paper-stages.mjs +0 -120
  86. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  87. package/eslint-rules/paper-typography.mjs +267 -192
  88. package/eslint-rules/paper-typography.test.mjs +297 -0
  89. package/eslint-rules/papers.harness.mjs +8 -12
  90. package/eslint-rules/papers.mjs +17 -31
  91. package/eslint-rules/pdf-last-page-balance.mjs +4 -7
  92. package/eslint-rules/review-frontmatter.mjs +118 -0
  93. package/eslint-rules/review-frontmatter.schema.json +36 -0
  94. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  95. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  96. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  97. package/eslint.config.mjs +0 -15
  98. package/fixtures/real-markdown-paper/baseline.json +6 -7
  99. package/hooks/hooks.harness.mjs +39 -98
  100. package/hooks/hooks.mutations.mjs +6 -6
  101. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  102. package/hooks/paper-edit-guard.hook.mjs +31 -42
  103. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  104. package/hooks/paper-status-gates.hook.mjs +21 -19
  105. package/hooks/paper-status-gates.sh +8 -13
  106. package/lib/paper-config.d.mts +15 -25
  107. package/lib/paper-config.harness.mjs +18 -35
  108. package/lib/paper-config.mjs +83 -105
  109. package/lib/paper-config.mutations.mjs +2 -2
  110. package/lib/skill-checks.mjs +1 -1
  111. package/lib/skill-trigger-cases.harness.mjs +9 -8
  112. package/lib/skill-trigger-cases.mjs +7 -15
  113. package/package.json +1 -1
  114. package/scripts/eslint-report-guard.mjs +1 -1
  115. package/scripts/harness-api.frozen.json +0 -3
  116. package/scripts/mutation-batteries.frozen.json +4 -13
  117. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  118. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  119. package/skills/cold-read-diff/SKILL.md +20 -1
  120. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  121. package/skills/grade-paper-writing/SKILL.md +20 -1
  122. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  123. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  124. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  125. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  126. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  127. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  128. package/skills/plan-paper-timeline/SKILL.md +2 -2
  129. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  130. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  131. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  132. package/skills/render-paper/check-render.sh +2 -3
  133. package/skills/render-paper/render-paper.harness.mjs +2 -2
  134. package/skills/verify-citations/SKILL.md +10 -1
  135. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  136. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  137. package/src/adapters/references/index.ts +102 -0
  138. package/src/adapters/references/reach.io.ts +12 -0
  139. package/src/build.harness.mjs +8 -4
  140. package/src/build.ts +62 -6
  141. package/src/cli.harness.mjs +147 -292
  142. package/src/cli.mutations.mjs +35 -44
  143. package/src/cli.ts +344 -304
  144. package/src/doctor.harness.mjs +41 -84
  145. package/src/doctor.mutations.mjs +9 -9
  146. package/src/doctor.ts +44 -89
  147. package/src/facts-file.test.ts +0 -7
  148. package/src/facts-file.ts +2 -2
  149. package/src/hooks-settings.harness.mjs +7 -92
  150. package/src/hooks-settings.mutations.mjs +2 -17
  151. package/src/hooks-settings.ts +18 -162
  152. package/src/init.test.ts +206 -0
  153. package/src/init.ts +186 -230
  154. package/src/link-skills.harness.mjs +1 -38
  155. package/src/link-skills.mutations.mjs +9 -5
  156. package/src/link-skills.ts +10 -34
  157. package/src/lint-fix.test.ts +120 -0
  158. package/src/new-paper.test.ts +1 -13
  159. package/src/new-paper.ts +2 -17
  160. package/src/paper-files.test.ts +290 -0
  161. package/src/paper-files.ts +144 -0
  162. package/src/paper-settings-commands.test.ts +84 -121
  163. package/src/paper-settings.test.ts +93 -64
  164. package/src/paper-settings.ts +99 -97
  165. package/src/ports/check-references.ts +28 -0
  166. package/src/presets.ts +5 -15
  167. package/src/reference-rules.ts +229 -0
  168. package/src/references.test.ts +209 -0
  169. package/src/references.ts +138 -0
  170. package/src/rules-config.harness.mjs +24 -8
  171. package/src/rules-config.ts +19 -7
  172. package/src/structure.harness.mjs +3 -1
  173. package/src/structure.ts +3 -3
  174. package/src/tex-requirements.ts +2 -2
  175. package/src/types.ts +30 -18
  176. package/src/venue-rules.test.ts +20 -11
  177. package/src/venue-rules.ts +11 -13
  178. package/eslint-rules/doc-fields.harness.mjs +0 -336
  179. package/eslint-rules/doc-fields.mjs +0 -186
  180. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  181. package/eslint-rules/paper-typography.harness.mjs +0 -291
  182. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  183. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  184. package/eslint-rules/review-findings-cause.mjs +0 -135
  185. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  186. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  187. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  188. package/fixtures/review-findings-cause/clean.md +0 -17
  189. package/fixtures/review-findings-cause/defect.md +0 -14
  190. package/fixtures/review-findings-cause/old-debt.md +0 -14
  191. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -84,13 +84,13 @@ process.exit(
84
84
  edits: [
85
85
  [
86
86
  SRC,
87
- ' unlinkSync,\n} from "node:fs";',
88
- ' unlinkSync,\n rmSync,\n} from "node:fs";',
87
+ ' symlinkSync,\n} from "node:fs";',
88
+ ' symlinkSync,\n rmSync,\n} from "node:fs";',
89
89
  ],
90
90
  [
91
91
  SRC,
92
- " if (!write || !fixable) {",
93
- ' if (!write || seen.status === "present") {',
92
+ ' if (!write || seen.status !== "missing")',
93
+ ' if (!write || seen.status === "present")',
94
94
  ],
95
95
  [
96
96
  SRC,
@@ -123,7 +123,11 @@ process.exit(
123
123
  "the difference between looking and changing. `paperlint doctor` would quietly repair the " +
124
124
  "state it is supposed to report, so the report can never show the gap",
125
125
  edits: [
126
- [SRC, " if (!write || !fixable) {", " if (!fixable) {"],
126
+ [
127
+ SRC,
128
+ ' if (!write || seen.status !== "missing")',
129
+ ' if (seen.status !== "missing")',
130
+ ],
127
131
  [
128
132
  SRC,
129
133
  " if (write && shipped.names.length > 0) {",
@@ -34,15 +34,13 @@ import {
34
34
  readlinkSync,
35
35
  realpathSync,
36
36
  symlinkSync,
37
- unlinkSync,
38
37
  } from "node:fs";
39
38
  /* eslint-enable boundaries/dependencies */
40
39
  import { createRequire } from "node:module";
41
- import { dirname, join, relative, resolve, sep } from "node:path";
40
+ import { dirname, join, relative, resolve } from "node:path";
42
41
  import { pathToFileURL } from "node:url";
43
42
  import {
44
43
  installedSkills,
45
- LEGACY_PACKAGE_NAME,
46
44
  PACKAGE_NAME,
47
45
  SHIPPED_SKILLS_DIR,
48
46
  } from "../skills/paper-pipeline/scripts/consumer.mjs";
@@ -109,8 +107,7 @@ export function shippedSkills(
109
107
  }
110
108
  }
111
109
 
112
- export type LinkStatus =
113
- "created" | "replaced" | "present" | "missing" | "foreign";
110
+ export type LinkStatus = "created" | "present" | "missing" | "foreign";
114
111
 
115
112
  export interface SkillLink {
116
113
  readonly name: string;
@@ -129,27 +126,17 @@ export type LinkReport =
129
126
  }
130
127
  | { readonly ok: false; readonly error: string };
131
128
 
132
- /**
133
- * What occupies `entry`, judged against the directory it should lead to. A link spelled exactly as
134
- * an install under the package's old name wrote it (`legacyTarget`) is `stale`: ours, from before
135
- * the rename, and replaced on the next `init`.
136
- */
129
+ /** What occupies `entry`, judged against the directory it should lead to. */
137
130
  function inspect(
138
131
  entry: string,
139
132
  want: string,
140
- legacyTarget: string,
141
- ): { status: "present" | "missing" | "foreign" | "stale"; reason?: string } {
133
+ ): { status: "present" | "missing" | "foreign"; reason?: string } {
142
134
  let st;
143
135
  try {
144
136
  st = lstatSync(entry);
145
137
  } catch {
146
138
  return { status: "missing" };
147
139
  }
148
- if (st.isSymbolicLink() && readlinkSync(entry) === legacyTarget)
149
- return {
150
- status: "stale",
151
- reason: `a link into ${LEGACY_PACKAGE_NAME}, the package's old name — \`npx ${PACKAGE_NAME} init\` replaces it`,
152
- };
153
140
  try {
154
141
  if (realpathSync(entry) === want) return { status: "present" };
155
142
  } catch {
@@ -168,10 +155,7 @@ function inspect(
168
155
  };
169
156
  }
170
157
 
171
- /**
172
- * One skill: inspect its entry and, when writing, create the link — or replace one an install
173
- * under the package's old name made.
174
- */
158
+ /** One skill: inspect its entry and, when writing, create the link. */
175
159
  function linkOne(
176
160
  name: string,
177
161
  {
@@ -181,22 +165,14 @@ function linkOne(
181
165
  write,
182
166
  }: { entry: string; target: string; want: string; write: boolean },
183
167
  ): SkillLink {
184
- const legacyTarget = target.replace(
185
- `node_modules${sep}${PACKAGE_NAME}${sep}`,
186
- `node_modules${sep}${LEGACY_PACKAGE_NAME}${sep}`,
187
- );
188
- const seen = inspect(entry, want, legacyTarget);
189
- const fixable = seen.status === "missing" || seen.status === "stale";
190
- if (!write || !fixable) {
191
- const status = seen.status === "stale" ? "foreign" : seen.status;
168
+ const seen = inspect(entry, want);
169
+ if (!write || seen.status !== "missing")
192
170
  return seen.reason
193
- ? { name, status, reason: seen.reason }
194
- : { name, status };
195
- }
171
+ ? { name, status: seen.status, reason: seen.reason }
172
+ : { name, status: seen.status };
196
173
  try {
197
- if (seen.status === "stale") unlinkSync(entry);
198
174
  symlinkSync(target, entry, "dir");
199
- return { name, status: seen.status === "stale" ? "replaced" : "created" };
175
+ return { name, status: "created" };
200
176
  } catch (e) {
201
177
  return {
202
178
  name,
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `paperlint lint --fix`, and the settings that replaced the ratchets, through the command.
3
+ */
4
+ import {
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { afterEach, describe, expect, it } from "vitest";
15
+ import { run } from "./cli.ts";
16
+
17
+ const dirs: string[] = [];
18
+ afterEach(() => {
19
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
20
+ });
21
+
22
+ function project(
23
+ settings: Record<string, unknown>,
24
+ files: Record<string, string> = {},
25
+ ): string {
26
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-fix-")));
27
+ dirs.push(root);
28
+ const all: Record<string, string> = {
29
+ "package.json": JSON.stringify({ name: "c", private: true }),
30
+ "paperlint.json": JSON.stringify({ papersDir: "papers", ...settings }),
31
+ "papers/a/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
32
+ "papers/a/paper.md": "# Intro\n\nSee §5, §6 and §7; p < .05.\n",
33
+ ...files,
34
+ };
35
+ for (const [p, text] of Object.entries(all)) {
36
+ mkdirSync(join(root, p, ".."), { recursive: true });
37
+ writeFileSync(join(root, p), text);
38
+ }
39
+ return root;
40
+ }
41
+
42
+ async function lint(root: string, args: string[] = []) {
43
+ const out: string[] = [];
44
+ const err: string[] = [];
45
+ const code = await run(["lint", "--json", ...args], {
46
+ cwd: root,
47
+ log: (s: string) => out.push(s),
48
+ err: (s: string) => err.push(s),
49
+ });
50
+ return { code, out: out.join("\n"), err: err.join("\n") };
51
+ }
52
+ const ruleIds = (stdout: string): string[] =>
53
+ (JSON.parse(stdout) as { messages: { ruleId: string }[] }[]).flatMap((r) =>
54
+ r.messages.map((m) => m.ruleId),
55
+ );
56
+
57
+ describe("paperlint lint --fix", () => {
58
+ it("🔴 three `§` and a bare decimal → --fix → zero findings, and the file says Section", async () => {
59
+ const root = project({});
60
+ const before = ruleIds((await lint(root)).out);
61
+ expect(before.filter((r) => r === "paper/section-word")).toHaveLength(3);
62
+ expect(before).toContain("paper/leading-zero");
63
+ const fixed = await lint(root, ["--fix"]);
64
+ expect(ruleIds(fixed.out)).not.toContain("paper/section-word");
65
+ expect(ruleIds(fixed.out)).not.toContain("paper/leading-zero");
66
+ expect(readFileSync(join(root, "papers/a/paper.md"), "utf8")).toBe(
67
+ "# Intro\n\nSee Section 5, Section 6 and Section 7; p < 0.05.\n",
68
+ );
69
+ });
70
+
71
+ it("without --fix nothing is written", async () => {
72
+ const root = project({});
73
+ await lint(root);
74
+ expect(readFileSync(join(root, "papers/a/paper.md"), "utf8")).toMatch(/§5/);
75
+ });
76
+ });
77
+
78
+ describe("sibling cards: `read:` is required, from the package's own schema", () => {
79
+ it("a card without it is a warning; with it, silent; the index is not a card", async () => {
80
+ const root = project(
81
+ {},
82
+ {
83
+ "papers/a/siblings/README.md": "# Siblings\n",
84
+ "papers/a/siblings/smith2025.md": "# Smith 2025\n",
85
+ "papers/a/siblings/jones2024.md": "---\nread: abstract\n---\n# Jones\n",
86
+ },
87
+ );
88
+ const results = JSON.parse((await lint(root)).out) as {
89
+ filePath: string;
90
+ messages: { ruleId: string; severity: number }[];
91
+ }[];
92
+ const of = (name: string) =>
93
+ results.find((r) => r.filePath.endsWith(name))?.messages ?? null;
94
+ expect(of("smith2025.md")?.map((m) => [m.ruleId, m.severity])).toEqual([
95
+ ["sibling/frontmatter", 1],
96
+ ]);
97
+ expect(of("jones2024.md")).toEqual([]);
98
+ expect(of("siblings/README.md")).toBeNull();
99
+ });
100
+ });
101
+
102
+ describe("settings: what 3.0.0 removed is an ordinary unknown key or rule", () => {
103
+ it.each([
104
+ "typographyDebt",
105
+ "authorListCommand",
106
+ "docFields",
107
+ "causeMarker",
108
+ "reviewSchema",
109
+ ])("%s → the generic unknown-key error", async (key) => {
110
+ const r = await lint(project({ [key]: 1 }));
111
+ expect(r.code).toBe(2);
112
+ expect(r.err).toMatch(new RegExp(`unknown key "${key}"`));
113
+ });
114
+
115
+ it("paper/typography → not a rule paperlint ships", async () => {
116
+ const r = await lint(project({ rules: { "paper/typography": "off" } }));
117
+ expect(r.code).toBe(2);
118
+ expect(r.err).toMatch(/"paper\/typography" is not a rule paperlint ships/);
119
+ });
120
+ });
@@ -83,14 +83,6 @@ describe("paperlint new — paperlint.json", () => {
83
83
  extends: "paperlint:aisec",
84
84
  });
85
85
  });
86
-
87
- it("is not added beside a pre-2.1.0 venue.json — `paperlint init` moves that one", () => {
88
- const papers = join(tmp(), "papers");
89
- mkdirSync(join(papers, "old"), { recursive: true });
90
- writeFileSync(join(papers, "old", "venue.json"), '{"venue":"aisec"}');
91
- const r = newPaper(papers, "old", "tex");
92
- expect(r.ok && r.files.map((f) => f.file)).not.toContain("paperlint.json");
93
- });
94
86
  });
95
87
 
96
88
  describe("paperlint lint — a paper with no venue preset chosen", () => {
@@ -98,11 +90,7 @@ describe("paperlint lint — a paper with no venue preset chosen", () => {
98
90
  const root = tmp();
99
91
  writeFileSync(
100
92
  join(root, "package.json"),
101
- JSON.stringify({
102
- name: "c",
103
- private: true,
104
- paperlint: { papersDir: "papers" },
105
- }),
93
+ JSON.stringify({ name: "c", private: true }),
106
94
  );
107
95
  newPaper(join(root, "papers"), "demo", "tex");
108
96
  if (extendsValue !== null)
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,18 +98,6 @@ function scorecardAndSource(dir: string, format: PaperFormat): string[] {
101
98
  return hasSource ? [STATUS_FILE] : [STATUS_FILE, SOURCE_FILE[format]];
102
99
  }
103
100
 
104
- /**
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
- */
110
- function settingsFile(dir: string): string[] {
111
- return existsSync(join(dir, LEGACY_PAPER_SETTINGS_FILE))
112
- ? []
113
- : [PAPER_SETTINGS_FILE];
114
- }
115
-
116
101
  // Documented in README.md#starting-a-paper — update it when this changes.
117
102
  export function newPaper(
118
103
  papersRoot: string,
@@ -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
+ });