paperlint 9.0.0 → 10.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 (190) hide show
  1. package/.github/CLAUDE.md +22 -0
  2. package/.github/CLAUDE.md.spec.ts +20 -0
  3. package/CLAUDE.md +59 -515
  4. package/CONTRIBUTING.md +2 -2
  5. package/README.md +68 -128
  6. package/dist/CLAUDE.md.spec.d.ts +3 -0
  7. package/dist/CLAUDE.md.spec.d.ts.map +1 -0
  8. package/dist/CLAUDE.md.spec.js +62 -0
  9. package/dist/CLAUDE.md.spec.js.map +1 -0
  10. package/dist/build.d.ts +1 -1
  11. package/dist/build.js +2 -2
  12. package/dist/cli.d.ts +5 -1
  13. package/dist/cli.d.ts.map +1 -1
  14. package/dist/cli.js +52 -6
  15. package/dist/cli.js.map +1 -1
  16. package/dist/domain/layout-tree.d.ts +19 -0
  17. package/dist/domain/layout-tree.d.ts.map +1 -0
  18. package/dist/domain/layout-tree.js +32 -0
  19. package/dist/domain/layout-tree.js.map +1 -0
  20. package/dist/domain/lookup-cache.d.ts +2 -2
  21. package/dist/domain/lookup-cache.js +2 -2
  22. package/dist/domain/paper-layout.d.ts +81 -0
  23. package/dist/domain/paper-layout.d.ts.map +1 -0
  24. package/dist/domain/paper-layout.js +259 -0
  25. package/dist/domain/paper-layout.js.map +1 -0
  26. package/dist/domain/repro-layout.d.ts +80 -0
  27. package/dist/domain/repro-layout.d.ts.map +1 -0
  28. package/dist/domain/repro-layout.js +247 -0
  29. package/dist/domain/repro-layout.js.map +1 -0
  30. package/dist/domain/scaffold-layout.d.ts +28 -0
  31. package/dist/domain/scaffold-layout.d.ts.map +1 -0
  32. package/dist/domain/scaffold-layout.js +81 -0
  33. package/dist/domain/scaffold-layout.js.map +1 -0
  34. package/dist/eslint-rules/CLAUDE.md.spec.d.ts +3 -0
  35. package/dist/eslint-rules/CLAUDE.md.spec.d.ts.map +1 -0
  36. package/dist/eslint-rules/CLAUDE.md.spec.js +39 -0
  37. package/dist/eslint-rules/CLAUDE.md.spec.js.map +1 -0
  38. package/dist/eslint-rules/install-path-literals.d.ts +1 -1
  39. package/dist/eslint-rules/install-path-literals.js +4 -4
  40. package/dist/eslint-rules/install-path-literals.js.map +1 -1
  41. package/dist/eslint-rules/pdf-last-page-balance.js +1 -1
  42. package/dist/eslint-rules/prereg-coverage.d.ts +98 -0
  43. package/dist/eslint-rules/prereg-coverage.d.ts.map +1 -0
  44. package/dist/eslint-rules/prereg-coverage.js +274 -0
  45. package/dist/eslint-rules/prereg-coverage.js.map +1 -0
  46. package/dist/folder-layout-hook.d.ts +21 -0
  47. package/dist/folder-layout-hook.d.ts.map +1 -0
  48. package/dist/folder-layout-hook.js +117 -0
  49. package/dist/folder-layout-hook.js.map +1 -0
  50. package/dist/folder-layout-rule.d.ts +22 -0
  51. package/dist/folder-layout-rule.d.ts.map +1 -0
  52. package/dist/folder-layout-rule.js +107 -0
  53. package/dist/folder-layout-rule.js.map +1 -0
  54. package/dist/init.js +1 -1
  55. package/dist/init.js.map +1 -1
  56. package/dist/lib/agent-cli-version.js +1 -1
  57. package/dist/lib/agent-cli-version.js.map +1 -1
  58. package/dist/new-paper.d.ts +4 -1
  59. package/dist/new-paper.d.ts.map +1 -1
  60. package/dist/new-paper.js +40 -6
  61. package/dist/new-paper.js.map +1 -1
  62. package/dist/references.d.ts +1 -1
  63. package/dist/references.d.ts.map +1 -1
  64. package/dist/references.js +2 -2
  65. package/dist/references.js.map +1 -1
  66. package/dist/repro-layout-rule.d.ts +16 -0
  67. package/dist/repro-layout-rule.d.ts.map +1 -0
  68. package/dist/repro-layout-rule.js +81 -0
  69. package/dist/repro-layout-rule.js.map +1 -0
  70. package/dist/tex-requirements.js +1 -1
  71. package/dist/toolchain.js +1 -1
  72. package/docs/ci.md +60 -0
  73. package/docs/configuration.md +2 -0
  74. package/docs/e2e.md +3 -3
  75. package/docs/incidents.md +5 -5
  76. package/docs/install.md +20 -16
  77. package/docs/layout.md +154 -0
  78. package/docs/optional-rules.md +5 -4
  79. package/docs/references.md +5 -2
  80. package/docs/rules/paper/allowed-files-only.md +192 -0
  81. package/docs/rules/repro/allowed-files-only.md +128 -0
  82. package/docs/rules/study/declare-sample.md +146 -0
  83. package/docs/rules.md +53 -50
  84. package/docs/skills.md +6 -3
  85. package/docs/venues.md +94 -0
  86. package/eslint-rules/CLAUDE.md +44 -0
  87. package/eslint-rules/CLAUDE.md.spec.ts +43 -0
  88. package/eslint-rules/install-path-literals.harness.mjs +1 -1
  89. package/eslint-rules/install-path-literals.ts +4 -4
  90. package/eslint-rules/pdf-last-page-balance.ts +1 -1
  91. package/eslint-rules/prereg-coverage.test.ts +448 -0
  92. package/eslint-rules/prereg-coverage.ts +419 -0
  93. package/eslint.config.mjs +6 -3
  94. package/hooks/CLAUDE.md +34 -0
  95. package/hooks/CLAUDE.md.spec.ts +30 -0
  96. package/lib/agent-cli-version.ts +1 -1
  97. package/lib/skill-trigger-cases.harness.mjs +51 -5
  98. package/lib/skill-trigger-cases.mjs +53 -15
  99. package/package.json +2 -2
  100. package/plugin/CLAUDE.md +22 -0
  101. package/plugin/CLAUDE.md.spec.ts +22 -0
  102. package/plugin/hooks/hooks.json +9 -0
  103. package/scripts/coverage-config.test.ts +2 -1
  104. package/skills/CLAUDE.md +19 -0
  105. package/skills/CLAUDE.md.spec.ts +18 -0
  106. package/skills/analyze-sibling-paper/SKILL.md +2 -2
  107. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +1 -1
  108. package/skills/build-benchmark/SKILL.md +14 -1
  109. package/skills/build-benchmark/SKILL.md.spec.ts +13 -0
  110. package/skills/build-label-page/SKILL.md +118 -0
  111. package/skills/build-label-page/SKILL.md.spec.ts +129 -0
  112. package/skills/build-label-page/build-label-page.eval.mjs +15 -0
  113. package/skills/build-label-page/build-label-page.test.ts +959 -0
  114. package/skills/build-label-page/references/checklist.md +148 -0
  115. package/skills/build-label-page/references/example-generator.py +803 -0
  116. package/skills/find-venue/SKILL.md +5 -3
  117. package/skills/find-venue/SKILL.md.spec.ts +4 -2
  118. package/skills/grade-paper-writing/SKILL.md +7 -7
  119. package/skills/grade-paper-writing/SKILL.md.spec.ts +6 -6
  120. package/skills/map-prior-work/SKILL.md +2 -2
  121. package/skills/map-prior-work/SKILL.md.spec.ts +1 -1
  122. package/skills/paper-pipeline/SKILL.md +8 -6
  123. package/skills/paper-pipeline/SKILL.md.spec.ts +7 -5
  124. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +19 -9
  125. package/skills/paper-pipeline/references/acceptance-gate.md +19 -19
  126. package/skills/paper-pipeline/references/artifact-checklist.md +5 -0
  127. package/skills/paper-pipeline/references/pipeline-status-template.md +30 -1
  128. package/skills/paper-pipeline/repro/2026-08-07-fork-check.log +1 -1
  129. package/skills/paper-pipeline/repro/2026-08-07-fork-check2.log +1 -1
  130. package/skills/paper-pipeline/repro/2026-08-07-language-eval-pilot.log +1 -1
  131. package/skills/paper-pipeline/repro/2026-08-07-language-eval-raw.log +1 -1
  132. package/skills/paper-pipeline/repro/2026-08-08-framing-vs-vocabulary-oracle.log +48 -48
  133. package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-A6-oracle.log +8 -8
  134. package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-oracle.log +48 -48
  135. package/skills/paper-pipeline/repro/2026-08-08-parent-replication-gpw.log +2 -2
  136. package/skills/paper-pipeline/repro/2026-08-08-parent-replication.mjs +3 -3
  137. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-preflight-after.log +81 -0
  138. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-preflight-before.log +81 -0
  139. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-setupdiff.log +59 -0
  140. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-trigger.jsonl +1 -0
  141. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-trigger.log +22 -0
  142. package/skills/paper-pipeline/repro/README.md +4 -0
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +2 -2
  144. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +2 -2
  145. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +4 -2
  146. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +7 -2
  147. package/skills/paper-pipeline/scripts/generated-code.mjs +8 -4
  148. package/skills/paper-pipeline/scripts/generated-code.test.ts +15 -1
  149. package/skills/paper-status/SKILL.md +2 -2
  150. package/skills/paper-status/SKILL.md.spec.ts +1 -1
  151. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +21 -102
  152. package/skills/study-accepted-papers/SKILL.md +163 -161
  153. package/skills/study-accepted-papers/SKILL.md.spec.ts +167 -165
  154. package/skills/study-accepted-papers/references/base-rates.md +69 -0
  155. package/skills/study-accepted-papers/references/mechanisms.md +181 -0
  156. package/skills/verify-citations/SKILL.md +2 -2
  157. package/skills/verify-citations/SKILL.md.spec.ts +1 -1
  158. package/src/CLAUDE.md +23 -1
  159. package/src/CLAUDE.md.spec.ts +72 -0
  160. package/src/build.ts +2 -2
  161. package/src/cli.harness.mjs +42 -0
  162. package/src/cli.test.ts +90 -6
  163. package/src/cli.ts +78 -5
  164. package/src/domain/layout-tree.test.ts +32 -0
  165. package/src/domain/layout-tree.ts +47 -0
  166. package/src/domain/lookup-cache.ts +2 -2
  167. package/src/domain/paper-layout.test.ts +264 -0
  168. package/src/domain/paper-layout.ts +336 -0
  169. package/src/domain/repro-layout.test.ts +222 -0
  170. package/src/domain/repro-layout.ts +311 -0
  171. package/src/domain/scaffold-layout.ts +104 -0
  172. package/src/folder-layout-hook.test.ts +214 -0
  173. package/src/folder-layout-hook.ts +141 -0
  174. package/src/folder-layout-rule.test.ts +235 -0
  175. package/src/folder-layout-rule.ts +151 -0
  176. package/src/hooks-settings.harness.ts +9 -8
  177. package/src/init.ts +1 -1
  178. package/src/layout-docs.test.ts +52 -0
  179. package/src/new-paper.harness.mjs +23 -1
  180. package/src/new-paper.test.ts +299 -6
  181. package/src/new-paper.ts +51 -5
  182. package/src/paper-files.test.ts +9 -6
  183. package/src/references.test.ts +5 -5
  184. package/src/references.ts +2 -2
  185. package/src/repro-layout-rule.test.ts +114 -0
  186. package/src/repro-layout-rule.ts +121 -0
  187. package/src/rule-cases.test.ts +98 -0
  188. package/src/tex-requirements.ts +1 -1
  189. package/src/toolchain.ts +1 -1
  190. package/templates/paper/PIPELINE-STATUS.md +41 -7
@@ -9,6 +9,8 @@
9
9
  * and a placeholder stage would be a red `paper/stages` on the very first run;
10
10
  * - the scorecard carries the `**Readiness verdict:**` line the status hook surfaces on every
11
11
  * paper edit — without it the hook reports that the paper cannot say whether it is ready;
12
+ * - a new folder also holds the layout (folders with a `.gitkeep`, `repro/README.md`), and lint
13
+ * still reports no finding on it: the rules that read those folders do not see the placeholders;
12
14
  * - it never overwrites, and on an existing folder adds only what is missing;
13
15
  * - the project's `<papers>/.template/` wins over the package, file by file.
14
16
  *
@@ -82,6 +84,11 @@ try {
82
84
  `\`${dot}\` is refused — discovery skips dot-directories, so it would never be linted`,
83
85
  /starting with a dot/.test(nameProblem(dot, []) ?? ""),
84
86
  );
87
+ for (const reserved of ["venues", "extra"])
88
+ check(
89
+ `\`${reserved}\` is refused — the papers directory's own folder, which no paper may take`,
90
+ /reserved/.test(nameProblem(reserved, []) ?? ""),
91
+ );
85
92
  check(
86
93
  "a refused name creates nothing",
87
94
  newPaper(work, "Bad", { venues: [] }).ok === false &&
@@ -119,6 +126,20 @@ try {
119
126
  /2 problems \(0 errors, 2 warnings\)/.test(l.text),
120
127
  );
121
128
 
129
+ check(
130
+ "🔴 the layout is there: the five folders every paper uses, `repro/` with an index, tools/ and data/ — and none of the optional ones",
131
+ ["reviews", "research", "figures", "versions", "submission"].every((d) =>
132
+ existsSync(join(papers, "demo", d, ".gitkeep")),
133
+ ) &&
134
+ existsSync(join(papers, "demo", "repro", "README.md")) &&
135
+ existsSync(join(papers, "demo", "repro", "tools", ".gitkeep")) &&
136
+ existsSync(join(papers, "demo", "repro", "data", ".gitkeep")) &&
137
+ !existsSync(join(papers, "demo", "repro", ".gitkeep")) &&
138
+ ["siblings", "sections", "talk", "extra", "_build"].every(
139
+ (d) => !existsSync(join(papers, "demo", d)),
140
+ ),
141
+ );
142
+
122
143
  // What the status hook reads on every paper edit: the verdict line.
123
144
  check(
124
145
  "the scorecard carries the `**Readiness verdict:**` line the status hook surfaces",
@@ -135,7 +156,8 @@ try {
135
156
  again.ok &&
136
157
  !again.fresh &&
137
158
  read(papers, "demo", STATUS_FILE) === "mine\n" &&
138
- again.files.every((f) => f.status === "kept"),
159
+ again.files.every((f) => f.status === "kept") &&
160
+ again.layout.length === 0,
139
161
  );
140
162
  writeFileSync(join(papers, "afile"), "x");
141
163
  check(
@@ -16,13 +16,28 @@ import { tmpdir } from "node:os";
16
16
  import { join } from "node:path";
17
17
  import { afterEach, describe, expect, it } from "vitest";
18
18
  import { existsSync } from "node:fs";
19
- import { newPaper, OVERRIDE_DIR, reportNewPaper } from "./new-paper.ts";
19
+ import {
20
+ newPaper,
21
+ OVERRIDE_DIR,
22
+ PACKAGE_TEMPLATES,
23
+ reportNewPaper,
24
+ } from "./new-paper.ts";
20
25
  import { parsePaperSettings } from "./paper-settings.ts";
21
26
  import { chooseVenue, run } from "./cli.ts";
22
27
  import { shippedPresets } from "./presets.ts";
23
28
  import { presetsDir } from "./package-dirs.ts";
24
29
  import { lintReport } from "../test/lint-report.ts";
25
30
  import { z } from "zod";
31
+ import { judgeLayout } from "./domain/paper-layout.ts";
32
+ import {
33
+ GITKEEP,
34
+ NOT_SCAFFOLDED,
35
+ SCAFFOLD_DIRS,
36
+ SCAFFOLD_FILES,
37
+ } from "./domain/scaffold-layout.ts";
38
+ import { ROOT_DIRS } from "./domain/paper-layout.ts";
39
+ import { judgeRepro, REPRO_DIR } from "./domain/repro-layout.ts";
40
+ import { statSync } from "node:fs";
26
41
 
27
42
  const dirs: string[] = [];
28
43
  afterEach(() => {
@@ -395,11 +410,13 @@ describe("paperlint new --allow-venue-name", () => {
395
410
  const r = await newIn(["--allow-venue-name"], {}, "realm-of-agents");
396
411
  expect(r.code).toBe(0);
397
412
  expect(r.out).not.toMatch(/venue really belongs|names a venue/);
398
- expect(readdirSync(r.dir).sort()).toEqual([
399
- "PIPELINE-STATUS.md",
400
- "paper.tex",
401
- "paperlint.json",
402
- ]);
413
+ expect(readdirSync(r.dir)).toEqual(
414
+ expect.arrayContaining([
415
+ "PIPELINE-STATUS.md",
416
+ "paper.tex",
417
+ "paperlint.json",
418
+ ]),
419
+ );
403
420
  });
404
421
 
405
422
  it.each<{
@@ -519,3 +536,279 @@ describe("a project's own template", () => {
519
536
  ).toBeGreaterThan(0);
520
537
  });
521
538
  });
539
+
540
+ describe("the PIPELINE-STATUS template — only stage tables are headed `id`", () => {
541
+ // A consumer's stage-table parser reads every table whose first column is `id` as stage rows, and a
542
+ // row under a heading that names no stage section is an error there: a fix-list table headed `id`
543
+ // gave a real paper two such errors (2026-10-12). Every other table starts with `#` or a word.
544
+ const SECTIONS = ["SETUP", "LOOP", "CONTINUOUS", "GATES", "AFTER"];
545
+ const text = readFileSync(
546
+ join(PACKAGE_TEMPLATES, "PIPELINE-STATUS.md"),
547
+ "utf8",
548
+ );
549
+ const idTablesUnder = text
550
+ .split("\n")
551
+ .reduce<{ heading: string; found: readonly string[] }>(
552
+ (acc, line) =>
553
+ line.startsWith("#")
554
+ ? { ...acc, heading: line.replace(/^#+\s*/, "") }
555
+ : /^\|\s*id\s*\|/.test(line)
556
+ ? { ...acc, found: [...acc.found, acc.heading] }
557
+ : acc,
558
+ { heading: "", found: [] },
559
+ ).found;
560
+
561
+ it("has stage tables at all", () => {
562
+ expect(idTablesUnder.length).toBeGreaterThanOrEqual(4);
563
+ });
564
+
565
+ it("puts each one under a stage section's heading", () => {
566
+ expect(
567
+ idTablesUnder.filter((h) => !SECTIONS.includes(h.split(/\s/)[0] ?? "")),
568
+ ).toEqual([]);
569
+ });
570
+ });
571
+
572
+ /** Every file under `dir`, as a path from it, sorted. */
573
+ const filesUnder = (dir: string, at = ""): string[] =>
574
+ readdirSync(join(dir, at), { withFileTypes: true })
575
+ .flatMap((e) =>
576
+ e.isDirectory() ? filesUnder(dir, `${at}${e.name}/`) : [`${at}${e.name}`],
577
+ )
578
+ .sort();
579
+
580
+ describe("paperlint new — the layout is scaffolded", () => {
581
+ const EXPECTED = [
582
+ "PIPELINE-STATUS.md",
583
+ "figures/.gitkeep",
584
+ "paper.tex",
585
+ "paperlint.json",
586
+ "repro/README.md",
587
+ "repro/data/.gitkeep",
588
+ "repro/tools/.gitkeep",
589
+ "research/.gitkeep",
590
+ "reviews/.gitkeep",
591
+ "submission/.gitkeep",
592
+ "versions/.gitkeep",
593
+ ];
594
+
595
+ it("a new paper has exactly the expected tree", () => {
596
+ const papers = join(tmp(), "papers");
597
+ const r = newPaper(papers, "demo", { venues: [] });
598
+ expect(r.ok && r.layout).toEqual(SCAFFOLD_FILES.map((f) => f.path));
599
+ expect(filesUnder(join(papers, "demo"))).toEqual(EXPECTED);
600
+ });
601
+
602
+ it("derives from the layout constants: every root folder is scaffolded or excused, and an excuse names a folder the layout has", () => {
603
+ const excused = NOT_SCAFFOLDED.map((n) => n.name);
604
+ expect([...SCAFFOLD_DIRS, ...excused].sort()).toEqual(
605
+ [...ROOT_DIRS].sort(),
606
+ );
607
+ expect(excused.filter((n) => !ROOT_DIRS.includes(n))).toEqual([]);
608
+ expect(NOT_SCAFFOLDED.filter((n) => n.why.length < 20)).toEqual([]);
609
+ // The optional folders are decided, not forgotten: these four are not made.
610
+ expect(excused).toEqual(
611
+ expect.arrayContaining(["siblings", "sections", "talk", "extra"]),
612
+ );
613
+ });
614
+
615
+ it(".gitkeep stands in an empty folder only, and no folder is empty but for it", () => {
616
+ const papers = join(tmp(), "papers");
617
+ newPaper(papers, "demo", { venues: [] });
618
+ const dir = join(papers, "demo");
619
+ const folders = new Set(
620
+ filesUnder(dir)
621
+ .filter((p) => p.includes("/"))
622
+ .map((p) => p.slice(0, p.lastIndexOf("/"))),
623
+ );
624
+ for (const f of folders) {
625
+ const names = readdirSync(join(dir, f)).filter((n) =>
626
+ statSync(join(dir, f, n)).isFile(),
627
+ );
628
+ const subdirs = readdirSync(join(dir, f)).length - names.length;
629
+ // A folder holds a .gitkeep alone, or real entries and no .gitkeep.
630
+ expect(
631
+ names.includes(GITKEEP) ? names.length === 1 && subdirs === 0 : true,
632
+ f,
633
+ ).toBe(true);
634
+ }
635
+ // repro/ holds its index and folders, so it has no placeholder of its own.
636
+ expect(existsSync(join(dir, REPRO_DIR, GITKEEP))).toBe(false);
637
+ expect(existsSync(join(dir, GITKEEP))).toBe(false);
638
+ });
639
+ });
640
+
641
+ describe("paperlint new — what the scaffold writes fits the layout", () => {
642
+ it("the folders it makes are the layout's: nothing it writes is a stray, at the root or in repro/", () => {
643
+ const papers = join(tmp(), "papers");
644
+ newPaper(papers, "demo", { venues: [] });
645
+ const dir = join(papers, "demo");
646
+ const entries = (d: string) =>
647
+ readdirSync(d, { withFileTypes: true }).map((e) => ({
648
+ name: e.name,
649
+ kind: e.isDirectory() ? ("dir" as const) : ("file" as const),
650
+ }));
651
+ expect(judgeLayout(entries(dir), { roots: [] })).toEqual([]);
652
+ expect(
653
+ judgeRepro(
654
+ entries(join(dir, REPRO_DIR)).map((e) => ({
655
+ ...e,
656
+ hasReadme: false,
657
+ })),
658
+ ),
659
+ ).toEqual([]);
660
+ });
661
+
662
+ it("repro/README.md is a stub index: one line saying one line per study", () => {
663
+ const papers = join(tmp(), "papers");
664
+ newPaper(papers, "demo", { venues: [] });
665
+ expect(
666
+ readFileSync(join(papers, "demo", "repro", "README.md"), "utf8"),
667
+ ).toMatch(/One line per study/);
668
+ });
669
+
670
+ it("the report names the folders in one line", () => {
671
+ const papers = join(tmp(), "papers");
672
+ const lines = reportNewPaper(
673
+ newPaper(papers, "demo", { venues: [] }),
674
+ (p) => p,
675
+ );
676
+ expect(lines.filter((l) => l.includes(GITKEEP))).toEqual([
677
+ " + versions/ reviews/ research/ repro/ repro/tools/ repro/data/ figures/ submission/ (the paper's layout; an empty folder holds a .gitkeep, so git keeps it)",
678
+ ]);
679
+ });
680
+ });
681
+
682
+ describe("paperlint new — the scaffold on a folder that is not new", () => {
683
+ it("a second run changes nothing: all kept, no layout, same tree", () => {
684
+ const papers = join(tmp(), "papers");
685
+ newPaper(papers, "demo", { venues: [] });
686
+ const before = filesUnder(join(papers, "demo"));
687
+ const again = newPaper(papers, "demo", { venues: [] });
688
+ expect(again.ok && again.layout).toEqual([]);
689
+ expect(again.ok && again.files.every((f) => f.status === "kept")).toBe(
690
+ true,
691
+ );
692
+ expect(filesUnder(join(papers, "demo"))).toEqual(before);
693
+ });
694
+
695
+ it("a folder that holds something gets only its missing three files, no layout", () => {
696
+ const papers = join(tmp(), "papers");
697
+ mkdirSync(join(papers, "old"), { recursive: true });
698
+ writeFileSync(join(papers, "old", "paper.tex"), "mine\n");
699
+ const r = newPaper(papers, "old", { venues: [] });
700
+ expect(r.ok && r.layout).toEqual([]);
701
+ expect(filesUnder(join(papers, "old"))).toEqual([
702
+ "PIPELINE-STATUS.md",
703
+ "paper.tex",
704
+ "paperlint.json",
705
+ ]);
706
+ expect(readFileSync(join(papers, "old", "paper.tex"), "utf8")).toBe(
707
+ "mine\n",
708
+ );
709
+ });
710
+
711
+ it("an empty folder made by hand is new: it gets the layout", () => {
712
+ const papers = join(tmp(), "papers");
713
+ mkdirSync(join(papers, "made"), { recursive: true });
714
+ const r = newPaper(papers, "made", { venues: [] });
715
+ expect(r.ok && !r.fresh).toBe(true);
716
+ expect(filesUnder(join(papers, "made"))).toContain("reviews/.gitkeep");
717
+ });
718
+
719
+ it("🔴 a folder the author emptied of its placeholder is not given it back, and a file in one stays", () => {
720
+ const papers = join(tmp(), "papers");
721
+ newPaper(papers, "demo", { venues: [] });
722
+ const dir = join(papers, "demo");
723
+ rmSync(join(dir, "reviews", GITKEEP));
724
+ writeFileSync(join(dir, "reviews", "2026-10-13-pass.md"), "x\n");
725
+ rmSync(join(dir, "figures"), { recursive: true });
726
+ newPaper(papers, "demo", { venues: [] });
727
+ expect(readdirSync(join(dir, "reviews"))).toEqual(["2026-10-13-pass.md"]);
728
+ expect(existsSync(join(dir, "figures"))).toBe(false);
729
+ });
730
+
731
+ it("an existing repro/README.md is never overwritten", () => {
732
+ const papers = join(tmp(), "papers");
733
+ mkdirSync(join(papers, "p", "repro"), { recursive: true });
734
+ writeFileSync(join(papers, "p", "repro", "README.md"), "mine\n");
735
+ newPaper(papers, "p", { venues: [] });
736
+ expect(readFileSync(join(papers, "p", "repro", "README.md"), "utf8")).toBe(
737
+ "mine\n",
738
+ );
739
+ });
740
+ });
741
+
742
+ /** A consumer project with one scaffolded paper and `added` files in it, linted: the rule ids found. */
743
+ async function lintScaffold(added: Record<string, string> = {}) {
744
+ const root = tmp();
745
+ writeFileSync(
746
+ join(root, "package.json"),
747
+ JSON.stringify({ name: "c", private: true }),
748
+ );
749
+ newPaper(join(root, "papers"), "demo", { venues: [] });
750
+ for (const [p, text] of Object.entries(added)) {
751
+ mkdirSync(join(root, "papers", "demo", p, ".."), { recursive: true });
752
+ writeFileSync(join(root, "papers", "demo", p), text);
753
+ }
754
+ const out: string[] = [];
755
+ const code = await run(["lint", "--json"], {
756
+ cwd: root,
757
+ log: (s: string) => out.push(s),
758
+ err: () => {},
759
+ });
760
+ return {
761
+ code,
762
+ ids: lintReport(out.join("\n")).flatMap((r) =>
763
+ r.messages.map((m) => m.ruleId),
764
+ ),
765
+ };
766
+ }
767
+
768
+ describe("paperlint lint — a scaffolded paper has no layout finding", () => {
769
+ const LAYOUT_RULES = [
770
+ "paper/allowed-files-only",
771
+ "repro/allowed-files-only",
772
+ "review/frontmatter",
773
+ "sibling/frontmatter",
774
+ "paper/stages",
775
+ "study/declare-sample",
776
+ ];
777
+
778
+ it("none of the rules that read the layout's folders reports it; only the not-built warnings remain", async () => {
779
+ const { code, ids } = await lintScaffold();
780
+ expect(ids.filter((id) => LAYOUT_RULES.includes(id ?? ""))).toEqual([]);
781
+ expect(ids).toEqual(["pdf/measured", "paper/sources-fresh"]);
782
+ expect(code).toBe(0);
783
+ });
784
+
785
+ it("🔴 a .gitkeep beside real entries, in any folder the rules read, is not reported either", async () => {
786
+ const { ids } = await lintScaffold({
787
+ "siblings/.gitkeep": "",
788
+ "talk/.gitkeep": "",
789
+ "sections/.gitkeep": "",
790
+ "repro/some-study-2026-10-13/README.md": "# s\n",
791
+ "repro/some-study-2026-10-13/.gitkeep": "",
792
+ });
793
+ expect(ids.filter((id) => LAYOUT_RULES.includes(id ?? ""))).toEqual([]);
794
+ });
795
+ });
796
+
797
+ describe("paperlint lint — extra/ is never read", () => {
798
+ // An archived snapshot of an older paper carries every marker a paper does. Under extra/ it is
799
+ // spillover the layout promises never to judge; anywhere else its folder reads as a paper root.
800
+ const SNAPSHOT = (dir: string) => ({
801
+ [`${dir}/PIPELINE-STATUS.md`]: "# Pipeline status\n",
802
+ [`${dir}/notes.txt`]: "x\n",
803
+ });
804
+
805
+ it("🔴 an archived paper under extra/ is never linted, by any rule", async () => {
806
+ const { ids } = await lintScaffold(SNAPSHOT("extra/old-2025"));
807
+ expect(ids).toEqual(["pdf/measured", "paper/sources-fresh"]);
808
+ });
809
+
810
+ it("the same snapshot outside extra/ is reported, so the test above can fail", async () => {
811
+ const { ids } = await lintScaffold(SNAPSHOT("research/old-2025"));
812
+ expect(ids).toContain("paper/allowed-files-only");
813
+ });
814
+ });
package/src/new-paper.ts CHANGED
@@ -12,20 +12,34 @@
12
12
  * - the name is validated, because it becomes a path and, through the hooks, a shell argument.
13
13
  *
14
14
  * The only substitution is `{{name}}` → the folder name. Nothing else in a template is touched.
15
+ *
16
+ * Beside the three files, a NEW paper folder gets the layout the rules enforce (`SCAFFOLD_FILES`,
17
+ * src/domain/scaffold-layout.ts): its folders, each with a `.gitkeep` or a first file, so the layout
18
+ * is visible from the first commit. That is for a folder that is absent or empty. On a folder that
19
+ * holds anything, `new` adds only the missing three files, as before: a paper that already has its
20
+ * own shape is not given folders it did not choose, and one whose author deleted an empty folder
21
+ * does not get it back.
15
22
  */
16
23
  /* eslint-disable boundaries/dependencies -- legacy I/O, moves behind a port in #76 */
17
24
  import {
18
25
  existsSync,
19
26
  mkdirSync,
27
+ readdirSync,
20
28
  readFileSync,
21
29
  statSync,
22
30
  writeFileSync,
23
31
  } from "node:fs";
24
32
  /* eslint-enable boundaries/dependencies */
25
- import { join } from "node:path";
33
+ import { dirname, join } from "node:path";
26
34
  import { fileURLToPath } from "node:url";
27
35
  import { CONFIG_FILE } from "#lib/paper-config";
28
36
  import { isRecord } from "./domain/record.ts";
37
+ import { ESCAPE_HATCH, VENUES_DIR } from "./domain/paper-layout.ts";
38
+ import {
39
+ GITKEEP,
40
+ SCAFFOLD_FILES,
41
+ type ScaffoldFile,
42
+ } from "./domain/scaffold-layout.ts";
29
43
  import {
30
44
  venuesNamedBy,
31
45
  withoutVenues,
@@ -51,10 +65,14 @@ export const SOURCE_FILE = "paper.tex";
51
65
  /** The flag that lets `paperlint new` create a folder whose name names a venue. */
52
66
  export const ALLOW_VENUE_NAME = "--allow-venue-name";
53
67
 
68
+ /** Names the papers directory's layout already gives a meaning to (src/domain/paper-layout.ts). */
69
+ const RESERVED_NAMES: readonly string[] = [VENUES_DIR, ESCAPE_HATCH];
70
+
54
71
  /**
55
72
  * Why a name is refused, or null. `[a-z0-9._-]+` is the charset; a LEADING dot is refused on top
56
73
  * of it, because discovery skips dot-directories — a paper named `.x` would be created and then
57
- * never linted, which is worse than a refusal.
74
+ * never linted, which is worse than a refusal. `venues` and `extra` are refused for the same reason:
75
+ * the first is the venues' corpus beside the papers, the second is never linted.
58
76
  *
59
77
  * A name that names a venue — one of `venues`' names as whole words (`aisec-2026`) — is refused
60
78
  * too, the paper's own venue included: a paper that is rejected goes to another venue and keeps its
@@ -71,6 +89,8 @@ export function nameProblem(
71
89
  return `\`${name}\` — a paper name may hold only a-z, 0-9, dot, underscore and hyphen (it becomes a path and a shell argument)`;
72
90
  if (name.startsWith("."))
73
91
  return `\`${name}\` — a name starting with a dot is skipped by paper discovery, so it would never be linted`;
92
+ if (RESERVED_NAMES.includes(name))
93
+ return `\`${name}\` — reserved: \`${VENUES_DIR}/\` beside the papers holds the venues' corpora, and lint never reads a folder named \`${ESCAPE_HATCH}/\`, so a paper by either name would be taken for something else or never linted`;
74
94
  const named = venuesNamedBy(name, venues);
75
95
  if (named.length === 0) return null;
76
96
  const which = named.map((n) => `"${n.name}"`).join(", ");
@@ -96,6 +116,8 @@ export type NewPaperResult =
96
116
  /** The folder did not exist before this run. */
97
117
  readonly fresh: boolean;
98
118
  readonly files: readonly FileOutcome[];
119
+ /** The layout files written into a folder that was absent or empty, relative to it; none otherwise. */
120
+ readonly layout: readonly string[];
99
121
  }
100
122
  | { readonly ok: false; readonly reason: string };
101
123
 
@@ -195,7 +217,19 @@ export interface NewPaperOptions {
195
217
  readonly venues: readonly NamedVenue[];
196
218
  }
197
219
 
198
- // Documented in README.md#getting-started — update it when this changes.
220
+ /** The layout files `dir` is given: all of them when it holds nothing (absent, or made by hand and still empty), else none. */
221
+ const layoutFor = (dir: string, fresh: boolean): readonly ScaffoldFile[] =>
222
+ fresh || readdirSync(dir).length === 0 ? SCAFFOLD_FILES : [];
223
+
224
+ /** Writes the layout files under `dir`; `wx` fails rather than overwrite, as for the three files. */
225
+ const writeLayout = (dir: string, layout: readonly ScaffoldFile[]): void => {
226
+ layout.forEach(({ path, text }) => {
227
+ mkdirSync(dirname(join(dir, path)), { recursive: true });
228
+ writeFileSync(join(dir, path), text, { encoding: "utf8", flag: "wx" });
229
+ });
230
+ };
231
+
232
+ // Documented in README.md#-getting-started — update it when this changes.
199
233
  export function newPaper(
200
234
  papersRoot: string,
201
235
  name: string,
@@ -225,6 +259,7 @@ export function newPaper(
225
259
  if (!planned.ok) return planned;
226
260
  toWrite.push(planned.value);
227
261
  }
262
+ const scaffold = layoutFor(dir, fresh);
228
263
  // Read every template before writing any file, so a missing one leaves no half-made folder.
229
264
  mkdirSync(dir, { recursive: true });
230
265
  for (const { file, text, from } of toWrite) {
@@ -232,9 +267,15 @@ export function newPaper(
232
267
  writeFileSync(join(dir, file), text, { encoding: "utf8", flag: "wx" });
233
268
  files.push({ file, status: "created", from });
234
269
  }
235
- return { ok: true, dir, fresh, files };
270
+ writeLayout(dir, scaffold);
271
+ return { ok: true, dir, fresh, files, layout: scaffold.map((f) => f.path) };
236
272
  }
237
273
 
274
+ /** The folders the layout files sit in, each once, in order: `reviews/`, `repro/tools/`, … */
275
+ const layoutFolders = (paths: readonly string[]): readonly string[] => [
276
+ ...new Set(paths.map((p) => `${dirname(p)}/`)),
277
+ ];
278
+
238
279
  /** The report lines, shared by `paperlint new` and the first-paper offer in `paperlint init`. */
239
280
  export function reportNewPaper(
240
281
  result: NewPaperResult,
@@ -250,5 +291,10 @@ export function reportNewPaper(
250
291
  ? ` + ${f.file} (from the ${f.from === "project" ? `project's ${OVERRIDE_DIR}/` : "package"} template)`
251
292
  : ` = ${f.file} (kept — never overwritten)`,
252
293
  );
253
- return out;
294
+ return result.layout.length === 0
295
+ ? out
296
+ : [
297
+ ...out,
298
+ ` + ${layoutFolders(result.layout).join(" ")} (the paper's layout; an empty folder holds a ${GITKEEP}, so git keeps it)`,
299
+ ];
254
300
  }
@@ -75,12 +75,15 @@ async function lint(
75
75
  const linted = (stdout: string): string[] =>
76
76
  lintReport(stdout).map((r) => r.filePath);
77
77
 
78
- /** Vendored code under a paper: a syntax error, an unknown rule in a directive, plain CommonJS. */
78
+ /**
79
+ * Vendored code under a paper: a syntax error, an unknown rule in a directive, plain CommonJS — in
80
+ * `repro/tools/`, where `repro/allowed-files-only` puts a paper's scripts.
81
+ */
79
82
  const VENDORED = {
80
- "papers/a/repro/x.js": "return 1;\n",
81
- "papers/a/repro/y.mjs":
83
+ "papers/a/repro/tools/x.js": "return 1;\n",
84
+ "papers/a/repro/tools/y.mjs":
82
85
  "// eslint-disable-next-line import/no-nodejs-modules\nimport fs from 'node:fs';\n",
83
- "papers/a/repro/lib/z.cjs": "module.exports = 1;\n",
86
+ "papers/a/repro/tools/lib/z.cjs": "module.exports = 1;\n",
84
87
  };
85
88
 
86
89
  describe("paperlint lint — lints only the files paperlint owns", () => {
@@ -125,10 +128,10 @@ describe("paperlint lint — lints only the files paperlint owns", () => {
125
128
 
126
129
  it("a file named on the command line that paperlint does not lint is refused by name", async () => {
127
130
  const root = project(VENDORED);
128
- const r = await lint(root, ["papers/a/repro/x.js"]);
131
+ const r = await lint(root, ["papers/a/repro/tools/x.js"]);
129
132
  expect(r.code).toBe(2);
130
133
  expect(r.err).toMatch(
131
- /papers\/a\/repro\/x\.js is not a file paperlint lints/,
134
+ /papers\/a\/repro\/tools\/x\.js is not a file paperlint lints/,
132
135
  );
133
136
  expect(r.err).toMatch(/PIPELINE-STATUS\.md/);
134
137
  });
@@ -303,13 +303,13 @@ const cacheText = (files: Files): string | null => {
303
303
  return b === null ? null : new TextDecoder().decode(b);
304
304
  };
305
305
 
306
- describe("the lookup cache — <paper>/repro/references-cache.json", () => {
306
+ describe("the lookup cache — <paper>/repro/data/references-cache.json", () => {
307
307
  it("a build that fetched writes the cache; the next build is handed it and writes nothing", async () => {
308
308
  const f = fetching("crossref:doi:10.1/x");
309
309
  const { files, out } = await build(ordinary(), f.check);
310
310
  expect(out).toEqual({
311
311
  ok: true,
312
- note: "references: 1 checked, 0 failing → _build/references.json; 1 new answer → repro/references-cache.json",
312
+ note: "references: 1 checked, 0 failing → _build/references.json; 1 new answer → repro/data/references-cache.json",
313
313
  });
314
314
  const written = cacheText(files);
315
315
  expect(written).toBe(
@@ -350,7 +350,7 @@ describe("the lookup cache — counting", () => {
350
350
  const { out } = await build(ordinary(), two);
351
351
  expect(out).toEqual({
352
352
  ok: true,
353
- note: "references: 1 checked, 0 failing → _build/references.json; 2 new answers → repro/references-cache.json",
353
+ note: "references: 1 checked, 0 failing → _build/references.json; 2 new answers → repro/data/references-cache.json",
354
354
  });
355
355
  });
356
356
 
@@ -381,7 +381,7 @@ describe("the lookup cache — counting", () => {
381
381
  expect({ again, file: cacheText(files) }).toEqual({
382
382
  again: {
383
383
  ok: true,
384
- note: "references: 1 checked, 0 failing → _build/references.json; 1 new answer → repro/references-cache.json",
384
+ note: "references: 1 checked, 0 failing → _build/references.json; 1 new answer → repro/data/references-cache.json",
385
385
  },
386
386
  file: serializeLookupCache({
387
387
  citations: new Map([["a", ANSWER]]),
@@ -402,7 +402,7 @@ describe("the lookup cache — refusing", () => {
402
402
  });
403
403
  expect(out).toEqual({
404
404
  ok: true,
405
- note: "references NOT checked — repro/references-cache.json cannot be read (schema 7: this paperlint reads schema 1) — fix it, or delete it to ask every question again; lint will say so",
405
+ note: "references NOT checked — repro/data/references-cache.json cannot be read (schema 7: this paperlint reads schema 1) — fix it, or delete it to ask every question again; lint will say so",
406
406
  });
407
407
  expect(f.seen).toEqual([]);
408
408
  expect(cacheText(files)).toBe('{"schema": 7}');
package/src/references.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * stale when it changes (`pdf_sha256`); these are about the databases and are stale when THEY
20
20
  * change (`bib.sha256`). One staleness key per file, the way `pdf/fresh` already works.
21
21
  *
22
- * 🔴 WHAT THE CHECKERS ANSWERED IS KEPT, COMMITTED, IN `<paper>/repro/references-cache.json`
22
+ * 🔴 WHAT THE CHECKERS ANSWERED IS KEPT, COMMITTED, IN `<paper>/repro/data/references-cache.json`
23
23
  * (`domain/lookup-cache.ts`). Loaded before the check and saved after it — only when something new
24
24
  * was fetched, a refreshed answer included — so a build asks each question at most once every
25
25
  * MAX_AGE_DAYS, not once per build (#107: 217 s for 27 references, every build). The verdicts above are NOT cached: they are derived from the cached
@@ -77,7 +77,7 @@ export const REFERENCES_SCHEMA = 3;
77
77
  export const REFERENCES_FILE = "references.json";
78
78
 
79
79
  /** The lookup cache's place in a paper: committed, beside the paper's other reproduction files. */
80
- export const LOOKUP_CACHE_FILE = "repro/references-cache.json";
80
+ export const LOOKUP_CACHE_FILE = "repro/data/references-cache.json";
81
81
 
82
82
  export const lookupCachePath = (paperDir: string): string =>
83
83
  join(paperDir, LOOKUP_CACHE_FILE);