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
@@ -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
+ }
@@ -1,13 +1,10 @@
1
1
  /**
2
2
  * `paperlint.json` through the commands, on a real directory: `paperlint lint` applies a paper's
3
- * `rules` to that paper alone and refuses an unknown rule id; `paperlint init` moves a pre-2.1.0
4
- * `venue.json` and refuses when both files exist and differ; `paperlint doctor` names a leftover.
3
+ * `rules` to that paper alone and refuses an unknown rule id.
5
4
  */
6
5
  import {
7
- existsSync,
8
6
  mkdirSync,
9
7
  mkdtempSync,
10
- readFileSync,
11
8
  realpathSync,
12
9
  rmSync,
13
10
  writeFileSync,
@@ -16,8 +13,6 @@ import { tmpdir } from "node:os";
16
13
  import { join } from "node:path";
17
14
  import { afterEach, describe, expect, it } from "vitest";
18
15
  import { run, toolchainTex } from "./cli.ts";
19
- import { migratePaperSettings } from "./init.ts";
20
- import { doctor } from "./doctor.ts";
21
16
 
22
17
  const dirs: string[] = [];
23
18
  afterEach(() => {
@@ -29,11 +24,7 @@ function project(files: Record<string, string> = {}): string {
29
24
  const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-settings-")));
30
25
  dirs.push(root);
31
26
  const all: Record<string, string> = {
32
- "package.json": JSON.stringify({
33
- name: "c",
34
- private: true,
35
- paperlint: { papersDir: "papers" },
36
- }),
27
+ "package.json": JSON.stringify({ name: "c", private: true }),
37
28
  "papers/a/paper.tex":
38
29
  "\\documentclass{article}\n\\begin{document}x\\end{document}\n",
39
30
  "papers/a/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
@@ -80,26 +71,18 @@ describe("paperlint lint — `rules` in a paper's paperlint.json", () => {
80
71
  expect(rulesIn(r.out, "b")).not.toContain("pdf/last-page-balance");
81
72
  });
82
73
 
83
- it("the project's own `rules` blocks come after, so they win", async () => {
74
+ it("🔴 the paper's own `rules` come after the root paperlint.json's, so they win", async () => {
84
75
  const root = project({
85
- "package.json": JSON.stringify({
86
- name: "c",
87
- private: true,
88
- paperlint: {
89
- papersDir: "papers",
90
- rules: [
91
- {
92
- files: ["papers/a/**"],
93
- rules: { "pdf/last-page-balance": "off" },
94
- },
95
- ],
96
- },
76
+ "paperlint.json": JSON.stringify({
77
+ rules: [
78
+ {
79
+ files: ["papers/*/**"],
80
+ rules: { "pdf/last-page-balance": "error" },
81
+ },
82
+ ],
97
83
  }),
98
84
  "papers/a/paperlint.json": JSON.stringify({
99
- rules: { "pdf/last-page-balance": "error" },
100
- }),
101
- "papers/b/paperlint.json": JSON.stringify({
102
- rules: { "pdf/last-page-balance": "error" },
85
+ rules: { "pdf/last-page-balance": "off" },
103
86
  }),
104
87
  });
105
88
  const r = await lint(root);
@@ -125,6 +108,61 @@ describe("paperlint lint — `rules` in a paper's paperlint.json", () => {
125
108
  });
126
109
  });
127
110
 
111
+ describe("papersDir — optional, `papers` by default", () => {
112
+ it("no paperlint.json, papers/x/paper.tex: that paper is linted", async () => {
113
+ const r = await lint(project());
114
+ expect(r.code).toBe(0);
115
+ expect(
116
+ (JSON.parse(r.out) as { filePath: string }[]).map((f) =>
117
+ f.filePath.slice(f.filePath.indexOf("papers/")),
118
+ ),
119
+ ).toEqual(
120
+ expect.arrayContaining(["papers/a/paper.tex", "papers/b/paper.tex"]),
121
+ );
122
+ });
123
+
124
+ it("🔴 no paperlint.json and no papers/: exit 2 and the one clear sentence", async () => {
125
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-empty-")));
126
+ dirs.push(root);
127
+ writeFileSync(join(root, "package.json"), "{}");
128
+ const r = await lint(root);
129
+ expect(r.code).toBe(2);
130
+ expect(r.err).toBe(
131
+ 'no papers in papers/ — create one with `npx paperlint new <name>`, or set "papersDir" in paperlint.json if your papers live elsewhere',
132
+ );
133
+ });
134
+
135
+ it("an explicit papersDir is honored", async () => {
136
+ const root = project({
137
+ "paperlint.json": JSON.stringify({ papersDir: "docs/drafts" }),
138
+ "docs/drafts/c/paper.tex":
139
+ "\\documentclass{article}\n\\begin{document}x\\end{document}\n",
140
+ "docs/drafts/c/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
141
+ });
142
+ const r = await lint(root);
143
+ const linted = (JSON.parse(r.out) as { filePath: string }[]).map((f) =>
144
+ f.filePath.slice(root.length + 1),
145
+ );
146
+ expect(linted).toContain("docs/drafts/c/paper.tex");
147
+ expect(linted.some((f) => f.startsWith("papers/"))).toBe(false);
148
+ });
149
+
150
+ it("run from inside a paper, the root file is still found and papersDir is relative to it", async () => {
151
+ const root = project({
152
+ "paperlint.json": JSON.stringify({ papersDir: "papers" }),
153
+ "papers/a/paperlint.json": JSON.stringify({ kind: "short" }),
154
+ });
155
+ const out: string[] = [];
156
+ const code = await run(["lint", "--json"], {
157
+ cwd: join(root, "papers/a"),
158
+ log: (x: string) => out.push(x),
159
+ err: () => {},
160
+ });
161
+ expect(code).toBe(0);
162
+ expect(out.join("\n")).toMatch(/papers\/b\/paper\.tex/);
163
+ });
164
+ });
165
+
128
166
  describe("paperlint lint — the venue preset's rules", () => {
129
167
  it("agenticdev's preset turns pdf/last-page-balance on for its paper alone", async () => {
130
168
  const root = project({
@@ -136,16 +174,19 @@ describe("paperlint lint — the venue preset's rules", () => {
136
174
  const cfgOf = await import("./cli.ts").then((m) =>
137
175
  m.paperRuleBlocks([join(root, "papers")]),
138
176
  );
139
- expect(cfgOf.ok && cfgOf.value).toEqual([
140
- {
141
- basePath: join(root, "papers/a"),
142
- files: expect.arrayContaining([
143
- "**/paper.tex",
144
- "**/PIPELINE-STATUS.md",
145
- ]),
146
- rules: { "pdf/last-page-balance": ["error", { tolerancePt: 120 }] },
147
- },
148
- ]);
177
+ expect(cfgOf.ok && cfgOf.value).toEqual({
178
+ preset: [
179
+ {
180
+ basePath: join(root, "papers/a"),
181
+ files: expect.arrayContaining([
182
+ "**/paper.tex",
183
+ "**/PIPELINE-STATUS.md",
184
+ ]),
185
+ rules: { "pdf/last-page-balance": ["error", { tolerancePt: 120 }] },
186
+ },
187
+ ],
188
+ own: [],
189
+ });
149
190
  });
150
191
 
151
192
  it("🔴 an unbuilt agenticdev paper: ONE warning (pdf/measured), and no balance error", async () => {
@@ -172,7 +213,11 @@ describe("paperlint lint — the paper over its preset, and the project's own pr
172
213
  const blocks = await import("./cli.ts").then((m) =>
173
214
  m.paperRuleBlocks([join(root, "papers")]),
174
215
  );
175
- expect(blocks.ok && blocks.value[0]?.rules).toEqual({
216
+ // The preset's block comes first and the paper's own after it: the later block wins.
217
+ expect(blocks.ok && blocks.value.preset[0]?.rules).toEqual({
218
+ "pdf/last-page-balance": ["error", { tolerancePt: 120 }],
219
+ });
220
+ expect(blocks.ok && blocks.value.own[0]?.rules).toEqual({
176
221
  "pdf/last-page-balance": "off",
177
222
  });
178
223
  });
@@ -227,85 +272,3 @@ describe("paperlint toolchain — installs what the project's own presets need",
227
272
  expect("acmart" in tex.packages).toBe(true);
228
273
  });
229
274
  });
230
-
231
- describe("paperlint init — moves venue.json to paperlint.json", () => {
232
- const legacy = '{ "venue": "aisec", "kind": "research" }\n';
233
-
234
- it('moves it, "venue" becoming "extends": "paperlint:<name>", and says so', () => {
235
- const root = project({ "papers/a/venue.json": legacy });
236
- const r = migratePaperSettings(join(root, "papers"));
237
- expect(r.code).toBe(0);
238
- expect(existsSync(join(root, "papers/a/venue.json"))).toBe(false);
239
- expect(
240
- JSON.parse(readFileSync(join(root, "papers/a/paperlint.json"), "utf8")),
241
- ).toEqual({ extends: "paperlint:aisec", kind: "research" });
242
- expect(r.lines.join("\n")).toMatch(/a\/venue\.json → a\/paperlint\.json/);
243
- });
244
-
245
- it("both, with the same content: the leftover is removed", () => {
246
- const root = project({
247
- "papers/a/venue.json": legacy,
248
- "papers/a/paperlint.json":
249
- '{"extends":"paperlint:aisec","kind":"research"}',
250
- });
251
- expect(migratePaperSettings(join(root, "papers")).code).toBe(0);
252
- expect(existsSync(join(root, "papers/a/venue.json"))).toBe(false);
253
- });
254
-
255
- it("🔴 both, and they differ: refused, naming both, and nothing is touched", () => {
256
- const root = project({
257
- "papers/a/venue.json": legacy,
258
- "papers/a/paperlint.json": '{"venue":"realm"}',
259
- });
260
- const r = migratePaperSettings(join(root, "papers"));
261
- expect(r.code).toBe(2);
262
- expect(r.lines.join("\n")).toMatch(/a\/venue\.json.*a\/paperlint\.json/);
263
- expect(readFileSync(join(root, "papers/a/venue.json"), "utf8")).toBe(
264
- legacy,
265
- );
266
- expect(readFileSync(join(root, "papers/a/paperlint.json"), "utf8")).toBe(
267
- '{"venue":"realm"}',
268
- );
269
- });
270
-
271
- it("no venue.json anywhere: nothing to say", () => {
272
- const root = project();
273
- expect(migratePaperSettings(join(root, "papers"))).toEqual({
274
- code: 0,
275
- lines: [],
276
- });
277
- });
278
- });
279
-
280
- describe("paperlint doctor — names a leftover venue.json", () => {
281
- it("✗ with the command that moves it, and a failing exit", () => {
282
- const root = project({ "papers/a/venue.json": '{"venue":"aisec"}' });
283
- const out: string[] = [];
284
- const code = doctor({
285
- log: (s: string) => out.push(s),
286
- cwd: root,
287
- projectDir: root,
288
- cliPapers: "papers",
289
- run: (() => ({ status: 0, stdout: "", stderr: "" })) as never,
290
- skillLinks: () => ({ ok: false, error: "not checked in this test" }),
291
- });
292
- const text = out.join("\n");
293
- expect(text).toMatch(/✗ papers\/a\/venue\.json is no longer read/);
294
- expect(text).toMatch(/npx paperlint init/);
295
- expect(code).not.toBe(0);
296
- });
297
-
298
- it("says nothing about it when there is none", () => {
299
- const root = project();
300
- const out: string[] = [];
301
- doctor({
302
- log: (s: string) => out.push(s),
303
- cwd: root,
304
- projectDir: root,
305
- cliPapers: "papers",
306
- run: (() => ({ status: 0, stdout: "", stderr: "" })) as never,
307
- skillLinks: () => ({ ok: false, error: "not checked in this test" }),
308
- });
309
- expect(out.join("\n")).not.toMatch(/venue\.json/);
310
- });
311
- });
@@ -1,12 +1,12 @@
1
1
  /**
2
- * `<paper>/paperlint.json` — the per-paper settings file (it was `venue.json` before 2.1.0): parsed
3
- * strictly, the old name never read, the move planned from bytes, and `rules` turned into an ESLint
4
- * block for that paper alone.
2
+ * `<paper>/paperlint.json` — the per-paper settings file: parsed strictly, merged over the root
3
+ * `paperlint.json`'s defaults, and `rules` turned into an ESLint block for that paper alone.
5
4
  */
6
5
  import { describe, expect, it } from "vitest";
7
6
  import { memoryFiles } from "./adapters/memory/index.ts";
7
+ import type { AbsolutePath } from "./domain/paths.ts";
8
+ import { findProjectRoot } from "../lib/paper-config.mjs";
8
9
  import {
9
- migrationOf,
10
10
  paperRules,
11
11
  parsePaperSettings,
12
12
  readPaperSettings,
@@ -16,9 +16,8 @@ const PAPER = "/work/papers/p";
16
16
  const SHIPPED = new Set([
17
17
  "pdf/last-page-balance",
18
18
  "pdf/profile",
19
- "paper/typography",
19
+ "paper/section-word",
20
20
  ]);
21
- const enc = (s: string) => new TextEncoder().encode(s);
22
21
 
23
22
  describe("parsePaperSettings", () => {
24
23
  it("reads extends, kind, pdf and rules; every absent field is null", () => {
@@ -58,10 +57,10 @@ describe("parsePaperSettings — optional keys", () => {
58
57
  [
59
58
  "an unknown key (a typo is not silent)",
60
59
  { venu: "aisec" },
61
- /unknown key "venu".*extends, kind, pdf, rules/,
60
+ /unknown key "venu" — known keys: papersDir, structure, rules, extends, kind, pdf/,
62
61
  ],
63
62
  [
64
- "the pre-2.1.0 comment key `_`",
63
+ "a comment key other than `$comment`",
65
64
  { extends: "paperlint:aisec", _: "note" },
66
65
  /unknown key "_"/,
67
66
  ],
@@ -76,10 +75,15 @@ describe("parsePaperSettings — optional keys", () => {
76
75
  /"extends" must be a non-empty string/,
77
76
  ],
78
77
  [
79
- "rules that are not an object",
80
- { rules: ["pdf/profile"] },
78
+ "rules that are neither an object nor a list of blocks",
79
+ { rules: "pdf/profile" },
81
80
  /"rules" must be an object/,
82
81
  ],
82
+ [
83
+ "🔴 papersDir — a project setting, refused in a paper's file",
84
+ { papersDir: "papers" },
85
+ /"papersDir" is a project setting — set it in the root paperlint\.json/,
86
+ ],
83
87
  ["not an object at all", ["aisec"], /must be a JSON object/],
84
88
  ])("refuses %s", (_, json, why) => {
85
89
  const r = parsePaperSettings(json);
@@ -104,21 +108,6 @@ describe("readPaperSettings", () => {
104
108
  expect(r.ok && r.value?.extends).toBe("paperlint:aisec");
105
109
  });
106
110
 
107
- it("🔴 a venue.json alone is NOT read — it is named, with the command that moves it", () => {
108
- const files = memoryFiles({ [`${PAPER}/venue.json`]: '{"venue":"aisec"}' });
109
- const r = readPaperSettings(files, PAPER);
110
- expect(r).toEqual({ ok: false, error: { kind: "legacy" } });
111
- });
112
-
113
- it("paperlint.json wins when both exist — the leftover is doctor's to report", () => {
114
- const files = memoryFiles({
115
- [`${PAPER}/venue.json`]: '{"venue":"realm"}',
116
- [`${PAPER}/paperlint.json`]: '{"extends":"paperlint:aisec"}',
117
- });
118
- const r = readPaperSettings(files, PAPER);
119
- expect(r.ok && r.value?.extends).toBe("paperlint:aisec");
120
- });
121
-
122
111
  it("not JSON: broken, with the parser's reason", () => {
123
112
  const files = memoryFiles({
124
113
  [`${PAPER}/paperlint.json`]: "{ extends: aisec",
@@ -129,52 +118,92 @@ describe("readPaperSettings", () => {
129
118
  });
130
119
  });
131
120
 
132
- describe("migrationOf — what `paperlint init` does with a paper's files", () => {
133
- const plan = (legacy: string | null, current: string | null) =>
134
- migrationOf(
135
- legacy === null ? null : enc(legacy),
136
- current === null ? null : enc(current),
137
- );
121
+ describe("readPaperSettings — the root paperlint.json's defaults, the paper's file over them", () => {
122
+ const ROOT = "/work";
123
+ const withFiles = (f: Record<string, string>) =>
124
+ memoryFiles({ [`${ROOT}/package.json`]: "{}", ...f });
125
+ const read = (f: Record<string, string>) => {
126
+ const r = readPaperSettings(withFiles(f), PAPER);
127
+ if (!r.ok) throw new Error(r.error.why);
128
+ return r.value;
129
+ };
130
+
131
+ it("no file at either level: null", () => {
132
+ expect(read({})).toBeNull();
133
+ });
138
134
 
139
- it.each([
140
- ["no venue.json", null, null, "none"],
141
- [
142
- "no venue.json, paperlint.json present",
143
- null,
144
- '{"extends":"paperlint:a"}',
145
- "none",
146
- ],
147
- ["venue.json only", '{"venue":"a"}', null, "move"],
148
- [
149
- "both, and paperlint.json already says the same (spacing differs)",
150
- '{"venue":"a","kind":"short"}',
151
- '{ "extends": "paperlint:a", "kind": "short" }\n',
152
- "drop-legacy",
153
- ],
154
- [
155
- "both, different",
156
- '{"venue":"a"}',
157
- '{"extends":"paperlint:b"}',
158
- "conflict",
159
- ],
160
- ["venue.json that is not JSON", "{", null, "broken"],
161
- ["venue.json that is not an object", "[]", null, "broken"],
162
- ])("%s → %s", (_, legacy, current, want) => {
163
- expect(plan(legacy, current).kind).toBe(want);
135
+ it("root only: its extends and kind are this paper's", () => {
136
+ expect(
137
+ read({
138
+ [`${ROOT}/paperlint.json`]:
139
+ '{"papersDir":"papers","extends":"paperlint:agenticdev","kind":"short"}',
140
+ }),
141
+ ).toEqual({
142
+ extends: "paperlint:agenticdev",
143
+ kind: "short",
144
+ pdf: null,
145
+ rules: null,
146
+ });
164
147
  });
165
148
 
166
- it('the move rewrites "venue": "x" to "extends": "paperlint:x", and "_" to "$comment"', () => {
167
- const r = plan(
168
- '{"venue":"aisec","kind":"research","pdf":"b.pdf","_":"note"}',
169
- null,
170
- );
171
- expect(r.kind === "move" && JSON.parse(r.text)).toEqual({
149
+ it("paper only: its own values", () => {
150
+ expect(
151
+ read({ [`${PAPER}/paperlint.json`]: '{"extends":"paperlint:aisec"}' }),
152
+ ).toEqual({
172
153
  extends: "paperlint:aisec",
154
+ kind: null,
155
+ pdf: null,
156
+ rules: null,
157
+ });
158
+ });
159
+
160
+ it("🔴 both: the paper's value wins, an absent one falls back to the root's, and `\"extends\": null` is absent", () => {
161
+ expect(
162
+ read({
163
+ [`${ROOT}/paperlint.json`]:
164
+ '{"extends":"paperlint:agenticdev","kind":"short","rules":{"pdf/profile":"off"}}',
165
+ [`${PAPER}/paperlint.json`]:
166
+ '{"extends":null,"kind":"research","rules":{"pdf/fonts":"off"}}',
167
+ }),
168
+ ).toEqual({
169
+ extends: "paperlint:agenticdev",
173
170
  kind: "research",
174
- pdf: "b.pdf",
175
- $comment: "note",
171
+ pdf: null,
172
+ // The root's rules are the project's blocks (cli.ts), not this paper's.
173
+ rules: { "pdf/fonts": "off" },
176
174
  });
177
175
  });
176
+
177
+ it("papersDir in a paper's file: broken, naming the root file as its place", () => {
178
+ const r = readPaperSettings(
179
+ withFiles({ [`${PAPER}/paperlint.json`]: '{"papersDir":"x"}' }),
180
+ PAPER,
181
+ );
182
+ expect(r.ok).toBe(false);
183
+ if (!r.ok) expect(r.error.why).toMatch(/project setting/);
184
+ });
185
+ });
186
+
187
+ describe("findProjectRoot — where the root paperlint.json is looked for", () => {
188
+ const ROOT = "/work";
189
+ const withFiles = (f: Record<string, string>) =>
190
+ memoryFiles({ [`${ROOT}/package.json`]: "{}", ...f });
191
+
192
+ it("🔴 from INSIDE a paper, the project root is not the paper: its paperlint.json sits beside paper.tex", () => {
193
+ const files = withFiles({
194
+ [`${PAPER}/paper.tex`]: "x",
195
+ [`${PAPER}/paperlint.json`]: '{"kind":"research"}',
196
+ [`${ROOT}/paperlint.json`]: '{"extends":"paperlint:aisec"}',
197
+ });
198
+ const isFile = (p: string) => files.isFile(p as AbsolutePath);
199
+ expect(findProjectRoot(PAPER, isFile)).toBe(ROOT);
200
+ // No root file anywhere: the package.json directory.
201
+ expect(findProjectRoot(PAPER, (p) => p === `${ROOT}/package.json`)).toBe(
202
+ ROOT,
203
+ );
204
+ // Neither: where the walk started.
205
+ expect(findProjectRoot(PAPER, () => false)).toBe(PAPER);
206
+ });
178
207
  });
179
208
 
180
209
  describe("paperRules — `rules` in paperlint.json", () => {