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
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The `paper-folder-layout` hook — `paper/allowed-files-only` and `repro/allowed-files-only` BEFORE a file is
3
+ * created, not at the next lint. PreToolUse on `Edit|Write|MultiEdit` (`plugin/hooks/hooks.json`):
4
+ * when the file about to be written does not exist yet and would add an entry the layout does not
5
+ * allow — at a paper folder's root, or at the top of its `repro/` — the call is denied, and the
6
+ * reason is the rule's own message: where the entry goes, and the layout drawn. The agent writes it
7
+ * where it belongs instead, in the same turn.
8
+ *
9
+ * ── WHAT IT NEVER BLOCKS ─────────────────────────────────────────────────────────
10
+ * - A file that already exists. Editing is never refused, wherever the file is: a paper whose
11
+ * folder predates the layout keeps working, and the rule reports what is out of place.
12
+ * - A new file inside an entry that already exists, even one the layout does not allow: the entry is
13
+ * not new, and the rule reports it once, by name. Only a NEW entry is refused.
14
+ * - Anything outside a paper folder (no `PIPELINE-STATUS.md` above it).
15
+ * A new study folder in `repro/` is created with its `README.md` first: any other first file in it
16
+ * is refused with `repro/allowed-files-only`'s "without a README.md" message.
17
+ *
18
+ * ── WHAT IT CANNOT SEE ───────────────────────────────────────────────────────────
19
+ * A shell command (`mv`, `cp`, a script writing files) is not an Edit or a Write, and this hook never
20
+ * sees it. The guarantee is the lint: both rules fail `paperlint lint`, in CI, whatever wrote the
21
+ * entry. The hook only saves the round trip. It does not read the paper's `rules` either: a paper
22
+ * that switched a layout rule off is still refused a new stray entry.
23
+ *
24
+ * ── ONE FUNCTION PER RULE, NOT TWO LISTS ─────────────────────────────────────────
25
+ * The texts are `strayTexts` (`src/folder-layout-rule.ts`) and `reproTexts`
26
+ * (`src/repro-layout-rule.ts`), the functions the rules report with.
27
+ *
28
+ * ── WHY IT IS NOT A VIGILES PROGRAM LIKE ITS NEIGHBOURS (measured 2026-10-12, vigiles 38) ──────
29
+ * A shipped `.hook.mjs` may import nothing but `vigiles/hook` (`checkHookImports`), so it cannot call
30
+ * this package's function; its only way to a tool is `run()`, whose output reaches stderr and never
31
+ * the hook's answer (`paper-status-gates.hook.mjs` measured that); and a `provide()` command runs
32
+ * before the reaction without the event, so it cannot know which file is written. Each of the three
33
+ * would mean a second copy of the layout. So `paperlint hook paper-folder-layout` runs this module
34
+ * in-process (`runHook`, src/cli.ts), behind the same wiring command as the other hooks, which is
35
+ * what `paperlint init` merges and `paperlint doctor` reads.
36
+ *
37
+ * ── THE CHEAP FIRST STEP ─────────────────────────────────────────────────────────
38
+ * Before reading any settings or listing any folder, the hook looks for the nearest folder above the
39
+ * file that holds `PIPELINE-STATUS.md` — the file every paper has and the rules are reported on — with
40
+ * one `isFile` per ancestor and nothing else: no git, no lint. No such folder: no output.
41
+ */
42
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
43
+ import { callerPath } from "./caller-path.ts";
44
+ import { ESCAPE_HATCH } from "./domain/paper-layout.ts";
45
+ import { README, REPRO_DIR, isStudyName } from "./domain/repro-layout.ts";
46
+ import { fieldOf } from "./domain/record.ts";
47
+ import { strayTexts, type FolderLayoutDeps } from "./folder-layout-rule.ts";
48
+ import type { Files } from "./ports/files.ts";
49
+ import { reproTexts } from "./repro-layout-rule.ts";
50
+
51
+ /** The file every paper folder has, and the rules are reported on. */
52
+ const STATUS_FILE = "PIPELINE-STATUS.md";
53
+
54
+ /** What Claude Code reads from a PreToolUse hook's stdout to refuse the call, with the reason. */
55
+ export interface HookOutput {
56
+ readonly hookSpecificOutput: {
57
+ readonly hookEventName: "PreToolUse";
58
+ readonly permissionDecision: "deny";
59
+ readonly permissionDecisionReason: string;
60
+ };
61
+ }
62
+
63
+ /** The target file's absolute path from the payload; null when there is none to read. */
64
+ export function writtenPath(payload: unknown): string | null {
65
+ const input = fieldOf(payload, "tool_input");
66
+ const raw = fieldOf(input, "file_path") ?? fieldOf(input, "notebook_path");
67
+ if (typeof raw !== "string" || raw === "") return null;
68
+ if (isAbsolute(raw)) return resolve(raw);
69
+ const cwd = fieldOf(payload, "cwd");
70
+ return typeof cwd === "string" && isAbsolute(cwd) ? resolve(cwd, raw) : null;
71
+ }
72
+
73
+ /**
74
+ * The nearest folder above `file` holding `PIPELINE-STATUS.md`, up to the root; null when none, and
75
+ * null for anything under a folder named `extra/`, which lint never reads: an archived paper kept
76
+ * there carries its own status file and is not a paper to hold to the layout.
77
+ */
78
+ export function paperFolderOf(file: string, files: Files): string | null {
79
+ // The same reach as lint's `**/extra/` ignore: a folder named extra/ anywhere above the file.
80
+ if (dirname(file).split(sep).includes(ESCAPE_HATCH)) return null;
81
+ const up = (dir: string): string | null => {
82
+ if (files.isFile(callerPath(join(dir, STATUS_FILE)))) return dir;
83
+ const parent = dirname(dir);
84
+ return parent === dir ? null : up(parent);
85
+ };
86
+ return up(dirname(file));
87
+ }
88
+
89
+ /** The entry `segments` would add under `dir`, as the layout judges it; null when it exists. */
90
+ const newEntry = (
91
+ dir: string,
92
+ [name = "", ...below]: readonly string[],
93
+ deps: FolderLayoutDeps,
94
+ ): { readonly name: string; readonly kind: "file" | "dir" } | null =>
95
+ deps.listDir(callerPath(dir)).includes(name)
96
+ ? null
97
+ : { name, kind: below.length > 0 ? "dir" : "file" };
98
+
99
+ /** Why creating `segments` (relative to the paper folder `dir`) breaks the layout; null when not. */
100
+ function refusal(
101
+ dir: string,
102
+ segments: readonly string[],
103
+ deps: FolderLayoutDeps,
104
+ ): string | null {
105
+ const atRoot = newEntry(dir, segments, deps);
106
+ const [rootText] =
107
+ atRoot === null ? [] : strayTexts(dir, [atRoot], deps.files);
108
+ if (rootText !== undefined) return rootText;
109
+ const [top, ...inRepro] = segments;
110
+ if (top !== REPRO_DIR || inRepro.length === 0) return null;
111
+ const entry = newEntry(join(dir, REPRO_DIR), inRepro, deps);
112
+ // A new study folder starts with its README.md: that write is what makes it complete.
113
+ if (
114
+ entry === null ||
115
+ (isStudyName(entry.name) && inRepro.slice(1).join("/") === README)
116
+ )
117
+ return null;
118
+ const [text] = reproTexts(dir, [entry], deps.files);
119
+ return text ?? null;
120
+ }
121
+
122
+ /** The hook: the payload of an Edit or a Write in, Claude Code's refusal out, or null to allow. */
123
+ export function folderLayoutHook(
124
+ payload: unknown,
125
+ deps: FolderLayoutDeps,
126
+ ): HookOutput | null {
127
+ const file = writtenPath(payload);
128
+ const dir = file === null ? null : paperFolderOf(file, deps.files);
129
+ if (file === null || dir === null || deps.files.isFile(callerPath(file)))
130
+ return null;
131
+ const reason = refusal(dir, relative(dir, file).split(/[\\/]/), deps);
132
+ return reason === null
133
+ ? null
134
+ : {
135
+ hookSpecificOutput: {
136
+ hookEventName: "PreToolUse",
137
+ permissionDecision: "deny",
138
+ permissionDecisionReason: reason,
139
+ },
140
+ };
141
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * `paper/allowed-files-only` (`src/folder-layout-rule.ts`) on paper folders held in memory, run the way
3
+ * ESLint runs it: `create`, then `root`. Both halves: a stray file or folder at the paper's root is
4
+ * reported once each, on the first line of `PIPELINE-STATUS.md`; the scaffold, and what the paper's
5
+ * settings name at its root, are not.
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+ import { memoryFiles, memoryListDir } from "./adapters/memory/index.ts";
9
+ import { KNOWN_CURES, strayMessage } from "./domain/paper-layout.ts";
10
+ import {
11
+ FOLDER_LAYOUT_RULE_LEVELS,
12
+ declaredRoots,
13
+ folderLayoutRules,
14
+ } from "./folder-layout-rule.ts";
15
+
16
+ const DIR = "/work/papers/p";
17
+ const SCAFFOLD = {
18
+ [`${DIR}/PIPELINE-STATUS.md`]: "# status\n",
19
+ [`${DIR}/paper.tex`]: "\\documentclass{article}\n",
20
+ [`${DIR}/paperlint.json`]: "{}\n",
21
+ };
22
+
23
+ interface Reported {
24
+ readonly messageId: string;
25
+ readonly message: string;
26
+ readonly line: number;
27
+ }
28
+
29
+ /** The rule over the folder `tree` describes, reached through `file` (default the scorecard). */
30
+ function lint(
31
+ tree: Readonly<Record<string, string>>,
32
+ file = `${DIR}/PIPELINE-STATUS.md`,
33
+ ): Reported[] {
34
+ const files = memoryFiles(tree);
35
+ const rule = folderLayoutRules({
36
+ files,
37
+ listDir: memoryListDir(files),
38
+ })["allowed-files-only"];
39
+ const out: Reported[] = [];
40
+ const visitor = rule.create({
41
+ filename: file,
42
+ sourceCode: { getLocFromIndex: () => ({ line: 1, column: 0 }) },
43
+ report: (d) => {
44
+ const template = rule.meta.messages[d.messageId] ?? "";
45
+ const message = Object.entries(d.data).reduce(
46
+ (m, [k, v]) => m.split(`{{${k}}}`).join(String(v)),
47
+ template,
48
+ );
49
+ out.push({ messageId: d.messageId, message, line: d.loc.start.line });
50
+ },
51
+ });
52
+ visitor.root?.();
53
+ return out;
54
+ }
55
+
56
+ describe("paper/allowed-files-only — reports", () => {
57
+ it("a stray file at the root, once, on the first line, with the one stray text", () => {
58
+ expect(lint({ ...SCAFFOLD, [`${DIR}/PLAN.md`]: "x" })).toEqual([
59
+ {
60
+ messageId: "stray",
61
+ message: strayMessage({
62
+ name: "PLAN.md",
63
+ kind: "file",
64
+ cure: KNOWN_CURES[0]?.cure ?? "",
65
+ }),
66
+ line: 1,
67
+ },
68
+ ]);
69
+ });
70
+
71
+ it("a stray folder, by its name with a slash, whatever it holds", () => {
72
+ expect(
73
+ lint({ ...SCAFFOLD, [`${DIR}/archive/old/x.pdf`]: "x" }).map(
74
+ (r) => r.message,
75
+ ),
76
+ ).toEqual([
77
+ expect.stringMatching(
78
+ /^`archive\/` is at the root of a paper folder.* Where it goes: versions\/ for frozen PDFs/,
79
+ ),
80
+ ]);
81
+ });
82
+
83
+ it("every stray entry, each once", () => {
84
+ expect(
85
+ lint({
86
+ ...SCAFFOLD,
87
+ [`${DIR}/a.pdf`]: "x",
88
+ [`${DIR}/data/x.json`]: "x",
89
+ [`${DIR}/run.py`]: "x",
90
+ }).length,
91
+ ).toBe(3);
92
+ });
93
+
94
+ it("a folder the settings name is allowed only while they name it", () => {
95
+ expect(
96
+ lint({ ...SCAFFOLD, [`${DIR}/video/talk.mp4`]: "x" }).map((r) =>
97
+ r.message.slice(0, 9),
98
+ ),
99
+ ).toEqual(["`video/` "]);
100
+ });
101
+ });
102
+
103
+ describe("paper/allowed-files-only — silent", () => {
104
+ it("on the scaffold `paperlint new` writes", () => {
105
+ expect(lint(SCAFFOLD)).toEqual([]);
106
+ });
107
+
108
+ it("on the allowed folders, whatever they hold, and on extra/", () => {
109
+ expect(
110
+ lint({
111
+ ...SCAFFOLD,
112
+ [`${DIR}/reviews/2026-10-12-x.md`]: "x",
113
+ [`${DIR}/repro/run.py`]: "x",
114
+ [`${DIR}/extra/anything/at/all.bin`]: "x",
115
+ [`${DIR}/_build/paper.facts.json`]: "{}",
116
+ [`${DIR}/paper.aux`]: "x",
117
+ [`${DIR}/refs.bib`]: "x",
118
+ }),
119
+ ).toEqual([]);
120
+ });
121
+
122
+ it("on what the settings name at the root: the PDF, the talk folder, the paper's own preset", () => {
123
+ expect(
124
+ lint({
125
+ ...SCAFFOLD,
126
+ [`${DIR}/paperlint.json`]: JSON.stringify({
127
+ extends: "./venue.jsonc",
128
+ pdf: "out/main.pdf",
129
+ talk: { mode: "remote-video", dir: "video" },
130
+ }),
131
+ [`${DIR}/venue.jsonc`]: "{}",
132
+ [`${DIR}/out/main.pdf`]: "x",
133
+ [`${DIR}/video/talk.mp4`]: "x",
134
+ }),
135
+ ).toEqual([]);
136
+ });
137
+
138
+ it("on any file but PIPELINE-STATUS.md: the folder is judged once per paper", () => {
139
+ expect(
140
+ lint({ ...SCAFFOLD, [`${DIR}/PLAN.md`]: "x" }, `${DIR}/paper.tex`),
141
+ ).toEqual([]);
142
+ });
143
+ });
144
+
145
+ describe("declaredRoots — what a paper's settings name at its root", () => {
146
+ const roots = (settings: object, tree: Record<string, string> = {}) =>
147
+ declaredRoots(
148
+ DIR,
149
+ memoryFiles({
150
+ [`${DIR}/paperlint.json`]: JSON.stringify(settings),
151
+ ...tree,
152
+ }),
153
+ );
154
+
155
+ it("the first segment of each path inside the folder", () => {
156
+ expect(
157
+ roots({
158
+ pdf: "build/out/paper.pdf",
159
+ talk: { mode: "remote-video", dir: "media/talk" },
160
+ }),
161
+ ).toEqual(["build", "media"]);
162
+ });
163
+
164
+ it("every cycle's own preset file, and none outside the folder or shipped", () => {
165
+ expect(
166
+ roots({
167
+ cycles: [
168
+ {
169
+ id: "a",
170
+ venue: { kind: "preset", extends: "./a.jsonc" },
171
+ opened: "2026-01-01",
172
+ deadlines: [],
173
+ outcome: {
174
+ kind: "rejected",
175
+ date: "2026-02-01",
176
+ evidence: "submission/a.eml",
177
+ desk: false,
178
+ },
179
+ },
180
+ {
181
+ id: "c",
182
+ venue: { kind: "named", name: "C", url: "https://c.example" },
183
+ opened: "2026-03-01",
184
+ deadlines: [],
185
+ outcome: {
186
+ kind: "withdrawn",
187
+ date: "2026-03-02",
188
+ evidence: "submission/c.eml",
189
+ },
190
+ },
191
+ {
192
+ id: "b",
193
+ venue: { kind: "preset", extends: "../shared/b.jsonc" },
194
+ opened: "2026-03-01",
195
+ deadlines: [],
196
+ outcome: { kind: "open" },
197
+ },
198
+ ],
199
+ }),
200
+ ).toEqual(["a.jsonc"]);
201
+ });
202
+ });
203
+
204
+ describe("declaredRoots — nothing to add", () => {
205
+ const roots = (settings: object) =>
206
+ declaredRoots(
207
+ DIR,
208
+ memoryFiles({ [`${DIR}/paperlint.json`]: JSON.stringify(settings) }),
209
+ );
210
+
211
+ it("nothing when the settings do not parse, or there are none", () => {
212
+ expect(roots({ venu: "x" })).toEqual([]);
213
+ expect(declaredRoots(DIR, memoryFiles({}))).toEqual([]);
214
+ });
215
+
216
+ it("an absolute path inside the folder counts; the folder itself, or a path outside it, does not", () => {
217
+ expect(
218
+ roots({
219
+ pdf: `${DIR}/out/paper.pdf`,
220
+ talk: { mode: "remote-video", dir: "." },
221
+ }),
222
+ ).toEqual(["out"]);
223
+ expect(roots({ pdf: "/elsewhere/paper.pdf" })).toEqual([]);
224
+ });
225
+
226
+ it("a shipped preset names no file", () => {
227
+ expect(roots({ extends: "paperlint:aidc" })).toEqual([]);
228
+ });
229
+ });
230
+
231
+ it("is on at error in paperlint's own config", () => {
232
+ expect(FOLDER_LAYOUT_RULE_LEVELS).toEqual({
233
+ "paper/allowed-files-only": "error",
234
+ });
235
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * `paper/allowed-files-only` — the root of a paper folder holds only paperlint's own files and folders.
3
+ *
4
+ * What may stand there, and why each entry, is `src/domain/paper-layout.ts` (the one list) and
5
+ * docs/rules/paper/allowed-files-only.md. This file reads the folder and the paper's settings, and reports.
6
+ *
7
+ * ── WHY A RULE THAT LISTS A DIRECTORY ───────────────────────────────────────────
8
+ * A structure linter over file NAMES (ls-lint, ESLint's `project-structure`) judges the files handed
9
+ * to it, and ESLint is handed only the files paperlint lints: a stray `.pdf`, `.py` or `.md` is never
10
+ * one of them, so a rule fired per file cannot see it. Listing the folder is one `readdir`; a rule is
11
+ * plain JS and may do it (root CLAUDE.md, "Measure before calling it impossible").
12
+ *
13
+ * ── WHICH FILE IT REPORTS ON ─────────────────────────────────────────────────────
14
+ * The folder is not a file ESLint lints; `PIPELINE-STATUS.md` is, in every paper (`paperlint lint`
15
+ * requires it, `paperlint new` writes it). Each stray entry is one finding on its first line, the
16
+ * place `paper/folder-venue-leftover` reports for the same reason.
17
+ *
18
+ * ── WHAT THE SETTINGS ADD ────────────────────────────────────────────────────────
19
+ * `pdf`, `talk.dir`, and a preset file of the paper's own (`extends`, or any cycle's) may name an
20
+ * entry at the root; each such entry is allowed while the settings name it. Settings that do not parse
21
+ * add nothing: `paperlint lint` refuses them before ESLint runs, and `pdf/profile` names the problem.
22
+ */
23
+ import { basename, dirname, join, posix, relative, resolve } from "node:path";
24
+ import { callerPath } from "./caller-path.ts";
25
+ import {
26
+ judgeLayout,
27
+ strayMessage,
28
+ type LayoutEntry,
29
+ } from "./domain/paper-layout.ts";
30
+ import type {
31
+ FolderRuleContext,
32
+ FolderRuleModule,
33
+ } from "./folder-venue-rule.ts";
34
+ import { readPaperSettings, type PaperSettings } from "./paper-settings.ts";
35
+ import type { Files } from "./ports/files.ts";
36
+ import type { ListDir } from "./ports/talk-media.ts";
37
+ import { rulePageUrl } from "./venue-rules.ts";
38
+
39
+ const STATUS_FILE = "PIPELINE-STATUS.md";
40
+
41
+ /** What the rule reads with: the disk, and the names in a directory. */
42
+ export interface FolderLayoutDeps {
43
+ readonly files: Files;
44
+ readonly listDir: ListDir;
45
+ }
46
+
47
+ /** The first segment of `p`, relative to the paper folder `dir`; null when it is not inside it. */
48
+ function rootOf(dir: string, p: string): string | null {
49
+ const first = relative(dir, resolve(dir, p)).split(/[\\/]/, 1).join("");
50
+ return first === "" || first === ".." ? null : first;
51
+ }
52
+
53
+ /** A preset spec that names a file (`./venue.jsonc`), not a shipped preset (`paperlint:aidc`). */
54
+ const isPresetFile = (spec: string): boolean =>
55
+ spec.startsWith("./") || spec.startsWith("../");
56
+
57
+ /** Every path the settings name relative to the paper: the PDF, the talk folder, its preset files. */
58
+ const pathsOf = (s: PaperSettings): readonly string[] => [
59
+ ...(s.pdf === null ? [] : [s.pdf]),
60
+ ...(s.talk === null ? [] : [s.talk.dir]),
61
+ ...[
62
+ s.extends,
63
+ ...(s.cycles?.list ?? []).map((c) =>
64
+ c.venue.kind === "preset" ? c.venue.extends : null,
65
+ ),
66
+ ].filter((spec): spec is string => spec !== null && isPresetFile(spec)),
67
+ ];
68
+
69
+ /** What the paper's settings name at the root of `dir`, each once; none without readable settings. */
70
+ export function declaredRoots(dir: string, files: Files): readonly string[] {
71
+ const read = readPaperSettings(files, dir);
72
+ if (!read.ok || read.value === null) return [];
73
+ return [
74
+ ...new Set(
75
+ pathsOf(read.value)
76
+ .map((p) => rootOf(dir, posix.normalize(p)))
77
+ .filter((r): r is string => r !== null),
78
+ ),
79
+ ];
80
+ }
81
+
82
+ /** The folder's root entries, each a file or a folder. */
83
+ const entriesOf = (
84
+ dir: string,
85
+ deps: FolderLayoutDeps,
86
+ ): readonly LayoutEntry[] =>
87
+ [...deps.listDir(callerPath(dir))].sort().map((name) => ({
88
+ name,
89
+ kind: deps.files.isFile(callerPath(join(dir, name))) ? "file" : "dir",
90
+ }));
91
+
92
+ /**
93
+ * The text of each stray entry among `entries` of the paper folder `dir` — THE one function behind
94
+ * both the rule and the `paper-folder-layout` hook (`src/folder-layout-hook.ts`), so the two cannot
95
+ * disagree about what is allowed or what to say.
96
+ */
97
+ export const strayTexts = (
98
+ dir: string,
99
+ entries: readonly LayoutEntry[],
100
+ files: Files,
101
+ ): readonly string[] =>
102
+ judgeLayout(entries, { roots: declaredRoots(dir, files) }).map(strayMessage);
103
+
104
+ const META: FolderRuleModule["meta"] = {
105
+ type: "suggestion",
106
+ docs: {
107
+ description:
108
+ "the paper folder's root holds only paperlint's own files and folders; anything else goes into the folder for its kind, or extra/",
109
+ url: rulePageUrl("paper/allowed-files-only"),
110
+ },
111
+ schema: [],
112
+ messages: {
113
+ // The whole text is `strayMessage` (src/domain/paper-layout.ts), the one the hook that fires on
114
+ // writing such an entry sends too.
115
+ stray: "{{text}}",
116
+ },
117
+ };
118
+
119
+ /** The rule, reading the folder and the paper's settings through `deps`. */
120
+ export function folderLayoutRules(
121
+ deps: FolderLayoutDeps,
122
+ ): Readonly<Record<"allowed-files-only", FolderRuleModule>> {
123
+ return {
124
+ "allowed-files-only": {
125
+ meta: META,
126
+ create(context: FolderRuleContext) {
127
+ if (basename(context.filename) !== STATUS_FILE) return {};
128
+ return {
129
+ root() {
130
+ const dir = dirname(context.filename);
131
+ const at = context.sourceCode.getLocFromIndex(0);
132
+ strayTexts(dir, entriesOf(dir, deps), deps.files).forEach(
133
+ (text) => {
134
+ context.report({
135
+ loc: { start: at, end: at },
136
+ messageId: "stray",
137
+ data: { text },
138
+ });
139
+ },
140
+ );
141
+ },
142
+ };
143
+ },
144
+ },
145
+ };
146
+ }
147
+
148
+ /** The level it is on at in paperlint's own config, for every paper's `PIPELINE-STATUS.md`. */
149
+ export const FOLDER_LAYOUT_RULE_LEVELS: Readonly<
150
+ Record<"paper/allowed-files-only", "error">
151
+ > = { "paper/allowed-files-only": "error" };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Both halves for `hooks-settings.ts` — the three hooks written into `.claude/settings.json`.
2
+ * Both halves for `hooks-settings.ts` — the four hooks written into `.claude/settings.json`.
3
3
  *
4
4
  * The merge under test is vigiles' REAL one (`vigiles/claude-code`), not a stand-in: the claims
5
5
  * that matter — a user's hook in the same matcher survives, a second run is byte-identical — are
@@ -65,9 +65,9 @@ check(
65
65
  // ── the one source: plugin/hooks/hooks.json ─────────────────────────────────────────────
66
66
  const wiring = shippedWiring();
67
67
  check(
68
- "the hook names are DERIVED from hooks.json — three, in its order",
68
+ "the hook names are DERIVED from hooks.json — four, in its order",
69
69
  wiring.names.join() ===
70
- "paper-edit-guard,paper-skills-nudge,paper-status-gates",
70
+ "paper-edit-guard,paper-folder-layout,paper-skills-nudge,paper-status-gates",
71
71
  );
72
72
  // ── settings written by hand: shapes Claude Code would ignore are skipped, not crashed on ──
73
73
  check(
@@ -194,7 +194,7 @@ try {
194
194
  const r = wireHooks(dir, merge, wiring);
195
195
  const s = settingsIn(dir);
196
196
  check(
197
- "no settings file → it is created with the three hooks",
197
+ "no settings file → it is created with the four hooks",
198
198
  r.status === "written" &&
199
199
  wiredCounts(s, wiring.names).every(
200
200
  (c) => c.ours === 1 && c.other === 0,
@@ -283,7 +283,8 @@ try {
283
283
  check(
284
284
  "and names the hooks that are not wired in ANY form",
285
285
  r.status === "foreign" &&
286
- r.missing.join() === "paper-skills-nudge,paper-status-gates",
286
+ r.missing.join() ===
287
+ "paper-folder-layout,paper-skills-nudge,paper-status-gates",
287
288
  );
288
289
  // The control: without the check, vigiles' merge WOULD add a second copy. This is the
289
290
  // reason the check exists, measured rather than asserted.
@@ -319,13 +320,13 @@ try {
319
320
  const wiredOnce = merge({}, wiring.compiled, MANAGED_BY);
320
321
  check(
321
322
  "wired once each → ✓ wired, with the fresh-clone consequence",
322
- /✓ wired — paper-edit-guard, paper-skills-nudge, paper-status-gates, once each/.test(
323
+ /✓ wired — paper-edit-guard, paper-folder-layout, paper-skills-nudge, paper-status-gates, once each/.test(
323
324
  doc(wiredOnce),
324
325
  ) && /cannot run until `npm install`/.test(doc(wiredOnce)),
325
326
  );
326
327
  check(
327
328
  "nothing wired → ⚠ not wired, with the remedy",
328
- /⚠ not wired — none of the 3 hooks/.test(doc({})),
329
+ /⚠ not wired — none of the 4 hooks/.test(doc({})),
329
330
  );
330
331
  const twice = PreToolUseSettings.parse(structuredClone(wiredOnce));
331
332
  twice.hooks.PreToolUse[0]?.hooks.push({
@@ -353,7 +354,7 @@ try {
353
354
  });
354
355
  check(
355
356
  "🔴 partly wired BY HAND → the remedy is NOT `npx paperlint init`, which would refuse to write",
356
- /partly wired — missing: paper-skills-nudge, paper-status-gates/.test(
357
+ /partly wired — missing: paper-folder-layout, paper-skills-nudge, paper-status-gates/.test(
357
358
  handPartial,
358
359
  ) && /writes nothing then/.test(handPartial),
359
360
  );
package/src/init.ts CHANGED
@@ -691,7 +691,7 @@ function papersLines(decl: DeclarationResult, why: string): string[] {
691
691
  ];
692
692
  }
693
693
 
694
- // Documented in README.md#install-and-set-up — update it when this changes.
694
+ // Documented in README.md#-getting-started and docs/install.md — update them when this changes.
695
695
  export async function init(
696
696
  dir: string,
697
697
  opts: InitOptions = {},
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The pages draw the layout the code enforces. Each page embeds the drawing between
3
+ * `<!-- layout:begin … -->` and `<!-- layout:end -->`, and that block must equal the render of the
4
+ * constants that decide what is allowed (`PAPER_LAYOUT_TREE`, `REPRO_LAYOUT_TREE`, and
5
+ * `PAPERS_LAYOUT_TREE`, which draws the paper folder beside the venues' corpora) — so a page cannot
6
+ * show an entry the rule reports, or miss one it allows. Both halves: the committed pages match, and
7
+ * a page with one entry changed or with no block at all does not.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { describe, expect, it } from "vitest";
13
+ import {
14
+ PAPER_LAYOUT_TREE,
15
+ PAPERS_LAYOUT_TREE,
16
+ } from "./domain/paper-layout.ts";
17
+ import { REPRO_LAYOUT_TREE } from "./domain/repro-layout.ts";
18
+
19
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
20
+
21
+ const PAGES: readonly (readonly [string, string])[] = [
22
+ ["docs/rules/paper/allowed-files-only.md", PAPER_LAYOUT_TREE],
23
+ ["docs/rules/repro/allowed-files-only.md", REPRO_LAYOUT_TREE],
24
+ ["docs/layout.md", PAPERS_LAYOUT_TREE],
25
+ ];
26
+
27
+ /** The drawing a page embeds, as its fenced block holds it; null when the page has no block. */
28
+ function embedded(page: string): string | null {
29
+ const m =
30
+ /<!-- layout:begin[^\n]*-->\n\n```text\n([\s\S]*?)\n```\n\n<!-- layout:end -->/u.exec(
31
+ page,
32
+ );
33
+ return m === null ? null : (m[1] ?? null);
34
+ }
35
+
36
+ describe("the pages draw the layout the code enforces", () => {
37
+ it.each(PAGES)("%s embeds the current render", (path, tree) => {
38
+ expect(
39
+ embedded(readFileSync(join(ROOT, path), "utf8")),
40
+ `${path}: paste this between its layout markers:\n\n${tree}\n`,
41
+ ).toBe(tree);
42
+ });
43
+
44
+ it("a page whose drawing lost an entry differs from the render", () => {
45
+ const page = `<!-- layout:begin x -->\n\n\`\`\`text\n${PAPER_LAYOUT_TREE.replace(/\n[^\n]*extra\/[^\n]*/u, "")}\n\`\`\`\n\n<!-- layout:end -->\n`;
46
+ expect(embedded(page)).not.toBe(PAPER_LAYOUT_TREE);
47
+ });
48
+
49
+ it("a page with no block has no drawing", () => {
50
+ expect(embedded("# a page\n")).toBeNull();
51
+ });
52
+ });