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
package/src/cli.ts CHANGED
@@ -138,7 +138,12 @@ import {
138
138
  init,
139
139
  processInteractivity,
140
140
  } from "./init.ts";
141
- import { newPaper, reportNewPaper, type VenueSetting } from "./new-paper.ts";
141
+ import {
142
+ newPaper,
143
+ reportNewPaper,
144
+ STATUS_FILE,
145
+ type VenueSetting,
146
+ } from "./new-paper.ts";
142
147
  // The one source for the consumer's config key lives in lib/ (the ESLint rules and the skill scripts
143
148
  // import it too). It is imported compiled, from dist/, the one path that resolves the same from src/
144
149
  // and from dist/ — see CONTRIBUTING.md.
@@ -164,12 +169,22 @@ export { init };
164
169
  export { nextSteps } from "./init.ts";
165
170
 
166
171
  import paperStages from "#eslint-rules/paper-stages";
172
+ import preregCoverage from "#eslint-rules/prereg-coverage";
167
173
  import cycleStage from "#eslint-rules/cycle-stage";
168
174
  import { CYCLE_RULE_LEVELS, cycleRules } from "./cycle-rules.ts";
169
175
  import {
170
176
  FOLDER_VENUE_RULE_LEVELS,
171
177
  folderVenueRules,
172
178
  } from "./folder-venue-rule.ts";
179
+ import {
180
+ FOLDER_LAYOUT_RULE_LEVELS,
181
+ folderLayoutRules,
182
+ } from "./folder-layout-rule.ts";
183
+ import { folderLayoutHook } from "./folder-layout-hook.ts";
184
+ import {
185
+ REPRO_LAYOUT_RULE_LEVELS,
186
+ reproLayoutRules,
187
+ } from "./repro-layout-rule.ts";
173
188
  import typography from "#eslint-rules/paper-typography";
174
189
  import texBuild from "#eslint-rules/tex-build";
175
190
  import reviewFrontmatter from "#eslint-rules/review-frontmatter";
@@ -177,6 +192,7 @@ import siblingFrontmatter from "#eslint-rules/sibling-frontmatter";
177
192
  import pdfRules from "#eslint-rules/pdf-last-page-balance";
178
193
  import { messageOf } from "./domain/text.ts";
179
194
  import { isRecord } from "./domain/record.ts";
195
+ import { ESCAPE_HATCH } from "./domain/paper-layout.ts";
180
196
 
181
197
  /** The shipped venue presets, read from the package's venues directory — the one list. */
182
198
  const SHIPPED_VENUES = (): string[] => shippedPresets(presetsDir());
@@ -348,6 +364,9 @@ const FRAGMENT_RULES = {
348
364
  "heading-case": REGISTER_AND_CASE_RULES["heading-case"],
349
365
  };
350
366
 
367
+ /** What the two layout rules read with: the disk and its directory listings. */
368
+ const LAYOUT_RULE_DEPS = { files: nodeFiles, listDir: nodeListDir };
369
+
351
370
  /** What the talk rules read with: the disk, the shipped presets, and the three media readers. */
352
371
  const TALK_RULE_DEPS = {
353
372
  files: nodeFiles,
@@ -380,7 +399,9 @@ export function buildConfig(
380
399
  // its files carry every marker a paper does. Flat config does NOT ignore dot-directories by
381
400
  // default (only `node_modules/` and `.git/`), so without this block `paperlint lint` would lint the
382
401
  // template as a paper — and a richer template with placeholder stages would fail the run.
383
- { ignores: ["**/.template/"] },
402
+ // The same for a paper's `extra/`: the layout promises it is never judged, and an archived
403
+ // snapshot there carries a paper's markers too, so its folder read as a paper root with strays.
404
+ { ignores: ["**/.template/", `**/${ESCAPE_HATCH}/`] },
384
405
  // The `pdf` plugin is registered for EVERY file. A consumer's block (`rules`, appended below)
385
406
  // may name its rules for a glob that also matches markdown files; with the plugin defined only
386
407
  // beside `paper.tex`, ESLint would refuse those files with "could not find plugin". Every rule
@@ -409,8 +430,12 @@ export function buildConfig(
409
430
  rules: {
410
431
  ...paperStages.rules,
411
432
  ...folderVenueRules({ files: nodeFiles, venuesDir: presetsDir() }),
433
+ ...folderLayoutRules(LAYOUT_RULE_DEPS),
412
434
  },
413
435
  },
436
+ repro: { rules: reproLayoutRules(LAYOUT_RULE_DEPS) },
437
+ // Registered, on for no paper: `"study/declare-sample": "error"` in paperlint.json turns it on.
438
+ study: { rules: preregCoverage.rules },
414
439
  cycle: { rules: CYCLE_STATUS_RULES },
415
440
  },
416
441
  ...md,
@@ -418,6 +443,8 @@ export function buildConfig(
418
443
  "paper/stages": "error",
419
444
  "paper/source": "error",
420
445
  ...FOLDER_VENUE_RULE_LEVELS,
446
+ ...FOLDER_LAYOUT_RULE_LEVELS,
447
+ ...REPRO_LAYOUT_RULE_LEVELS,
421
448
  ...CYCLE_RULE_LEVELS,
422
449
  },
423
450
  },
@@ -647,7 +674,7 @@ export const OPTIONAL_RULES: ReadonlySet<string> = new Set(
647
674
  * 🔴 AN OPTIONAL RULE THAT IS ON AND REACHES NO PAPER IS A GREEN ZERO. A `files` glob that matches
648
675
  * nothing — a typo, a path relative to the wrong directory — leaves the rule never invoked, and a
649
676
  * rule that never runs reports exactly like a rule that passed. So for every optional rule the
650
- * consumer turned on, some `paper.tex` must actually have it enabled; the ones none has are
677
+ * consumer turned on, some paper's `paper.tex` or `PIPELINE-STATUS.md` must actually have it enabled; the ones none has are
651
678
  * returned, for the caller to refuse.
652
679
  *
653
680
  * `files` is every `paper.tex` the guard may count: the ones this run linted AND every paper of the
@@ -673,9 +700,16 @@ export async function silentOptionalRules(
673
700
  ),
674
701
  );
675
702
  const reached = new Set<string>();
676
- for (const f of new Set(files.filter((p) => basename(p) === MAIN))) {
703
+ // A paper's rules reach it through two files: `paper.tex` and the `PIPELINE-STATUS.md` beside it,
704
+ // where the rules about the paper folder itself run.
705
+ const probes = [
706
+ ...new Set(files.filter((p) => basename(p) === MAIN)),
707
+ ].flatMap((f) => [f, join(dirname(f), STATUS_FILE)]);
708
+ for (const probe of probes) {
677
709
  // Undefined for a file outside ESLint's cwd or scope: it reaches nothing.
678
- const cfg = ComputedConfig.parse(await eslint.calculateConfigForFile(f));
710
+ const cfg = ComputedConfig.parse(
711
+ await eslint.calculateConfigForFile(probe),
712
+ );
679
713
  // Reached is "some block names the rule for this paper", on OR off: a paper that turns off what
680
714
  // its venue preset turned on is the documented way out, and reads the same as a glob that missed
681
715
  // only if "off" is counted as "not there" — which refused every such paper.
@@ -1084,6 +1118,35 @@ export function toPaths(papers: unknown): string[] {
1084
1118
  return [];
1085
1119
  }
1086
1120
 
1121
+ /** What an in-process hook is handed: the payload, and where its output and complaints go. */
1122
+ interface HookIo {
1123
+ readonly err: typeof console.error;
1124
+ readonly stdin: () => string;
1125
+ readonly out: (text: string) => void;
1126
+ }
1127
+
1128
+ /**
1129
+ * Hooks that run inside this process instead of as a vigiles program, each because it must call the
1130
+ * package's own code (`src/folder-layout-hook.ts` says why for its one). Advisory: whatever goes
1131
+ * wrong, the tool call has already happened, so it says so on stderr and exits 0.
1132
+ */
1133
+ const IN_PROCESS_HOOKS: Readonly<Record<string, (io: HookIo) => number>> = {
1134
+ "paper-folder-layout": ({ err, stdin, out }) => {
1135
+ try {
1136
+ const said = folderLayoutHook(JSON.parse(stdin()), {
1137
+ files: nodeFiles,
1138
+ listDir: nodeListDir,
1139
+ });
1140
+ if (said !== null) out(`${JSON.stringify(said)}\n`);
1141
+ } catch (e) {
1142
+ err(
1143
+ `paperlint: the paper-folder-layout hook did not run: ${messageOf(e)}`,
1144
+ );
1145
+ }
1146
+ return 0;
1147
+ },
1148
+ };
1149
+
1087
1150
  /**
1088
1151
  * `paperlint hook <name>` — run an editor hook. It exists for ONE thing: so that the wiring does not
1089
1152
  * address the runtime from the project root.
@@ -1125,6 +1188,10 @@ export function runHook(
1125
1188
  // the very file the package itself declares as its `bin`.
1126
1189
  resolve = (spec: string): string =>
1127
1190
  createRequire(import.meta.url).resolve(spec),
1191
+ stdin = (): string => readFileSync(0, "utf8"),
1192
+ out = (text: string): void => {
1193
+ process.stdout.write(text);
1194
+ },
1128
1195
  }: {
1129
1196
  err?: typeof console.error;
1130
1197
  /** Runs the hook with the terminal's streams: `spawnSync` is one, and only its status is read. */
@@ -1134,12 +1201,18 @@ export function runHook(
1134
1201
  options: { readonly stdio: "inherit" },
1135
1202
  ) => { readonly status: number | null };
1136
1203
  resolve?: (spec: string) => string;
1204
+ /** The hook's payload, as Claude Code sends it on stdin. */
1205
+ stdin?: () => string;
1206
+ /** Where an in-process hook's output goes: stdout, which Claude Code reads. */
1207
+ out?: (text: string) => void;
1137
1208
  } = {},
1138
1209
  ): number {
1139
1210
  if (!name) {
1140
1211
  err(`\`hook\` needs a name, e.g. \`paperlint hook paper-edit-guard\``);
1141
1212
  return 2;
1142
1213
  }
1214
+ const inProcess = IN_PROCESS_HOOKS[name];
1215
+ if (inProcess !== undefined) return inProcess({ err, stdin, out });
1143
1216
  const program = fileURLToPath(
1144
1217
  new URL(`../hooks/${name}.hook.mjs`, import.meta.url),
1145
1218
  );
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `renderTree` (`src/domain/layout-tree.ts`): the drawing every layout message and rule page shows.
3
+ * Branches drawn as `tree` draws them, nested entries indented under a continuing or a closed branch,
4
+ * every note in one column.
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+ import { leaf, renderTree } from "./layout-tree.ts";
8
+
9
+ describe("renderTree", () => {
10
+ it("draws nested entries under open and closed branches, notes in one column", () => {
11
+ expect(
12
+ renderTree("root/", [
13
+ { name: "a/", note: "first", children: [leaf("x", "inner")] },
14
+ leaf("bb", "middle"),
15
+ { name: "c/", note: "last", children: [leaf("y", "under last")] },
16
+ ]),
17
+ ).toBe(
18
+ [
19
+ "root/",
20
+ "├── a/ ← first",
21
+ "│ └── x ← inner",
22
+ "├── bb ← middle",
23
+ "└── c/ ← last",
24
+ " └── y ← under last",
25
+ ].join("\n"),
26
+ );
27
+ });
28
+
29
+ it("an empty layout is its root alone", () => {
30
+ expect(renderTree("root/", [])).toBe("root/");
31
+ });
32
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A layout drawn as a tree — the one picture of what a paper folder may hold.
3
+ *
4
+ * The rules' messages, the hook's refusal and the rules' pages all show the same drawing, rendered by
5
+ * `renderTree` from the constants that decide what is allowed (`paper-layout.ts`, `repro-layout.ts`).
6
+ * The pages embed it between markers and a test compares them with this render, so a page cannot
7
+ * describe a layout the code does not enforce.
8
+ */
9
+
10
+ /** One entry of a layout: its name (a folder's ends in a slash), what it is for, what it holds. */
11
+ export interface TreeNode {
12
+ readonly name: string;
13
+ readonly note: string;
14
+ readonly children: readonly TreeNode[];
15
+ }
16
+
17
+ /** An entry with nothing drawn under it. */
18
+ export const leaf = (name: string, note: string): TreeNode => ({
19
+ name,
20
+ note,
21
+ children: [],
22
+ });
23
+
24
+ interface Row {
25
+ readonly left: string;
26
+ readonly note: string;
27
+ }
28
+
29
+ /** Each entry's line, depth first, with the branch drawing in front of its name. */
30
+ const rowsOf = (nodes: readonly TreeNode[], indent: string): readonly Row[] =>
31
+ nodes.flatMap((n, i) => {
32
+ const last = i === nodes.length - 1;
33
+ return [
34
+ { left: `${indent}${last ? "└── " : "├── "}${n.name}`, note: n.note },
35
+ ...rowsOf(n.children, `${indent}${last ? " " : "│ "}`),
36
+ ];
37
+ });
38
+
39
+ /** `nodes` under `root`, one line each, every note in one column after `←`. Pure. */
40
+ export function renderTree(root: string, nodes: readonly TreeNode[]): string {
41
+ const rows = rowsOf(nodes, "");
42
+ const width = Math.max(0, ...rows.map((r) => r.left.length));
43
+ return [
44
+ root,
45
+ ...rows.map((r) => `${r.left.padEnd(width)} ← ${r.note}`),
46
+ ].join("\n");
47
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * THE LOOKUP CACHE — `<paper>/repro/references-cache.json`: what the citation services ANSWERED,
2
+ * THE LOOKUP CACHE — `<paper>/repro/data/references-cache.json`: what the citation services ANSWERED,
3
3
  * kept so that a build asks each question at most once every MAX_AGE_DAYS, not once per build (#107).
4
4
  *
5
5
  * { "schema": 1,
@@ -15,7 +15,7 @@
15
15
  * responses on every build, by the current version of the checkers. A cached verdict would freeze
16
16
  * whatever the checker believed the day it ran — a bug fixed in the comparison would never reach a
17
17
  * paper whose entries are cached. A cached response is evidence: it cannot be made up, only
18
- * refetched. The precedent is `extract-ref-facts.mjs`'s `repro/refs-cache.json` ("the cache is
18
+ * refetched. The precedent is `extract-ref-facts.mjs`'s `repro/data/refs-cache.json` ("the cache is
19
19
  * evidence, not a second declaration").
20
20
  *
21
21
  * 🔴 COMMITTED, so CI and every other clone start warm: a build whose entries are all here makes no
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The paper folder's layout (`src/domain/paper-layout.ts`), judged on its root entries alone. Both
3
+ * halves: the scaffold `paperlint new` writes, and a folder holding every allowed entry, are clean; a
4
+ * stray file or folder at the root is reported once, by name, with where it belongs.
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+ import {
8
+ CURE_BY_KIND,
9
+ KNOWN_CURES,
10
+ NOTHING_DECLARED,
11
+ PAPER_LAYOUT_TREE,
12
+ ROOT_DIRS,
13
+ ROOT_FILES,
14
+ judgeLayout,
15
+ strayMessage,
16
+ type LayoutEntry,
17
+ } from "./paper-layout.ts";
18
+
19
+ const file = (name: string): LayoutEntry => ({ name, kind: "file" });
20
+ const dir = (name: string): LayoutEntry => ({ name, kind: "dir" });
21
+
22
+ describe("judgeLayout — silent", () => {
23
+ it("on the scaffold `paperlint new` writes", () => {
24
+ expect(
25
+ judgeLayout(
26
+ [file("PIPELINE-STATUS.md"), file("paper.tex"), file("paperlint.json")],
27
+ NOTHING_DECLARED,
28
+ ),
29
+ ).toEqual([]);
30
+ });
31
+ });
32
+
33
+ describe("judgeLayout — silent on the whole allowed set", () => {
34
+ it("on every allowed file and folder, the TeX inputs and the build's byproducts", () => {
35
+ const entries = [
36
+ ...[
37
+ "PIPELINE-STATUS.md",
38
+ "paper.tex",
39
+ "paperlint.json",
40
+ "README.md",
41
+ "CLAUDE.md",
42
+ "CLAIMS.md",
43
+ "NOTES.md",
44
+ "SUBMIT-CHECKLIST.md",
45
+ ".gitignore",
46
+ "paper.pdf",
47
+ "numbers.tex",
48
+ "refs.bib",
49
+ "acmart.cls",
50
+ "pbalance.sty",
51
+ "ACM-Reference-Format.bst",
52
+ "paper.aux",
53
+ "paper.log",
54
+ "paper.bbl",
55
+ "paper.blg",
56
+ "paper.out",
57
+ "paper.fls",
58
+ "paper.toc",
59
+ "paper.synctex.gz",
60
+ ".DS_Store",
61
+ ].map(file),
62
+ ...[
63
+ "_build",
64
+ "versions",
65
+ "reviews",
66
+ "siblings",
67
+ "research",
68
+ "repro",
69
+ "figures",
70
+ "sections",
71
+ "talk",
72
+ "submission",
73
+ "extra",
74
+ ].map(dir),
75
+ ];
76
+ expect(judgeLayout(entries, NOTHING_DECLARED)).toEqual([]);
77
+ });
78
+
79
+ it("on what the paper's settings name at its root: another PDF, another talk folder, its own preset", () => {
80
+ expect(
81
+ judgeLayout([file("main.pdf"), dir("video"), file("venue.jsonc")], {
82
+ roots: ["main.pdf", "video", "venue.jsonc"],
83
+ }),
84
+ ).toEqual([]);
85
+ });
86
+ });
87
+
88
+ describe("judgeLayout — reports", () => {
89
+ it("a known name with its one destination: the plan is a section of the status file", () => {
90
+ const [plan] = judgeLayout(
91
+ [file("paper.tex"), file("PLAN.md")],
92
+ NOTHING_DECLARED,
93
+ );
94
+ expect(plan?.name).toBe("PLAN.md");
95
+ expect(plan?.cure).toMatch(
96
+ /^a section of PIPELINE-STATUS\.md .*"Plan \/ now"/,
97
+ );
98
+ });
99
+
100
+ it.each([
101
+ ["DECISION-LOG.md", "file", /^research\//],
102
+ ["EMAILS.md", "file", /^submission\//],
103
+ ["data", "dir", /^repro\//],
104
+ ["artifact", "dir", /^repro\//],
105
+ [
106
+ "archive",
107
+ "dir",
108
+ /^versions\/ for frozen PDFs, submission\/ .*, extra\/ for the rest$/,
109
+ ],
110
+ [
111
+ "venue-corpus",
112
+ "dir",
113
+ /^<papers-root>\/venues\/<venue>\/, outside the paper folder/,
114
+ ],
115
+ ["plans", "dir", /^a section of PIPELINE-STATUS\.md/],
116
+ ["Plan.md", "file", /^a section of PIPELINE-STATUS\.md/],
117
+ ] as const)("%s (%s) goes to its destination", (name, kind, cure) => {
118
+ expect(judgeLayout([{ name, kind }], NOTHING_DECLARED)[0]?.cure).toMatch(
119
+ cure,
120
+ );
121
+ });
122
+
123
+ it("an unknown name gets the choice by what it is", () => {
124
+ expect(judgeLayout([dir("corpus")], NOTHING_DECLARED)).toEqual([
125
+ { name: "corpus/", kind: "dir", cure: CURE_BY_KIND },
126
+ ]);
127
+ });
128
+
129
+ it("a known file name used by a folder is not that file: the choice by kind", () => {
130
+ expect(judgeLayout([dir("PLAN.md")], NOTHING_DECLARED)[0]?.cure).toBe(
131
+ CURE_BY_KIND,
132
+ );
133
+ });
134
+ });
135
+
136
+ describe("judgeLayout — the build's byproducts", () => {
137
+ // What pdflatex, bibtex, makeindex, biber, glossaries, beamer and latexmk write next to paper.tex.
138
+ const byproducts = [
139
+ "aux",
140
+ "log",
141
+ "out",
142
+ "toc",
143
+ "lof",
144
+ "lot",
145
+ "loa",
146
+ "lol",
147
+ "bbl",
148
+ "blg",
149
+ "idx",
150
+ "ind",
151
+ "ilg",
152
+ "glo",
153
+ "gls",
154
+ "glg",
155
+ "nlo",
156
+ "nls",
157
+ "fls",
158
+ "fdb_latexmk",
159
+ "synctex.gz",
160
+ "run.xml",
161
+ "bcf",
162
+ "nav",
163
+ "snm",
164
+ "vrb",
165
+ "xdv",
166
+ "dvi",
167
+ ];
168
+
169
+ it("a root holding paper.idx, paper.ind, paper.ilg and the rest of the job's outputs reports nothing", () => {
170
+ expect(
171
+ judgeLayout(
172
+ byproducts.map((ext) => file(`paper.${ext}`)),
173
+ NOTHING_DECLARED,
174
+ ),
175
+ ).toEqual([]);
176
+ });
177
+
178
+ it("only under the paper's own job name: draft.idx is reported", () => {
179
+ expect(
180
+ judgeLayout([file("draft.idx"), file("paper.idx")], NOTHING_DECLARED).map(
181
+ (s) => s.name,
182
+ ),
183
+ ).toEqual(["draft.idx"]);
184
+ });
185
+
186
+ it("🔴 the .aux that \\include writes beside a root source: chapter.aux with chapter.tex is the build's; alone it is reported", () => {
187
+ expect(
188
+ judgeLayout(
189
+ [file("chapter.tex"), file("chapter.aux"), file("orphan.aux")],
190
+ NOTHING_DECLARED,
191
+ ).map((s) => s.name),
192
+ ).toEqual(["orphan.aux"]);
193
+ });
194
+ });
195
+
196
+ describe("judgeLayout — reports by name, exactly", () => {
197
+ it("a PDF other than the built one, a dotfile, another job's byproduct", () => {
198
+ expect(
199
+ judgeLayout(
200
+ [file("paper-2026-07-18.pdf"), file(".body-limit"), file("draft.aux")],
201
+ NOTHING_DECLARED,
202
+ ).map((s) => s.name),
203
+ ).toEqual(["paper-2026-07-18.pdf", ".body-limit", "draft.aux"]);
204
+ });
205
+
206
+ it("an allowed folder's name used by a file, and an allowed file's name used by a folder", () => {
207
+ expect(
208
+ judgeLayout([file("reviews"), dir("README.md")], NOTHING_DECLARED).map(
209
+ (s) => s.name,
210
+ ),
211
+ ).toEqual(["reviews", "README.md/"]);
212
+ });
213
+
214
+ it("names compared exactly: case matters", () => {
215
+ expect(
216
+ judgeLayout([dir("Reviews")], NOTHING_DECLARED).map((s) => s.name),
217
+ ).toEqual(["Reviews/"]);
218
+ });
219
+
220
+ it("each stray entry once, in the order given", () => {
221
+ expect(
222
+ judgeLayout(
223
+ [file("a.md"), file("paper.tex"), dir("data")],
224
+ NOTHING_DECLARED,
225
+ ).map((s) => s.name),
226
+ ).toEqual(["a.md", "data/"]);
227
+ });
228
+ });
229
+
230
+ describe("strayMessage — stands alone for a reader with no context", () => {
231
+ const [plan] = judgeLayout([file("PLAN.md")], NOTHING_DECLARED);
232
+ const text = plan === undefined ? "" : strayMessage(plan);
233
+
234
+ it("names the entry, where it goes, draws the whole layout, and links the rule's page", () => {
235
+ expect(text.split("\n")).toEqual([
236
+ `\`PLAN.md\` is at the root of a paper folder (the folder holding PIPELINE-STATUS.md), and that root holds only paperlint's layout. Where it goes: ${KNOWN_CURES[0]?.cure ?? ""}.`,
237
+ "The layout (extra/ takes anything and is never judged; the build's paper.* byproducts are never reported):",
238
+ ...PAPER_LAYOUT_TREE.split("\n"),
239
+ "Rule paper/allowed-files-only: https://github.com/zernie/paperlint/blob/main/docs/rules/paper/allowed-files-only.md",
240
+ ]);
241
+ });
242
+
243
+ it("the layout it draws holds every allowed root entry, repro/'s own layout inside", () => {
244
+ const lines = PAPER_LAYOUT_TREE.split("\n");
245
+ expect(lines[0]).toBe("<paper>/");
246
+ const drawn = new Set(
247
+ lines.map((l) =>
248
+ l
249
+ .replace(/^[│ ]*[├└]── /u, "")
250
+ .split(" ", 1)
251
+ .join(""),
252
+ ),
253
+ );
254
+ expect(
255
+ [...ROOT_FILES, ...ROOT_DIRS.map((d) => `${d}/`)].filter(
256
+ (name) => !drawn.has(name),
257
+ ),
258
+ ).toEqual([]);
259
+ expect(lines).toContain(
260
+ "│ └── <slug>-YYYY-MM-DD/ ← one folder per study or spike (<slug>-YYYY-MM for one that ran over weeks)",
261
+ );
262
+ expect(lines.at(-1)).toMatch(/^└── extra\/ +← anything; never judged$/);
263
+ });
264
+ });