paperlint 2.0.0 → 2.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 (207) hide show
  1. package/.github/workflows/ci.yml +4 -4
  2. package/CLAUDE.md +3 -3
  3. package/CONTRIBUTING.md +21 -0
  4. package/LICENSE +1 -1
  5. package/README.md +81 -24
  6. package/action.harness.mjs +10 -10
  7. package/action.mutations.mjs +3 -3
  8. package/action.yml +11 -11
  9. package/bin/{rpp.mjs → paperlint.mjs} +2 -2
  10. package/dist/adapters/banal/invocation.js +3 -3
  11. package/dist/adapters/banal/invocation.js.map +1 -1
  12. package/dist/adapters/banal/run.d.ts +1 -1
  13. package/dist/adapters/banal/run.js +1 -1
  14. package/dist/adapters/banal/run.js.map +1 -1
  15. package/dist/adapters/banal/settings.d.ts +4 -4
  16. package/dist/adapters/banal/settings.d.ts.map +1 -1
  17. package/dist/adapters/banal/settings.js +4 -4
  18. package/dist/adapters/banal/settings.js.map +1 -1
  19. package/dist/adapters/banal/xml.js +1 -1
  20. package/dist/adapters/banal/xml.js.map +1 -1
  21. package/dist/adapters/curl/download.io.d.ts.map +1 -1
  22. package/dist/adapters/curl/download.io.js +2 -2
  23. package/dist/adapters/curl/download.io.js.map +1 -1
  24. package/dist/build-engine.d.ts +1 -1
  25. package/dist/build-engine.d.ts.map +1 -1
  26. package/dist/build-engine.js +6 -3
  27. package/dist/build-engine.js.map +1 -1
  28. package/dist/build.d.ts +1 -1
  29. package/dist/build.d.ts.map +1 -1
  30. package/dist/build.js +5 -4
  31. package/dist/build.js.map +1 -1
  32. package/dist/cli.d.ts +29 -30
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +174 -98
  35. package/dist/cli.js.map +1 -1
  36. package/dist/doctor.d.ts.map +1 -1
  37. package/dist/doctor.js +21 -6
  38. package/dist/doctor.js.map +1 -1
  39. package/dist/engine.d.ts +2 -2
  40. package/dist/engine.d.ts.map +1 -1
  41. package/dist/engine.js +1 -1
  42. package/dist/facts-file.d.ts +33 -9
  43. package/dist/facts-file.d.ts.map +1 -1
  44. package/dist/facts-file.js +117 -17
  45. package/dist/facts-file.js.map +1 -1
  46. package/dist/hooks-settings.d.ts +0 -18
  47. package/dist/hooks-settings.d.ts.map +1 -1
  48. package/dist/hooks-settings.js +70 -42
  49. package/dist/hooks-settings.js.map +1 -1
  50. package/dist/init.d.ts +13 -10
  51. package/dist/init.d.ts.map +1 -1
  52. package/dist/init.js +74 -42
  53. package/dist/init.js.map +1 -1
  54. package/dist/link-skills.js +2 -2
  55. package/dist/link-skills.js.map +1 -1
  56. package/dist/new-paper.d.ts +2 -2
  57. package/dist/new-paper.d.ts.map +1 -1
  58. package/dist/new-paper.js +17 -2
  59. package/dist/new-paper.js.map +1 -1
  60. package/dist/paper-settings.d.ts +68 -0
  61. package/dist/paper-settings.d.ts.map +1 -0
  62. package/dist/paper-settings.js +144 -0
  63. package/dist/paper-settings.js.map +1 -0
  64. package/dist/presets.d.ts +84 -0
  65. package/dist/presets.d.ts.map +1 -0
  66. package/dist/presets.js +186 -0
  67. package/dist/presets.js.map +1 -0
  68. package/dist/rules-config.d.ts +8 -1
  69. package/dist/rules-config.d.ts.map +1 -1
  70. package/dist/rules-config.js +13 -8
  71. package/dist/rules-config.js.map +1 -1
  72. package/dist/structure.d.ts.map +1 -1
  73. package/dist/structure.js +2 -1
  74. package/dist/structure.js.map +1 -1
  75. package/dist/tex-requirements.d.ts +63 -7
  76. package/dist/tex-requirements.d.ts.map +1 -1
  77. package/dist/tex-requirements.js +91 -24
  78. package/dist/tex-requirements.js.map +1 -1
  79. package/dist/toolchain.d.ts +2 -2
  80. package/dist/toolchain.d.ts.map +1 -1
  81. package/dist/toolchain.js +8 -8
  82. package/dist/toolchain.js.map +1 -1
  83. package/dist/types.d.ts +4 -4
  84. package/dist/types.d.ts.map +1 -1
  85. package/dist/venue-rules.d.ts +109 -0
  86. package/dist/venue-rules.d.ts.map +1 -0
  87. package/dist/venue-rules.js +387 -0
  88. package/dist/venue-rules.js.map +1 -0
  89. package/docs/configuration.md +73 -17
  90. package/docs/e2e.md +3 -4
  91. package/docs/install.md +11 -3
  92. package/docs/optional-rules.md +26 -25
  93. package/docs/rules.md +132 -3
  94. package/docs/toolchain.md +5 -5
  95. package/eslint-rules/paper-typography.mjs +1 -1
  96. package/eslint-rules/paper-typography.mutations.mjs +1 -1
  97. package/eslint-rules/papers.mjs +6 -2
  98. package/eslint-rules/pdf-last-page-balance.harness.mjs +5 -3
  99. package/eslint-rules/pdf-last-page-balance.mjs +23 -2
  100. package/fixtures/build-e2e/acmart/paperlint.json +1 -0
  101. package/fixtures/build-e2e/guards/paper.tex +1 -1
  102. package/fixtures/pdf-facts/README.md +1 -1
  103. package/fixtures/real-markdown-paper/baseline.json +1 -1
  104. package/fixtures/real-markdown-paper/baseline.mjs +2 -2
  105. package/fixtures/toolchain-mirror/install-tl +2 -2
  106. package/fixtures/toolchain-mirror/stub-pdflatex +1 -1
  107. package/fixtures/toolchain-mirror/stub-tlmgr +1 -1
  108. package/hooks/hooks.harness.mjs +1 -1
  109. package/lib/paper-config.d.mts +7 -0
  110. package/lib/paper-config.harness.mjs +3 -3
  111. package/lib/paper-config.mjs +34 -3
  112. package/lib/skill-trigger-cases.harness.mjs +1 -1
  113. package/package.json +4 -4
  114. package/plugin/hooks/hooks.json +3 -3
  115. package/scripts/check.mjs +1 -1
  116. package/scripts/harness-api.frozen.json +1 -1
  117. package/scripts/harness-api.test.ts +2 -2
  118. package/scripts/layer-legacy.frozen.json +1 -1
  119. package/scripts/mutation-batteries-frozen.harness.mjs +1 -1
  120. package/scripts/mutation-batteries.frozen.json +2 -2
  121. package/scripts/release-config.test.ts +1 -1
  122. package/skills/find-venue/SKILL.md +5 -1
  123. package/skills/find-venue/SKILL.md.spec.ts +4 -0
  124. package/skills/paper-pipeline/PIPELINE-MAP.md +14 -4
  125. package/skills/paper-pipeline/SKILL.md +4 -3
  126. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -2
  127. package/skills/paper-pipeline/description-language.eval.mjs +1 -1
  128. package/skills/paper-pipeline/framing-vs-vocabulary.eval.mjs +1 -1
  129. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +2 -2
  130. package/skills/paper-pipeline/pipeline-firing.eval.mjs +1 -1
  131. package/skills/paper-pipeline/pipeline-language.eval.mjs +1 -1
  132. package/skills/paper-pipeline/scripts/consumer.d.mts +1 -0
  133. package/skills/paper-pipeline/scripts/consumer.harness.mjs +10 -4
  134. package/skills/paper-pipeline/scripts/consumer.mjs +8 -1
  135. package/skills/paper-pipeline/scripts/consumer.mutations.mjs +1 -1
  136. package/skills/render-paper/SKILL.md +3 -3
  137. package/skills/render-paper/SKILL.md.spec.ts +2 -2
  138. package/skills/render-paper/extract-pdf-facts.harness.mjs +6 -6
  139. package/skills/render-paper/extract-pdf-facts.mjs +4 -4
  140. package/skills/study-accepted-papers/SKILL.md +2 -1
  141. package/skills/study-accepted-papers/SKILL.md.spec.ts +1 -0
  142. package/skills/submit-paper/SKILL.md +9 -1
  143. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  144. package/skills/submit-paper/references/venues/acm-sigconf.jsonc +86 -0
  145. package/skills/submit-paper/references/venues/agenticdev.jsonc +34 -102
  146. package/skills/submit-paper/references/venues/aisec.jsonc +21 -82
  147. package/skills/submit-paper/references/venues/realm.jsonc +45 -43
  148. package/skills/submit-paper/references/venues/tex-base.jsonc +1 -1
  149. package/skills/submit-paper/references/venues/venue-profile.schema.json +120 -30
  150. package/src/adapters/banal/index.test.ts +1 -1
  151. package/src/adapters/banal/invocation.test.ts +3 -3
  152. package/src/adapters/banal/invocation.ts +3 -3
  153. package/src/adapters/banal/locate.test.ts +4 -4
  154. package/src/adapters/banal/run.ts +2 -2
  155. package/src/adapters/banal/settings.test.ts +12 -6
  156. package/src/adapters/banal/settings.ts +9 -5
  157. package/src/adapters/banal/xml.test.ts +1 -1
  158. package/src/adapters/banal/xml.ts +1 -1
  159. package/src/adapters/curl/download.io.ts +4 -2
  160. package/src/adapters/curl/download.test.ts +3 -1
  161. package/src/adapters/node/files.test.ts +1 -1
  162. package/src/adapters/node/process.test.ts +2 -2
  163. package/src/adapters/node/workspace.test.ts +4 -4
  164. package/src/build-engine.harness.mjs +2 -2
  165. package/src/build-engine.ts +11 -3
  166. package/src/build.harness.mjs +27 -22
  167. package/src/build.mutations.mjs +3 -2
  168. package/src/build.ts +6 -5
  169. package/src/cli.harness.mjs +72 -131
  170. package/src/cli.mutations.mjs +7 -32
  171. package/src/cli.ts +204 -121
  172. package/src/doctor.harness.mjs +9 -25
  173. package/src/doctor.ts +28 -5
  174. package/src/engine.harness.mjs +4 -2
  175. package/src/engine.ts +2 -2
  176. package/src/facts-file.test.ts +54 -7
  177. package/src/facts-file.ts +145 -24
  178. package/src/hooks-settings.harness.mjs +24 -15
  179. package/src/hooks-settings.mutations.mjs +2 -4
  180. package/src/hooks-settings.ts +92 -49
  181. package/src/init.ts +88 -52
  182. package/src/latex-log.harness.mjs +1 -1
  183. package/src/link-skills.harness.mjs +3 -1
  184. package/src/link-skills.mutations.mjs +1 -1
  185. package/src/link-skills.ts +2 -2
  186. package/src/new-paper.harness.mjs +10 -7
  187. package/src/new-paper.test.ts +145 -0
  188. package/src/new-paper.ts +22 -2
  189. package/src/paper-settings-commands.test.ts +311 -0
  190. package/src/paper-settings.test.ts +220 -0
  191. package/src/paper-settings.ts +206 -0
  192. package/src/pdf-facts.harness.mjs +1 -1
  193. package/src/presets.test.ts +236 -0
  194. package/src/presets.ts +307 -0
  195. package/src/rules-config.ts +14 -9
  196. package/src/structure.harness.mjs +4 -4
  197. package/src/structure.ts +2 -1
  198. package/src/tex-requirements.harness.mjs +12 -18
  199. package/src/tex-requirements.ts +179 -29
  200. package/src/toolchain.harness.mjs +34 -25
  201. package/src/toolchain.ts +14 -8
  202. package/src/types.ts +4 -4
  203. package/src/venue-rules.test.ts +545 -0
  204. package/src/venue-rules.ts +573 -0
  205. package/templates/paper/paperlint.json +4 -0
  206. package/fixtures/build-e2e/acmart/venue.json +0 -1
  207. package/plugin/.claude-plugin/plugin.json +0 -8
package/src/new-paper.ts CHANGED
@@ -25,6 +25,10 @@ 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
32
 
29
33
  export type PaperFormat = "tex" | "md";
30
34
  export const FORMATS: readonly PaperFormat[] = ["tex", "md"];
@@ -83,16 +87,32 @@ export type NewPaperResult =
83
87
  | { readonly ok: false; readonly reason: string };
84
88
 
85
89
  /**
86
- * The files a paper folder must end up with: the scorecard, plus a source in `format` — unless
87
- * it already has a source in EITHER format, in which case that one stands and nothing is added.
90
+ * The files a paper folder must end up with: the scorecard, a source in `format` — unless it
91
+ * already has a source in EITHER format, in which case that one stands — and `paperlint.json`.
88
92
  */
89
93
  export function wantedFiles(dir: string, format: PaperFormat): string[] {
94
+ return [...scorecardAndSource(dir, format), ...settingsFile(dir)];
95
+ }
96
+
97
+ function scorecardAndSource(dir: string, format: PaperFormat): string[] {
90
98
  const hasSource = Object.values(SOURCE_FILE).some((f) =>
91
99
  existsSync(join(dir, f)),
92
100
  );
93
101
  return hasSource ? [STATUS_FILE] : [STATUS_FILE, SOURCE_FILE[format]];
94
102
  }
95
103
 
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
+
96
116
  // Documented in README.md#starting-a-paper — update it when this changes.
97
117
  export function newPaper(
98
118
  papersRoot: string,
@@ -0,0 +1,311 @@
1
+ /**
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.
5
+ */
6
+ import {
7
+ existsSync,
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ rmSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { afterEach, describe, expect, it } from "vitest";
18
+ import { run, toolchainTex } from "./cli.ts";
19
+ import { migratePaperSettings } from "./init.ts";
20
+ import { doctor } from "./doctor.ts";
21
+
22
+ const dirs: string[] = [];
23
+ afterEach(() => {
24
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
25
+ });
26
+
27
+ /** A project with two papers, `a` and `b`, each with paper.tex and PIPELINE-STATUS.md. */
28
+ function project(files: Record<string, string> = {}): string {
29
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-settings-")));
30
+ dirs.push(root);
31
+ const all: Record<string, string> = {
32
+ "package.json": JSON.stringify({
33
+ name: "c",
34
+ private: true,
35
+ paperlint: { papersDir: "papers" },
36
+ }),
37
+ "papers/a/paper.tex":
38
+ "\\documentclass{article}\n\\begin{document}x\\end{document}\n",
39
+ "papers/a/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
40
+ "papers/b/paper.tex":
41
+ "\\documentclass{article}\n\\begin{document}x\\end{document}\n",
42
+ "papers/b/PIPELINE-STATUS.md": "---\nstages: []\n---\n",
43
+ ...files,
44
+ };
45
+ for (const [p, text] of Object.entries(all)) {
46
+ mkdirSync(join(root, p, ".."), { recursive: true });
47
+ writeFileSync(join(root, p), text);
48
+ }
49
+ return root;
50
+ }
51
+
52
+ async function lint(
53
+ root: string,
54
+ ): Promise<{ code: number; out: string; err: string }> {
55
+ const out: string[] = [];
56
+ const err: string[] = [];
57
+ const code = await run(["lint", "--json"], {
58
+ cwd: root,
59
+ log: (s: string) => out.push(s),
60
+ err: (s: string) => err.push(s),
61
+ });
62
+ return { code, out: out.join("\n"), err: err.join("\n") };
63
+ }
64
+
65
+ const rulesIn = (stdout: string, paper: string): string[] =>
66
+ (JSON.parse(stdout) as { filePath: string; messages: { ruleId: string }[] }[])
67
+ .filter((r) => r.filePath.endsWith(join(paper, "paper.tex")))
68
+ .flatMap((r) => r.messages.map((m) => m.ruleId));
69
+
70
+ describe("paperlint lint — `rules` in a paper's paperlint.json", () => {
71
+ it("turns an optional rule on for that paper alone", async () => {
72
+ const root = project({
73
+ "papers/a/paperlint.json": JSON.stringify({
74
+ rules: { "pdf/last-page-balance": "error" },
75
+ }),
76
+ });
77
+ const r = await lint(root);
78
+ // The rule is on for `a` (no facts file: it says so, loudly) and for `b` it does not exist.
79
+ expect(rulesIn(r.out, "a")).toContain("pdf/last-page-balance");
80
+ expect(rulesIn(r.out, "b")).not.toContain("pdf/last-page-balance");
81
+ });
82
+
83
+ it("the project's own `rules` blocks come after, so they win", async () => {
84
+ 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
+ },
97
+ }),
98
+ "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" },
103
+ }),
104
+ });
105
+ const r = await lint(root);
106
+ expect(rulesIn(r.out, "a")).not.toContain("pdf/last-page-balance");
107
+ expect(rulesIn(r.out, "b")).toContain("pdf/last-page-balance");
108
+ });
109
+
110
+ it.each([
111
+ [
112
+ "an unknown rule id",
113
+ { rules: { "pdf/no-such-rule": "error" } },
114
+ /not a rule paperlint ships/,
115
+ ],
116
+ ["an unknown key", { venu: "aisec" }, /unknown key "venu"/],
117
+ ])("refuses %s before linting, naming the file", async (_, settings, why) => {
118
+ const root = project({
119
+ "papers/a/paperlint.json": JSON.stringify(settings),
120
+ });
121
+ const r = await lint(root);
122
+ expect(r.code).toBe(2);
123
+ expect(r.err).toMatch(why);
124
+ expect(r.err).toMatch(/papers\/a\/paperlint\.json/);
125
+ });
126
+ });
127
+
128
+ describe("paperlint lint — the venue preset's rules", () => {
129
+ it("agenticdev's preset turns pdf/last-page-balance on for its paper alone", async () => {
130
+ const root = project({
131
+ "papers/a/paperlint.json": JSON.stringify({
132
+ extends: "paperlint:agenticdev",
133
+ kind: "short",
134
+ }),
135
+ });
136
+ const cfgOf = await import("./cli.ts").then((m) =>
137
+ m.paperRuleBlocks([join(root, "papers")]),
138
+ );
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
+ ]);
149
+ });
150
+
151
+ it("🔴 an unbuilt agenticdev paper: ONE warning (pdf/measured), and no balance error", async () => {
152
+ const root = project({
153
+ "papers/a/paperlint.json": JSON.stringify({
154
+ extends: "paperlint:agenticdev",
155
+ kind: "short",
156
+ }),
157
+ });
158
+ const r = await lint(root);
159
+ expect(r.code).toBe(0);
160
+ expect(rulesIn(r.out, "a")).toEqual(["pdf/measured"]);
161
+ });
162
+ });
163
+
164
+ describe("paperlint lint — the paper over its preset, and the project's own presets", () => {
165
+ it("the paper's own rules win over its preset's", async () => {
166
+ const root = project({
167
+ "papers/a/paperlint.json": JSON.stringify({
168
+ extends: "paperlint:agenticdev",
169
+ rules: { "pdf/last-page-balance": "off" },
170
+ }),
171
+ });
172
+ const blocks = await import("./cli.ts").then((m) =>
173
+ m.paperRuleBlocks([join(root, "papers")]),
174
+ );
175
+ expect(blocks.ok && blocks.value[0]?.rules).toEqual({
176
+ "pdf/last-page-balance": "off",
177
+ });
178
+ });
179
+
180
+ it("a project's own preset, by relative path: its rules apply, an unknown rule id is refused", async () => {
181
+ const root = project({
182
+ "venues/usenix-sec.jsonc": JSON.stringify({
183
+ extends: "paperlint:acm-sigconf",
184
+ rules: { "pdf/no-such-rule": "error" },
185
+ }),
186
+ "papers/a/paperlint.json": JSON.stringify({
187
+ extends: "../../venues/usenix-sec.jsonc",
188
+ }),
189
+ });
190
+ const r = await lint(root);
191
+ expect(r.code).toBe(2);
192
+ expect(r.err).toMatch(/usenix-sec\.jsonc.*not a rule paperlint ships/);
193
+ });
194
+
195
+ it("a typo in extends is a pdf/profile error listing the shipped presets", async () => {
196
+ const root = project({
197
+ "papers/a/paperlint.json": JSON.stringify({
198
+ extends: "paperlint:agenticdve",
199
+ }),
200
+ });
201
+ const r = await lint(root);
202
+ expect(r.code).toBe(1);
203
+ expect(r.out).toMatch(/acm-sigconf, agenticdev, aisec, realm/);
204
+ });
205
+ });
206
+
207
+ describe("paperlint toolchain — installs what the project's own presets need", () => {
208
+ it("the shipped union plus a relative preset's tex packages", () => {
209
+ const root = project({
210
+ "venues/usenix-sec.jsonc": JSON.stringify({
211
+ extends: "paperlint:acm-sigconf",
212
+ tex: { packages: { usenix: ["usenix.sty"] } },
213
+ }),
214
+ "papers/a/paperlint.json": JSON.stringify({
215
+ extends: "../../venues/usenix-sec.jsonc",
216
+ }),
217
+ });
218
+ const tex = toolchainTex(root);
219
+ expect(tex.packages["usenix"]).toEqual(["usenix.sty"]);
220
+ expect("acmart" in tex.packages).toBe(true);
221
+ expect("hyperref" in tex.packages).toBe(true);
222
+ });
223
+
224
+ it("without papers of its own: the shipped union alone", () => {
225
+ const tex = toolchainTex(project());
226
+ expect("usenix" in tex.packages).toBe(false);
227
+ expect("acmart" in tex.packages).toBe(true);
228
+ });
229
+ });
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
+ });
@@ -0,0 +1,220 @@
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.
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+ import { memoryFiles } from "./adapters/memory/index.ts";
8
+ import {
9
+ migrationOf,
10
+ paperRules,
11
+ parsePaperSettings,
12
+ readPaperSettings,
13
+ } from "./paper-settings.ts";
14
+
15
+ const PAPER = "/work/papers/p";
16
+ const SHIPPED = new Set([
17
+ "pdf/last-page-balance",
18
+ "pdf/profile",
19
+ "paper/typography",
20
+ ]);
21
+ const enc = (s: string) => new TextEncoder().encode(s);
22
+
23
+ describe("parsePaperSettings", () => {
24
+ it("reads extends, kind, pdf and rules; every absent field is null", () => {
25
+ expect(
26
+ parsePaperSettings({ extends: "paperlint:aisec", kind: "research" }),
27
+ ).toEqual({
28
+ ok: true,
29
+ value: {
30
+ extends: "paperlint:aisec",
31
+ kind: "research",
32
+ pdf: null,
33
+ rules: null,
34
+ },
35
+ });
36
+ expect(parsePaperSettings({})).toEqual({
37
+ ok: true,
38
+ value: { extends: null, kind: null, pdf: null, rules: null },
39
+ });
40
+ });
41
+
42
+ it("extends: null is valid and means no venue chosen yet — what `paperlint new` writes", () => {
43
+ expect(parsePaperSettings({ extends: null })).toEqual({
44
+ ok: true,
45
+ value: { extends: null, kind: null, pdf: null, rules: null },
46
+ });
47
+ });
48
+ });
49
+
50
+ describe("parsePaperSettings — optional keys", () => {
51
+ it("accepts $comment, JSON Schema's comment keyword, and ignores it", () => {
52
+ expect(
53
+ parsePaperSettings({ extends: "paperlint:aisec", $comment: "why" }).ok,
54
+ ).toBe(true);
55
+ });
56
+
57
+ it.each([
58
+ [
59
+ "an unknown key (a typo is not silent)",
60
+ { venu: "aisec" },
61
+ /unknown key "venu".*extends, kind, pdf, rules/,
62
+ ],
63
+ [
64
+ "the pre-2.1.0 comment key `_`",
65
+ { extends: "paperlint:aisec", _: "note" },
66
+ /unknown key "_"/,
67
+ ],
68
+ [
69
+ "an extends that is not a string",
70
+ { extends: 3 },
71
+ /"extends" must be a non-empty string/,
72
+ ],
73
+ [
74
+ "an empty extends",
75
+ { extends: "" },
76
+ /"extends" must be a non-empty string/,
77
+ ],
78
+ [
79
+ "rules that are not an object",
80
+ { rules: ["pdf/profile"] },
81
+ /"rules" must be an object/,
82
+ ],
83
+ ["not an object at all", ["aisec"], /must be a JSON object/],
84
+ ])("refuses %s", (_, json, why) => {
85
+ const r = parsePaperSettings(json);
86
+ expect(r.ok).toBe(false);
87
+ if (!r.ok) expect(r.error).toMatch(why);
88
+ });
89
+ });
90
+
91
+ describe("readPaperSettings", () => {
92
+ it("no file: null — the paper declares nothing", () => {
93
+ expect(readPaperSettings(memoryFiles(), PAPER)).toEqual({
94
+ ok: true,
95
+ value: null,
96
+ });
97
+ });
98
+
99
+ it("reads paperlint.json", () => {
100
+ const files = memoryFiles({
101
+ [`${PAPER}/paperlint.json`]: '{"extends":"paperlint:aisec"}',
102
+ });
103
+ const r = readPaperSettings(files, PAPER);
104
+ expect(r.ok && r.value?.extends).toBe("paperlint:aisec");
105
+ });
106
+
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
+ it("not JSON: broken, with the parser's reason", () => {
123
+ const files = memoryFiles({
124
+ [`${PAPER}/paperlint.json`]: "{ extends: aisec",
125
+ });
126
+ const r = readPaperSettings(files, PAPER);
127
+ expect(r.ok).toBe(false);
128
+ if (!r.ok) expect(r.error.kind).toBe("broken");
129
+ });
130
+ });
131
+
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
+ );
138
+
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);
164
+ });
165
+
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({
172
+ extends: "paperlint:aisec",
173
+ kind: "research",
174
+ pdf: "b.pdf",
175
+ $comment: "note",
176
+ });
177
+ });
178
+ });
179
+
180
+ describe("paperRules — `rules` in paperlint.json", () => {
181
+ const settings = (rules: Record<string, unknown> | null) => ({
182
+ extends: null,
183
+ kind: null,
184
+ pdf: null,
185
+ rules,
186
+ });
187
+
188
+ it("no rules: none", () => {
189
+ expect(paperRules(PAPER, settings(null), SHIPPED)).toEqual({
190
+ ok: true,
191
+ value: null,
192
+ });
193
+ });
194
+
195
+ it("known rules, parsed", () => {
196
+ expect(
197
+ paperRules(
198
+ PAPER,
199
+ settings({ "pdf/last-page-balance": "error" }),
200
+ SHIPPED,
201
+ ),
202
+ ).toEqual({ ok: true, value: { "pdf/last-page-balance": "error" } });
203
+ });
204
+
205
+ it.each([
206
+ [
207
+ "an unknown rule id",
208
+ { "pdf/no-such-rule": "error" },
209
+ /"pdf\/no-such-rule" is not a rule paperlint ships/,
210
+ ],
211
+ ["a bad severity", { "pdf/profile": "loud" }, /is not a severity/],
212
+ ])("refuses %s, naming the file", (_, rules, why) => {
213
+ const r = paperRules(PAPER, settings(rules), SHIPPED);
214
+ expect(r.ok).toBe(false);
215
+ if (!r.ok) {
216
+ expect(r.error).toMatch(why);
217
+ expect(r.error).toMatch(/paperlint\.json/);
218
+ }
219
+ });
220
+ });