paperlint 2.1.0 → 3.1.0

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