paperlint 2.0.1 → 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 (226) hide show
  1. package/CLAUDE.md +6 -1
  2. package/CONTRIBUTING.md +21 -0
  3. package/README.md +231 -180
  4. package/action.yml +4 -6
  5. package/dist/adapters/references/index.d.ts +13 -0
  6. package/dist/adapters/references/index.d.ts.map +1 -0
  7. package/dist/adapters/references/index.js +55 -0
  8. package/dist/adapters/references/index.js.map +1 -0
  9. package/dist/adapters/references/reach.io.d.ts +3 -0
  10. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  11. package/dist/adapters/references/reach.io.js +14 -0
  12. package/dist/adapters/references/reach.io.js.map +1 -0
  13. package/dist/build-engine.d.ts.map +1 -1
  14. package/dist/build-engine.js +5 -2
  15. package/dist/build-engine.js.map +1 -1
  16. package/dist/build.d.ts +16 -1
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/build.js +40 -5
  19. package/dist/build.js.map +1 -1
  20. package/dist/cli.d.ts +37 -20
  21. package/dist/cli.d.ts.map +1 -1
  22. package/dist/cli.js +377 -251
  23. package/dist/cli.js.map +1 -1
  24. package/dist/doctor.d.ts.map +1 -1
  25. package/dist/doctor.js +37 -52
  26. package/dist/doctor.js.map +1 -1
  27. package/dist/facts-file.d.ts +33 -9
  28. package/dist/facts-file.d.ts.map +1 -1
  29. package/dist/facts-file.js +117 -17
  30. package/dist/facts-file.js.map +1 -1
  31. package/dist/hooks-settings.d.ts +0 -21
  32. package/dist/hooks-settings.d.ts.map +1 -1
  33. package/dist/hooks-settings.js +17 -135
  34. package/dist/hooks-settings.js.map +1 -1
  35. package/dist/init.d.ts +40 -19
  36. package/dist/init.d.ts.map +1 -1
  37. package/dist/init.js +144 -109
  38. package/dist/init.js.map +1 -1
  39. package/dist/link-skills.d.ts +1 -1
  40. package/dist/link-skills.d.ts.map +1 -1
  41. package/dist/link-skills.js +11 -29
  42. package/dist/link-skills.js.map +1 -1
  43. package/dist/new-paper.d.ts +2 -2
  44. package/dist/new-paper.d.ts.map +1 -1
  45. package/dist/new-paper.js +6 -2
  46. package/dist/new-paper.js.map +1 -1
  47. package/dist/paper-files.d.ts +63 -0
  48. package/dist/paper-files.d.ts.map +1 -0
  49. package/dist/paper-files.js +69 -0
  50. package/dist/paper-files.js.map +1 -0
  51. package/dist/paper-settings.d.ts +40 -0
  52. package/dist/paper-settings.d.ts.map +1 -0
  53. package/dist/paper-settings.js +144 -0
  54. package/dist/paper-settings.js.map +1 -0
  55. package/dist/ports/check-references.d.ts +31 -0
  56. package/dist/ports/check-references.d.ts.map +1 -0
  57. package/dist/ports/check-references.js +7 -0
  58. package/dist/ports/check-references.js.map +1 -0
  59. package/dist/presets.d.ts +84 -0
  60. package/dist/presets.d.ts.map +1 -0
  61. package/dist/presets.js +184 -0
  62. package/dist/presets.js.map +1 -0
  63. package/dist/reference-rules.d.ts +47 -0
  64. package/dist/reference-rules.d.ts.map +1 -0
  65. package/dist/reference-rules.js +156 -0
  66. package/dist/reference-rules.js.map +1 -0
  67. package/dist/references.d.ts +36 -0
  68. package/dist/references.d.ts.map +1 -0
  69. package/dist/references.js +91 -0
  70. package/dist/references.js.map +1 -0
  71. package/dist/rules-config.d.ts +11 -2
  72. package/dist/rules-config.d.ts.map +1 -1
  73. package/dist/rules-config.js +25 -11
  74. package/dist/rules-config.js.map +1 -1
  75. package/dist/structure.d.ts.map +1 -1
  76. package/dist/structure.js +3 -2
  77. package/dist/structure.js.map +1 -1
  78. package/dist/tex-requirements.d.ts +63 -7
  79. package/dist/tex-requirements.d.ts.map +1 -1
  80. package/dist/tex-requirements.js +91 -24
  81. package/dist/tex-requirements.js.map +1 -1
  82. package/dist/types.d.ts +20 -18
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/venue-rules.d.ts +109 -0
  85. package/dist/venue-rules.d.ts.map +1 -0
  86. package/dist/venue-rules.js +389 -0
  87. package/dist/venue-rules.js.map +1 -0
  88. package/docs/configuration.md +152 -77
  89. package/docs/e2e.md +1 -1
  90. package/docs/install.md +25 -40
  91. package/docs/optional-rules.md +26 -24
  92. package/docs/rules.md +181 -15
  93. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  94. package/eslint-rules/latex-language.mjs +29 -1
  95. package/eslint-rules/paper-stages.harness.mjs +1 -117
  96. package/eslint-rules/paper-stages.mjs +0 -120
  97. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  98. package/eslint-rules/paper-typography.mjs +267 -192
  99. package/eslint-rules/paper-typography.test.mjs +297 -0
  100. package/eslint-rules/papers.harness.mjs +8 -12
  101. package/eslint-rules/papers.mjs +17 -27
  102. package/eslint-rules/pdf-last-page-balance.mjs +23 -5
  103. package/eslint-rules/review-frontmatter.mjs +118 -0
  104. package/eslint-rules/review-frontmatter.schema.json +36 -0
  105. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  106. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  107. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  108. package/eslint.config.mjs +0 -15
  109. package/fixtures/build-e2e/acmart/paperlint.json +1 -0
  110. package/fixtures/real-markdown-paper/baseline.json +6 -7
  111. package/hooks/hooks.harness.mjs +39 -98
  112. package/hooks/hooks.mutations.mjs +6 -6
  113. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  114. package/hooks/paper-edit-guard.hook.mjs +31 -42
  115. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  116. package/hooks/paper-status-gates.hook.mjs +21 -19
  117. package/hooks/paper-status-gates.sh +8 -13
  118. package/lib/paper-config.d.mts +15 -18
  119. package/lib/paper-config.harness.mjs +18 -35
  120. package/lib/paper-config.mjs +90 -81
  121. package/lib/paper-config.mutations.mjs +2 -2
  122. package/lib/skill-checks.mjs +1 -1
  123. package/lib/skill-trigger-cases.harness.mjs +9 -8
  124. package/lib/skill-trigger-cases.mjs +7 -15
  125. package/package.json +1 -1
  126. package/scripts/eslint-report-guard.mjs +1 -1
  127. package/scripts/harness-api.frozen.json +0 -3
  128. package/scripts/mutation-batteries.frozen.json +4 -13
  129. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  130. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  131. package/skills/cold-read-diff/SKILL.md +20 -1
  132. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  133. package/skills/find-venue/SKILL.md +5 -1
  134. package/skills/find-venue/SKILL.md.spec.ts +4 -0
  135. package/skills/grade-paper-writing/SKILL.md +20 -1
  136. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  137. package/skills/paper-pipeline/PIPELINE-MAP.md +14 -4
  138. package/skills/paper-pipeline/SKILL.md +4 -3
  139. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -2
  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/extract-pdf-facts.harness.mjs +5 -5
  151. package/skills/render-paper/extract-pdf-facts.mjs +4 -4
  152. package/skills/render-paper/render-paper.harness.mjs +2 -2
  153. package/skills/study-accepted-papers/SKILL.md +2 -1
  154. package/skills/study-accepted-papers/SKILL.md.spec.ts +1 -0
  155. package/skills/submit-paper/SKILL.md +9 -1
  156. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  157. package/skills/submit-paper/references/venues/acm-sigconf.jsonc +86 -0
  158. package/skills/submit-paper/references/venues/agenticdev.jsonc +34 -102
  159. package/skills/submit-paper/references/venues/aisec.jsonc +21 -82
  160. package/skills/submit-paper/references/venues/realm.jsonc +45 -43
  161. package/skills/submit-paper/references/venues/tex-base.jsonc +1 -1
  162. package/skills/submit-paper/references/venues/venue-profile.schema.json +119 -29
  163. package/skills/verify-citations/SKILL.md +10 -1
  164. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  165. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  166. package/src/adapters/references/index.ts +102 -0
  167. package/src/adapters/references/reach.io.ts +12 -0
  168. package/src/build-engine.ts +10 -2
  169. package/src/build.harness.mjs +31 -22
  170. package/src/build.ts +65 -8
  171. package/src/cli.harness.mjs +151 -294
  172. package/src/cli.mutations.mjs +35 -44
  173. package/src/cli.ts +457 -288
  174. package/src/doctor.harness.mjs +41 -84
  175. package/src/doctor.mutations.mjs +9 -9
  176. package/src/doctor.ts +41 -61
  177. package/src/facts-file.test.ts +47 -7
  178. package/src/facts-file.ts +145 -24
  179. package/src/hooks-settings.harness.mjs +7 -92
  180. package/src/hooks-settings.mutations.mjs +2 -17
  181. package/src/hooks-settings.ts +18 -162
  182. package/src/init.test.ts +206 -0
  183. package/src/init.ts +193 -159
  184. package/src/link-skills.harness.mjs +1 -38
  185. package/src/link-skills.mutations.mjs +9 -5
  186. package/src/link-skills.ts +10 -34
  187. package/src/lint-fix.test.ts +120 -0
  188. package/src/new-paper.harness.mjs +9 -6
  189. package/src/new-paper.test.ts +133 -0
  190. package/src/new-paper.ts +7 -2
  191. package/src/paper-files.test.ts +290 -0
  192. package/src/paper-files.ts +144 -0
  193. package/src/paper-settings-commands.test.ts +274 -0
  194. package/src/paper-settings.test.ts +249 -0
  195. package/src/paper-settings.ts +208 -0
  196. package/src/ports/check-references.ts +28 -0
  197. package/src/presets.test.ts +236 -0
  198. package/src/presets.ts +297 -0
  199. package/src/reference-rules.ts +229 -0
  200. package/src/references.test.ts +209 -0
  201. package/src/references.ts +138 -0
  202. package/src/rules-config.harness.mjs +24 -8
  203. package/src/rules-config.ts +30 -13
  204. package/src/structure.harness.mjs +6 -4
  205. package/src/structure.ts +3 -2
  206. package/src/tex-requirements.harness.mjs +11 -17
  207. package/src/tex-requirements.ts +179 -29
  208. package/src/types.ts +30 -18
  209. package/src/venue-rules.test.ts +554 -0
  210. package/src/venue-rules.ts +571 -0
  211. package/templates/paper/paperlint.json +4 -0
  212. package/eslint-rules/doc-fields.harness.mjs +0 -336
  213. package/eslint-rules/doc-fields.mjs +0 -186
  214. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  215. package/eslint-rules/paper-typography.harness.mjs +0 -291
  216. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  217. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  218. package/eslint-rules/review-findings-cause.mjs +0 -135
  219. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  220. package/fixtures/build-e2e/acmart/venue.json +0 -1
  221. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  222. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  223. package/fixtures/review-findings-cause/clean.md +0 -17
  224. package/fixtures/review-findings-cause/defect.md +0 -14
  225. package/fixtures/review-findings-cause/old-debt.md +0 -14
  226. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -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
+ });
@@ -98,11 +98,11 @@ try {
98
98
  const papers = join(work, "papers");
99
99
  const r = newPaper(papers, "demo", "tex");
100
100
  check(
101
- "a fresh folder gets the scorecard and a .tex source, both from the package",
101
+ "a fresh folder gets the scorecard, a .tex source and paperlint.json, all from the package",
102
102
  r.ok &&
103
103
  r.fresh &&
104
104
  r.files.map((f) => `${f.file}:${f.status}:${f.from}`).join(" ") ===
105
- "PIPELINE-STATUS.md:created:package paper.tex:created:package",
105
+ "PIPELINE-STATUS.md:created:package paper.tex:created:package paperlint.json:created:package",
106
106
  );
107
107
  const status = read(papers, "demo", STATUS_FILE);
108
108
  const fm = front(status);
@@ -122,8 +122,10 @@ try {
122
122
  );
123
123
  const l = await lint(join(papers, "demo"));
124
124
  check(
125
- "🔴 `paperlint lint` passes the scaffold clean — the first run is green, not a missing-file error",
126
- l.code === 0 && /no findings/.test(l.text),
125
+ "🔴 `paperlint lint` passes the scaffold — exit 0, not a missing-file error — with the one warning that no venue is chosen yet",
126
+ l.code === 0 &&
127
+ /names no venue preset yet/.test(l.text) &&
128
+ /1 problem \(0 errors, 1 warning\)/.test(l.text),
127
129
  );
128
130
 
129
131
  // The format contract of the skills' checker: four sections, parsed.
@@ -171,9 +173,10 @@ try {
171
173
  );
172
174
  check(
173
175
  "wantedFiles asks for a source only when neither format is there",
174
- wantedFiles(join(papers, "old"), "tex").join() === STATUS_FILE &&
176
+ wantedFiles(join(papers, "old"), "tex").join() ===
177
+ `${STATUS_FILE},paperlint.json` &&
175
178
  wantedFiles(join(papers, "nowhere"), "md").join() ===
176
- `${STATUS_FILE},paper.md`,
179
+ `${STATUS_FILE},paper.md,paperlint.json`,
177
180
  );
178
181
  writeFileSync(join(papers, "afile"), "x");
179
182
  check(
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `paperlint new` writes `<paper>/paperlint.json` — from the package's template, or from the
3
+ * project's `<papers>/.template/` when it has one — with no venue chosen yet, which lint then names
4
+ * in one warning instead of staying silent.
5
+ */
6
+ import {
7
+ mkdirSync,
8
+ mkdtempSync,
9
+ readFileSync,
10
+ realpathSync,
11
+ rmSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { afterEach, describe, expect, it } from "vitest";
17
+ import { newPaper, OVERRIDE_DIR } from "./new-paper.ts";
18
+ import { parsePaperSettings } from "./paper-settings.ts";
19
+ import { run } from "./cli.ts";
20
+
21
+ const dirs: string[] = [];
22
+ afterEach(() => {
23
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
24
+ });
25
+ const tmp = () => {
26
+ const d = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-new-")));
27
+ dirs.push(d);
28
+ return d;
29
+ };
30
+ const settingsOf = (dir: string) =>
31
+ JSON.parse(readFileSync(join(dir, "paperlint.json"), "utf8")) as Record<
32
+ string,
33
+ unknown
34
+ >;
35
+
36
+ describe("paperlint new — paperlint.json", () => {
37
+ it("is always written, from the package template: no venue chosen yet, and valid", () => {
38
+ const papers = join(tmp(), "papers");
39
+ const r = newPaper(papers, "demo", "tex");
40
+ expect(r.ok && r.files.find((f) => f.file === "paperlint.json")).toEqual({
41
+ file: "paperlint.json",
42
+ status: "created",
43
+ from: "package",
44
+ });
45
+ const s = settingsOf(join(papers, "demo"));
46
+ expect(s["extends"]).toBeNull();
47
+ expect(String(s["$comment"])).toMatch(/paperlint:agenticdev/);
48
+ expect(parsePaperSettings(s)).toEqual({
49
+ ok: true,
50
+ value: { extends: null, kind: null, pdf: null, rules: null },
51
+ });
52
+ });
53
+
54
+ it("comes from the project's <papers>/.template/ when it has one", () => {
55
+ const papers = join(tmp(), "papers");
56
+ mkdirSync(join(papers, OVERRIDE_DIR), { recursive: true });
57
+ writeFileSync(
58
+ join(papers, OVERRIDE_DIR, "paperlint.json"),
59
+ '{ "extends": "paperlint:aisec", "kind": "research" }\n',
60
+ );
61
+ const r = newPaper(papers, "house", "tex");
62
+ expect(r.ok && r.files.find((f) => f.file === "paperlint.json")?.from).toBe(
63
+ "project",
64
+ );
65
+ expect(settingsOf(join(papers, "house"))).toEqual({
66
+ extends: "paperlint:aisec",
67
+ kind: "research",
68
+ });
69
+ });
70
+
71
+ it("is never overwritten", () => {
72
+ const papers = join(tmp(), "papers");
73
+ mkdirSync(join(papers, "p"), { recursive: true });
74
+ writeFileSync(
75
+ join(papers, "p", "paperlint.json"),
76
+ '{"extends":"paperlint:aisec"}',
77
+ );
78
+ const r = newPaper(papers, "p", "tex");
79
+ expect(
80
+ r.ok && r.files.find((f) => f.file === "paperlint.json")?.status,
81
+ ).toBe("kept");
82
+ expect(settingsOf(join(papers, "p"))).toEqual({
83
+ extends: "paperlint:aisec",
84
+ });
85
+ });
86
+ });
87
+
88
+ describe("paperlint lint — a paper with no venue preset chosen", () => {
89
+ async function lintNew(extendsValue: string | null) {
90
+ const root = tmp();
91
+ writeFileSync(
92
+ join(root, "package.json"),
93
+ JSON.stringify({ name: "c", private: true }),
94
+ );
95
+ newPaper(join(root, "papers"), "demo", "tex");
96
+ if (extendsValue !== null)
97
+ writeFileSync(
98
+ join(root, "papers", "demo", "paperlint.json"),
99
+ JSON.stringify({ extends: extendsValue, kind: "short" }),
100
+ );
101
+ const out: string[] = [];
102
+ const code = await run(["lint", "--json"], {
103
+ cwd: root,
104
+ log: (s: string) => out.push(s),
105
+ err: () => {},
106
+ });
107
+ const messages = (
108
+ JSON.parse(out.join("\n")) as {
109
+ messages: { ruleId: string; severity: number; message: string }[];
110
+ }[]
111
+ ).flatMap((r) => r.messages);
112
+ return { code, messages };
113
+ }
114
+
115
+ it("gets exactly one warning naming the file to set, and exits 0", async () => {
116
+ const { code, messages } = await lintNew(null);
117
+ expect(code).toBe(0);
118
+ expect(messages).toHaveLength(1);
119
+ expect(messages[0]?.ruleId).toBe("pdf/measured");
120
+ expect(messages[0]?.severity).toBe(1);
121
+ expect(messages[0]?.message).toMatch(/names no venue preset yet/);
122
+ expect(messages[0]?.message).toMatch(
123
+ /set "extends" in .*papers\/demo\/paperlint\.json/,
124
+ );
125
+ });
126
+
127
+ it("a paper with a real extends does not get it", async () => {
128
+ const { messages } = await lintNew("paperlint:agenticdev");
129
+ expect(messages.map((m) => m.message).join("\n")).not.toMatch(
130
+ /names no venue preset yet/,
131
+ );
132
+ });
133
+ });
package/src/new-paper.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  /* eslint-enable boundaries/dependencies */
26
26
  import { join } from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
+ import { CONFIG_FILE } from "../lib/paper-config.mjs";
28
29
 
29
30
  export type PaperFormat = "tex" | "md";
30
31
  export const FORMATS: readonly PaperFormat[] = ["tex", "md"];
@@ -83,10 +84,14 @@ export type NewPaperResult =
83
84
  | { readonly ok: false; readonly reason: string };
84
85
 
85
86
  /**
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.
87
+ * The files a paper folder must end up with: the scorecard, a source in `format` — unless it
88
+ * already has a source in EITHER format, in which case that one stands — and `paperlint.json`.
88
89
  */
89
90
  export function wantedFiles(dir: string, format: PaperFormat): string[] {
91
+ return [...scorecardAndSource(dir, format), CONFIG_FILE];
92
+ }
93
+
94
+ function scorecardAndSource(dir: string, format: PaperFormat): string[] {
90
95
  const hasSource = Object.values(SOURCE_FILE).some((f) =>
91
96
  existsSync(join(dir, f)),
92
97
  );