paperlint 2.0.1 → 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 (103) hide show
  1. package/CONTRIBUTING.md +21 -0
  2. package/README.md +56 -14
  3. package/dist/build-engine.d.ts.map +1 -1
  4. package/dist/build-engine.js +5 -2
  5. package/dist/build-engine.js.map +1 -1
  6. package/dist/build.d.ts +1 -1
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +4 -3
  9. package/dist/build.js.map +1 -1
  10. package/dist/cli.d.ts +15 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +138 -24
  13. package/dist/cli.js.map +1 -1
  14. package/dist/doctor.d.ts.map +1 -1
  15. package/dist/doctor.js +21 -4
  16. package/dist/doctor.js.map +1 -1
  17. package/dist/facts-file.d.ts +33 -9
  18. package/dist/facts-file.d.ts.map +1 -1
  19. package/dist/facts-file.js +117 -17
  20. package/dist/facts-file.js.map +1 -1
  21. package/dist/init.d.ts +11 -0
  22. package/dist/init.d.ts.map +1 -1
  23. package/dist/init.js +67 -3
  24. package/dist/init.js.map +1 -1
  25. package/dist/new-paper.d.ts +2 -2
  26. package/dist/new-paper.d.ts.map +1 -1
  27. package/dist/new-paper.js +17 -2
  28. package/dist/new-paper.js.map +1 -1
  29. package/dist/paper-settings.d.ts +68 -0
  30. package/dist/paper-settings.d.ts.map +1 -0
  31. package/dist/paper-settings.js +144 -0
  32. package/dist/paper-settings.js.map +1 -0
  33. package/dist/presets.d.ts +84 -0
  34. package/dist/presets.d.ts.map +1 -0
  35. package/dist/presets.js +186 -0
  36. package/dist/presets.js.map +1 -0
  37. package/dist/rules-config.d.ts +7 -0
  38. package/dist/rules-config.d.ts.map +1 -1
  39. package/dist/rules-config.js +11 -6
  40. package/dist/rules-config.js.map +1 -1
  41. package/dist/structure.d.ts.map +1 -1
  42. package/dist/structure.js +2 -1
  43. package/dist/structure.js.map +1 -1
  44. package/dist/tex-requirements.d.ts +63 -7
  45. package/dist/tex-requirements.d.ts.map +1 -1
  46. package/dist/tex-requirements.js +91 -24
  47. package/dist/tex-requirements.js.map +1 -1
  48. package/dist/venue-rules.d.ts +109 -0
  49. package/dist/venue-rules.d.ts.map +1 -0
  50. package/dist/venue-rules.js +387 -0
  51. package/dist/venue-rules.js.map +1 -0
  52. package/docs/configuration.md +67 -7
  53. package/docs/optional-rules.md +25 -24
  54. package/docs/rules.md +131 -2
  55. package/eslint-rules/papers.mjs +6 -2
  56. package/eslint-rules/pdf-last-page-balance.mjs +23 -2
  57. package/fixtures/build-e2e/acmart/paperlint.json +1 -0
  58. package/lib/paper-config.d.mts +7 -0
  59. package/lib/paper-config.mjs +31 -0
  60. package/package.json +1 -1
  61. package/skills/find-venue/SKILL.md +5 -1
  62. package/skills/find-venue/SKILL.md.spec.ts +4 -0
  63. package/skills/paper-pipeline/PIPELINE-MAP.md +14 -4
  64. package/skills/paper-pipeline/SKILL.md +4 -3
  65. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -2
  66. package/skills/render-paper/extract-pdf-facts.harness.mjs +5 -5
  67. package/skills/render-paper/extract-pdf-facts.mjs +4 -4
  68. package/skills/study-accepted-papers/SKILL.md +2 -1
  69. package/skills/study-accepted-papers/SKILL.md.spec.ts +1 -0
  70. package/skills/submit-paper/SKILL.md +9 -1
  71. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  72. package/skills/submit-paper/references/venues/acm-sigconf.jsonc +86 -0
  73. package/skills/submit-paper/references/venues/agenticdev.jsonc +34 -102
  74. package/skills/submit-paper/references/venues/aisec.jsonc +21 -82
  75. package/skills/submit-paper/references/venues/realm.jsonc +45 -43
  76. package/skills/submit-paper/references/venues/tex-base.jsonc +1 -1
  77. package/skills/submit-paper/references/venues/venue-profile.schema.json +119 -29
  78. package/src/build-engine.ts +10 -2
  79. package/src/build.harness.mjs +23 -18
  80. package/src/build.ts +5 -4
  81. package/src/cli.harness.mjs +4 -2
  82. package/src/cli.ts +158 -29
  83. package/src/doctor.ts +28 -3
  84. package/src/facts-file.test.ts +54 -7
  85. package/src/facts-file.ts +145 -24
  86. package/src/init.ts +81 -3
  87. package/src/new-paper.harness.mjs +9 -6
  88. package/src/new-paper.test.ts +145 -0
  89. package/src/new-paper.ts +22 -2
  90. package/src/paper-settings-commands.test.ts +311 -0
  91. package/src/paper-settings.test.ts +220 -0
  92. package/src/paper-settings.ts +206 -0
  93. package/src/presets.test.ts +236 -0
  94. package/src/presets.ts +307 -0
  95. package/src/rules-config.ts +12 -7
  96. package/src/structure.harness.mjs +3 -3
  97. package/src/structure.ts +2 -1
  98. package/src/tex-requirements.harness.mjs +11 -17
  99. package/src/tex-requirements.ts +179 -29
  100. package/src/venue-rules.test.ts +545 -0
  101. package/src/venue-rules.ts +573 -0
  102. package/templates/paper/paperlint.json +4 -0
  103. package/fixtures/build-e2e/acmart/venue.json +0 -1
package/src/facts-file.ts CHANGED
@@ -40,13 +40,16 @@ import { describeFailure, type PdfFacts, type PdfReader } from "./pdf-facts.ts";
40
40
  import {
41
41
  flatGeometry,
42
42
  type FactsGeometryFields,
43
+ type FlatGeometry,
43
44
  type Geometry,
44
45
  } from "./domain/geometry.ts";
45
- import { sha256Hex } from "./domain/sha256.ts";
46
+ import { parseSha256, sha256Hex, type Sha256 } from "./domain/sha256.ts";
46
47
  import type { MeasureGeometry } from "./ports/measure-geometry.ts";
47
48
  import type { AbsolutePath } from "./domain/paths.ts";
48
49
  import type { Files } from "./ports/files.ts";
49
50
  import { err, ok, type Result } from "./domain/result.ts";
51
+ import { paperPreset, paperPresetProblem } from "./presets.ts";
52
+ import { packageVenuesDir } from "../skills/paper-pipeline/scripts/consumer.mjs";
50
53
 
51
54
  export const FACTS_SCHEMA = 2;
52
55
  export const FACTS_DIR = "_build";
@@ -56,41 +59,42 @@ export const FACTS_FILE = "paper.facts.json";
56
59
  export const factsPath = (paperDir: string): string =>
57
60
  join(paperDir, FACTS_DIR, FACTS_FILE);
58
61
 
59
- /** The `venue.json` a paper declares: where it is submitted, as which kind, and where its PDF is. */
60
- export interface VenueDecl {
61
- readonly venue: string;
62
- readonly kind: string | null;
63
- readonly pdf: string | null;
64
- }
65
-
66
62
  /**
67
63
  * A path as the caller gave it. The `Files` adapter resolves a relative one against the process's
68
64
  * cwd, which is what these callers have always meant; the brand is not a claim this code checked it.
69
65
  */
70
66
  const at = (p: string): AbsolutePath => p as AbsolutePath;
71
67
 
72
- const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
73
-
74
- /** A parsed `venue.json`, or null when it names no venue. */
75
- export function parseVenueDecl(json: unknown): VenueDecl | null {
76
- const d = typeof json === "object" && json !== null ? json : {};
77
- const field = (k: string) => str((d as Record<string, unknown>)[k]);
78
- const venue = field("venue");
79
- return venue ? { venue, kind: field("kind"), pdf: field("pdf") } : null;
68
+ /** What the facts need from a paper's `paperlint.json`: its venue's label, its kind, where its PDF is. */
69
+ export interface VenueDecl {
70
+ /** The venue preset's display label (`agenticdev`), or null when the paper extends none. */
71
+ readonly label: string | null;
72
+ readonly kind: string | null;
73
+ readonly pdf: string | null;
80
74
  }
81
75
 
82
76
  /**
83
- * The paper's `venue.json`, or null when there is none or it names no venue. The one reader of
84
- * that file: the build, the facts writer and the shim all read it here.
77
+ * What a paper's `paperlint.json` declares, or null when it has none. THROWS with a one-line reason
78
+ * when the file does not parse, its `extends` does not resolve, or only the pre-2.1.0 `venue.json`
79
+ * is there: the build reports that as its `facts` failure rather than building against no venue.
85
80
  */
86
81
  export function declaredVenue(
87
82
  files: Files,
88
83
  paperDir: string,
84
+ venuesDir: string = packageVenuesDir(),
89
85
  ): VenueDecl | null {
90
- const bytes = files.readBytes(at(join(paperDir, "venue.json")));
91
- return bytes === null
92
- ? null
93
- : parseVenueDecl(JSON.parse(new TextDecoder().decode(bytes)));
86
+ const p = paperPreset(paperDir, { files, venuesDir });
87
+ const problem = paperPresetProblem(paperDir, p);
88
+ if (problem !== null) throw new Error(problem);
89
+ if (p.kind === "resolved")
90
+ return {
91
+ label: p.preset.label,
92
+ kind: p.settings.kind,
93
+ pdf: p.settings.pdf,
94
+ };
95
+ return p.kind === "none" && p.settings
96
+ ? { label: null, kind: p.settings.kind, pdf: p.settings.pdf }
97
+ : null;
94
98
  }
95
99
 
96
100
  // ── the document ────────────────────────────────────────────────────────────────────────
@@ -184,7 +188,7 @@ export interface MeasureOptions {
184
188
  readonly kind?: string | null;
185
189
  /** The page-geometry measurer (banal, as the composition root wired it). */
186
190
  readonly measure: MeasureGeometry;
187
- /** Where the PDF and `venue.json` are read from. */
191
+ /** Where the PDF and `paperlint.json` are read from. */
188
192
  readonly files: Files;
189
193
  }
190
194
 
@@ -215,7 +219,7 @@ export async function measurePaper(
215
219
  const facts = factsDocument({
216
220
  pdf: posix(relative(paperDir, pdf)),
217
221
  sha: sha256Hex(bytes),
218
- venue: o.venue ?? decl?.venue ?? null,
222
+ venue: o.venue ?? decl?.label ?? null,
219
223
  kind: o.kind ?? decl?.kind ?? null,
220
224
  read: r.facts,
221
225
  geometry,
@@ -239,3 +243,120 @@ export function writeFactsFile(
239
243
  );
240
244
  return out;
241
245
  }
246
+
247
+ // ── reading ─────────────────────────────────────────────────────────────────────────────
248
+
249
+ /** What a rule judges, read back from the facts file and typed once. */
250
+ export interface ReadFacts {
251
+ /** As the file spells it: relative to the paper directory, or absolute. */
252
+ readonly pdf: string;
253
+ readonly sha: Sha256;
254
+ readonly fonts: readonly FontEntry[];
255
+ /** The geometry columns, or null when no measurer ran (`geometry_source` is null). */
256
+ readonly geometry: FlatGeometry | null;
257
+ }
258
+
259
+ /** Why a facts file cannot be judged. */
260
+ export type FactsProblem =
261
+ | { readonly kind: "schema"; readonly got: string }
262
+ | { readonly kind: "broken"; readonly why: string };
263
+
264
+ const isRecord = (v: unknown): v is Readonly<Record<string, unknown>> =>
265
+ typeof v === "object" && v !== null && !Array.isArray(v);
266
+ const isNum = (v: unknown): v is number =>
267
+ typeof v === "number" && Number.isFinite(v);
268
+ const numOrNull = (v: unknown): v is number | null => v === null || isNum(v);
269
+
270
+ function fontOf(v: unknown): FontEntry | null {
271
+ if (!isRecord(v)) return null;
272
+ const { name, type, embedded, program } = v;
273
+ return typeof name === "string" &&
274
+ typeof type === "string" &&
275
+ typeof embedded === "boolean" &&
276
+ typeof program === "string"
277
+ ? { name, type, embedded, program }
278
+ : null;
279
+ }
280
+
281
+ const MAYBE_NUMBERS = [
282
+ "page_w_in",
283
+ "page_h_in",
284
+ "columns",
285
+ "body_pt",
286
+ "ref_pt",
287
+ ] as const;
288
+ const NUMBERS = ["body_pages", "ref_pages", "appendix_pages"] as const;
289
+
290
+ /** The geometry columns when a measurer ran; the shape error otherwise. */
291
+ function geometryOf(
292
+ d: Readonly<Record<string, unknown>>,
293
+ ): Result<FlatGeometry | null, string> {
294
+ if (d["geometry_source"] === null) return ok(null);
295
+ if (typeof d["geometry_source"] !== "string")
296
+ return err("`geometry_source` is neither a measurer's name nor null");
297
+ const bad =
298
+ MAYBE_NUMBERS.find((k) => !numOrNull(d[k])) ??
299
+ NUMBERS.find((k) => !isNum(d[k]));
300
+ if (bad) return err(`\`${bad}\` is not a number`);
301
+ const pagesByType = d["pages_by_type"];
302
+ if (!isRecord(pagesByType) || !Object.values(pagesByType).every(isNum))
303
+ return err("`pages_by_type` is not an object of page counts");
304
+ const n = (k: (typeof MAYBE_NUMBERS)[number]) => d[k] as number | null;
305
+ return ok({
306
+ page_w_in: n("page_w_in"),
307
+ page_h_in: n("page_h_in"),
308
+ columns: n("columns"),
309
+ body_pt: n("body_pt"),
310
+ ref_pt: n("ref_pt"),
311
+ body_pages: d["body_pages"] as number,
312
+ ref_pages: d["ref_pages"] as number,
313
+ appendix_pages: d["appendix_pages"] as number,
314
+ pages_by_type: pagesByType as Readonly<Record<string, number>>,
315
+ });
316
+ }
317
+
318
+ /** The PDF the facts describe: its path and its sha256. */
319
+ function artifactOf(
320
+ d: Readonly<Record<string, unknown>>,
321
+ ): Result<{ pdf: string; sha: Sha256 }, string> {
322
+ const pdf = d["pdf"];
323
+ if (typeof pdf !== "string" || pdf === "") return err("no `pdf` path");
324
+ try {
325
+ return ok({ pdf, sha: parseSha256(String(d["pdf_sha256"])) });
326
+ } catch {
327
+ return err("no `pdf_sha256` of 64 hex digits");
328
+ }
329
+ }
330
+
331
+ /**
332
+ * The facts file's text → what a rule judges, or why it cannot be judged. The one reader of the
333
+ * file in `src/`, beside its one writer: a field renamed here and not there is a compile error in
334
+ * this module, not a rule that silently reads `undefined`. Pure.
335
+ */
336
+ export function parseFactsText(text: string): Result<ReadFacts, FactsProblem> {
337
+ let d: unknown;
338
+ try {
339
+ d = JSON.parse(text);
340
+ } catch (e) {
341
+ return err({ kind: "broken", why: `not JSON (${(e as Error).message})` });
342
+ }
343
+ if (!isRecord(d)) return err({ kind: "broken", why: "not a JSON object" });
344
+ if (d["schema"] !== FACTS_SCHEMA)
345
+ return err({ kind: "schema", got: JSON.stringify(d["schema"] ?? null) });
346
+ const artifact = artifactOf(d);
347
+ if (!artifact.ok) return err({ kind: "broken", why: artifact.error });
348
+ const rawFonts = d["fonts"];
349
+ const fonts = Array.isArray(rawFonts) ? rawFonts.map(fontOf) : null;
350
+ if (fonts === null || fonts.some((f) => f === null))
351
+ return err({
352
+ kind: "broken",
353
+ why: "`fonts` is not a list of { name, type, embedded, program }",
354
+ });
355
+ const geometry = geometryOf(d);
356
+ if (!geometry.ok) return err({ kind: "broken", why: geometry.error });
357
+ return ok({
358
+ ...artifact.value,
359
+ fonts: fonts as FontEntry[],
360
+ geometry: geometry.value,
361
+ });
362
+ }
package/src/init.ts CHANGED
@@ -35,8 +35,14 @@
35
35
  * an answer nobody gave. So the non-interactive path takes the stated default and SAYS which
36
36
  * default it took and why nothing was asked, rather than pretending it asked.
37
37
  */
38
- // eslint-disable-next-line boundaries/dependencies -- legacy I/O, moves behind a port in #76
39
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
38
+ import {
39
+ existsSync,
40
+ mkdirSync,
41
+ readFileSync,
42
+ unlinkSync,
43
+ writeFileSync,
44
+ // eslint-disable-next-line boundaries/dependencies -- legacy I/O, moves behind a port in #76
45
+ } from "node:fs";
40
46
  // eslint-disable-next-line boundaries/dependencies -- legacy I/O, moves behind a port in #76
41
47
  import { spawnSync } from "node:child_process";
42
48
  import { dirname, join, relative, resolve } from "node:path";
@@ -66,10 +72,13 @@ import {
66
72
  CONFIG_KEY,
67
73
  DEFAULT_PAPERS_ROOT,
68
74
  LEGACY_CONFIG_KEY,
75
+ LEGACY_PAPER_SETTINGS_FILE,
69
76
  PAPERS_DIR_FIELD,
77
+ PAPER_SETTINGS_FILE,
70
78
  declaredSettings,
71
79
  renamedFieldMessage,
72
80
  } from "../lib/paper-config.mjs";
81
+ import { migrationOf } from "./paper-settings.ts";
73
82
 
74
83
  /** How the papers directory was arrived at. Printed, because a guess must not read as a fact. */
75
84
  export type PapersHow =
@@ -609,6 +618,73 @@ export interface InitOptions {
609
618
  }
610
619
 
611
620
  /** Reads one line from a real terminal. Kept out of `init` so the command stays testable. */
621
+ /**
622
+ * Move every paper's pre-2.1.0 `venue.json` to `paperlint.json`, the way the old package.json key
623
+ * is moved, and in the same step `"venue": "aisec"` becomes `"extends": "paperlint:aisec"`: written
624
+ * as `paperlint.json` when it is alone, removed when `paperlint.json` already says the same, and
625
+ * REFUSED — both files left as they are — when they differ, since there is no way to know which
626
+ * one the author means. `code` is 2 when anything was refused.
627
+ */
628
+ export function migratePaperSettings(papersAbs: string): {
629
+ readonly code: number;
630
+ readonly lines: readonly string[];
631
+ } {
632
+ const lines: string[] = [];
633
+ let code = 0;
634
+ for (const dir of papersIn(papersAbs, [
635
+ ...PAPER_MARKERS,
636
+ LEGACY_PAPER_SETTINGS_FILE,
637
+ ])) {
638
+ const r = migrateOne(dir, (p) => relative(papersAbs, p));
639
+ if (r === null) continue;
640
+ lines.push(r.line);
641
+ if (r.refused) code = 2;
642
+ }
643
+ return {
644
+ code,
645
+ lines: lines.length ? ["", "paper settings", ...lines] : [],
646
+ };
647
+ }
648
+
649
+ /** One paper's move: done, and the line that says so — or null when there is nothing to move. */
650
+ function migrateOne(
651
+ dir: string,
652
+ shown: (p: string) => string,
653
+ ): { readonly line: string; readonly refused: boolean } | null {
654
+ const [from, to] = [LEGACY_PAPER_SETTINGS_FILE, PAPER_SETTINGS_FILE].map(
655
+ (f) => join(dir, f),
656
+ ) as [string, string];
657
+ const read = (p: string) => (existsSync(p) ? readFileSync(p) : null);
658
+ const plan = migrationOf(read(from), read(to));
659
+ switch (plan.kind) {
660
+ case "none":
661
+ return null;
662
+ case "move":
663
+ writeFileSync(to, plan.text);
664
+ unlinkSync(from);
665
+ return {
666
+ line: ` ✓ ${shown(from)} → ${shown(to)} ("venue" is now "extends": "paperlint:<name>"; renamed in paperlint 2.1.0)`,
667
+ refused: false,
668
+ };
669
+ case "drop-legacy":
670
+ unlinkSync(from);
671
+ return {
672
+ line: ` ✓ ${shown(from)} removed — ${shown(to)} already says the same`,
673
+ refused: false,
674
+ };
675
+ case "conflict":
676
+ return {
677
+ line: ` ✗ ${shown(from)} and ${shown(to)} both exist and differ — nothing was moved. Keep ${PAPER_SETTINGS_FILE}, copy what you need from ${LEGACY_PAPER_SETTINGS_FILE} into it (its "venue": "x" is "extends": "paperlint:x"), delete ${LEGACY_PAPER_SETTINGS_FILE}, then run init again`,
678
+ refused: true,
679
+ };
680
+ case "broken":
681
+ return {
682
+ line: ` ✗ ${shown(from)} was not moved: ${plan.why}`,
683
+ refused: true,
684
+ };
685
+ }
686
+ }
687
+
612
688
  export async function askOnTerminal(question: string): Promise<string> {
613
689
  // eslint-disable-next-line boundaries/dependencies -- legacy I/O, moves behind a port in #76
614
690
  const { createInterface } = await import("node:readline/promises");
@@ -747,6 +823,8 @@ export async function init(
747
823
  // guessed directory while lint and the hooks keep reading the declared one.
748
824
  const papersDir =
749
825
  decl.status === "kept" ? (decl.papers as string) : choice.papers;
826
+ const moved = migratePaperSettings(resolve(root, papersDir));
827
+ for (const line of moved.lines) (moved.code ? err : log)(line);
750
828
 
751
829
  // ── 3. the skills, linked where Claude Code looks for them ─────────────────────────────
752
830
  for (const line of reportSkillLinks(link(root), here)) log(line);
@@ -854,5 +932,5 @@ export async function init(
854
932
  `doctor exits ${String(code)} — the install is NOT finished. The lines marked ✗ above say what is\n` +
855
933
  `left; re-run \`npx paperlint doctor\` once you have done them.`,
856
934
  );
857
- return paperCode !== 0 ? paperCode : code;
935
+ return paperCode || moved.code || code;
858
936
  }
@@ -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,145 @@
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
+ 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
+ });
95
+
96
+ describe("paperlint lint — a paper with no venue preset chosen", () => {
97
+ async function lintNew(extendsValue: string | null) {
98
+ const root = tmp();
99
+ writeFileSync(
100
+ join(root, "package.json"),
101
+ JSON.stringify({
102
+ name: "c",
103
+ private: true,
104
+ paperlint: { papersDir: "papers" },
105
+ }),
106
+ );
107
+ newPaper(join(root, "papers"), "demo", "tex");
108
+ if (extendsValue !== null)
109
+ writeFileSync(
110
+ join(root, "papers", "demo", "paperlint.json"),
111
+ JSON.stringify({ extends: extendsValue, kind: "short" }),
112
+ );
113
+ const out: string[] = [];
114
+ const code = await run(["lint", "--json"], {
115
+ cwd: root,
116
+ log: (s: string) => out.push(s),
117
+ err: () => {},
118
+ });
119
+ const messages = (
120
+ JSON.parse(out.join("\n")) as {
121
+ messages: { ruleId: string; severity: number; message: string }[];
122
+ }[]
123
+ ).flatMap((r) => r.messages);
124
+ return { code, messages };
125
+ }
126
+
127
+ it("gets exactly one warning naming the file to set, and exits 0", async () => {
128
+ const { code, messages } = await lintNew(null);
129
+ expect(code).toBe(0);
130
+ expect(messages).toHaveLength(1);
131
+ expect(messages[0]?.ruleId).toBe("pdf/measured");
132
+ expect(messages[0]?.severity).toBe(1);
133
+ expect(messages[0]?.message).toMatch(/names no venue preset yet/);
134
+ expect(messages[0]?.message).toMatch(
135
+ /set "extends" in .*papers\/demo\/paperlint\.json/,
136
+ );
137
+ });
138
+
139
+ it("a paper with a real extends does not get it", async () => {
140
+ const { messages } = await lintNew("paperlint:agenticdev");
141
+ expect(messages.map((m) => m.message).join("\n")).not.toMatch(
142
+ /names no venue preset yet/,
143
+ );
144
+ });
145
+ });
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,