paperlint 9.0.1 → 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 (150) hide show
  1. package/README.md +68 -128
  2. package/dist/build.d.ts +1 -1
  3. package/dist/build.js +1 -1
  4. package/dist/cli.d.ts +5 -1
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +52 -6
  7. package/dist/cli.js.map +1 -1
  8. package/dist/domain/layout-tree.d.ts +19 -0
  9. package/dist/domain/layout-tree.d.ts.map +1 -0
  10. package/dist/domain/layout-tree.js +32 -0
  11. package/dist/domain/layout-tree.js.map +1 -0
  12. package/dist/domain/lookup-cache.d.ts +2 -2
  13. package/dist/domain/lookup-cache.js +2 -2
  14. package/dist/domain/paper-layout.d.ts +81 -0
  15. package/dist/domain/paper-layout.d.ts.map +1 -0
  16. package/dist/domain/paper-layout.js +259 -0
  17. package/dist/domain/paper-layout.js.map +1 -0
  18. package/dist/domain/repro-layout.d.ts +80 -0
  19. package/dist/domain/repro-layout.d.ts.map +1 -0
  20. package/dist/domain/repro-layout.js +247 -0
  21. package/dist/domain/repro-layout.js.map +1 -0
  22. package/dist/domain/scaffold-layout.d.ts +28 -0
  23. package/dist/domain/scaffold-layout.d.ts.map +1 -0
  24. package/dist/domain/scaffold-layout.js +81 -0
  25. package/dist/domain/scaffold-layout.js.map +1 -0
  26. package/dist/eslint-rules/prereg-coverage.d.ts +98 -0
  27. package/dist/eslint-rules/prereg-coverage.d.ts.map +1 -0
  28. package/dist/eslint-rules/prereg-coverage.js +274 -0
  29. package/dist/eslint-rules/prereg-coverage.js.map +1 -0
  30. package/dist/folder-layout-hook.d.ts +21 -0
  31. package/dist/folder-layout-hook.d.ts.map +1 -0
  32. package/dist/folder-layout-hook.js +117 -0
  33. package/dist/folder-layout-hook.js.map +1 -0
  34. package/dist/folder-layout-rule.d.ts +22 -0
  35. package/dist/folder-layout-rule.d.ts.map +1 -0
  36. package/dist/folder-layout-rule.js +107 -0
  37. package/dist/folder-layout-rule.js.map +1 -0
  38. package/dist/init.js +1 -1
  39. package/dist/init.js.map +1 -1
  40. package/dist/new-paper.d.ts +4 -1
  41. package/dist/new-paper.d.ts.map +1 -1
  42. package/dist/new-paper.js +40 -6
  43. package/dist/new-paper.js.map +1 -1
  44. package/dist/references.d.ts +1 -1
  45. package/dist/references.d.ts.map +1 -1
  46. package/dist/references.js +2 -2
  47. package/dist/references.js.map +1 -1
  48. package/dist/repro-layout-rule.d.ts +16 -0
  49. package/dist/repro-layout-rule.d.ts.map +1 -0
  50. package/dist/repro-layout-rule.js +81 -0
  51. package/dist/repro-layout-rule.js.map +1 -0
  52. package/docs/ci.md +60 -0
  53. package/docs/configuration.md +2 -0
  54. package/docs/e2e.md +2 -2
  55. package/docs/install.md +20 -16
  56. package/docs/layout.md +154 -0
  57. package/docs/optional-rules.md +5 -4
  58. package/docs/references.md +5 -2
  59. package/docs/rules/paper/allowed-files-only.md +192 -0
  60. package/docs/rules/repro/allowed-files-only.md +128 -0
  61. package/docs/rules/study/declare-sample.md +146 -0
  62. package/docs/rules.md +53 -50
  63. package/docs/skills.md +6 -3
  64. package/docs/venues.md +94 -0
  65. package/eslint-rules/prereg-coverage.test.ts +448 -0
  66. package/eslint-rules/prereg-coverage.ts +419 -0
  67. package/lib/skill-trigger-cases.harness.mjs +51 -5
  68. package/lib/skill-trigger-cases.mjs +53 -15
  69. package/package.json +1 -1
  70. package/plugin/hooks/hooks.json +9 -0
  71. package/skills/analyze-sibling-paper/SKILL.md +2 -2
  72. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +1 -1
  73. package/skills/build-benchmark/SKILL.md +14 -1
  74. package/skills/build-benchmark/SKILL.md.spec.ts +13 -0
  75. package/skills/build-label-page/SKILL.md +118 -0
  76. package/skills/build-label-page/SKILL.md.spec.ts +129 -0
  77. package/skills/build-label-page/build-label-page.eval.mjs +15 -0
  78. package/skills/build-label-page/build-label-page.test.ts +959 -0
  79. package/skills/build-label-page/references/checklist.md +148 -0
  80. package/skills/build-label-page/references/example-generator.py +803 -0
  81. package/skills/find-venue/SKILL.md +5 -3
  82. package/skills/find-venue/SKILL.md.spec.ts +4 -2
  83. package/skills/grade-paper-writing/SKILL.md +7 -7
  84. package/skills/grade-paper-writing/SKILL.md.spec.ts +6 -6
  85. package/skills/map-prior-work/SKILL.md +2 -2
  86. package/skills/map-prior-work/SKILL.md.spec.ts +1 -1
  87. package/skills/paper-pipeline/SKILL.md +8 -6
  88. package/skills/paper-pipeline/SKILL.md.spec.ts +7 -5
  89. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +19 -9
  90. package/skills/paper-pipeline/references/acceptance-gate.md +19 -19
  91. package/skills/paper-pipeline/references/artifact-checklist.md +5 -0
  92. package/skills/paper-pipeline/references/pipeline-status-template.md +30 -1
  93. package/skills/paper-pipeline/repro/2026-08-07-fork-check.log +1 -1
  94. package/skills/paper-pipeline/repro/2026-08-07-fork-check2.log +1 -1
  95. package/skills/paper-pipeline/repro/2026-08-07-language-eval-pilot.log +1 -1
  96. package/skills/paper-pipeline/repro/2026-08-07-language-eval-raw.log +1 -1
  97. package/skills/paper-pipeline/repro/2026-08-08-framing-vs-vocabulary-oracle.log +48 -48
  98. package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-A6-oracle.log +8 -8
  99. package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-oracle.log +48 -48
  100. package/skills/paper-pipeline/repro/2026-08-08-parent-replication-gpw.log +2 -2
  101. package/skills/paper-pipeline/repro/2026-08-08-parent-replication.mjs +3 -3
  102. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-preflight-after.log +81 -0
  103. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-preflight-before.log +81 -0
  104. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-setupdiff.log +59 -0
  105. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-trigger.jsonl +1 -0
  106. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-trigger.log +22 -0
  107. package/skills/paper-pipeline/repro/README.md +4 -0
  108. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +2 -2
  109. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +2 -2
  110. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +4 -2
  111. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +7 -2
  112. package/skills/paper-pipeline/scripts/generated-code.mjs +8 -4
  113. package/skills/paper-pipeline/scripts/generated-code.test.ts +15 -1
  114. package/skills/paper-status/SKILL.md +2 -2
  115. package/skills/paper-status/SKILL.md.spec.ts +1 -1
  116. package/skills/study-accepted-papers/SKILL.md +163 -161
  117. package/skills/study-accepted-papers/SKILL.md.spec.ts +167 -165
  118. package/skills/study-accepted-papers/references/base-rates.md +69 -0
  119. package/skills/study-accepted-papers/references/mechanisms.md +181 -0
  120. package/skills/verify-citations/SKILL.md +2 -2
  121. package/skills/verify-citations/SKILL.md.spec.ts +1 -1
  122. package/src/build.ts +1 -1
  123. package/src/cli.harness.mjs +42 -0
  124. package/src/cli.test.ts +90 -6
  125. package/src/cli.ts +78 -5
  126. package/src/domain/layout-tree.test.ts +32 -0
  127. package/src/domain/layout-tree.ts +47 -0
  128. package/src/domain/lookup-cache.ts +2 -2
  129. package/src/domain/paper-layout.test.ts +264 -0
  130. package/src/domain/paper-layout.ts +336 -0
  131. package/src/domain/repro-layout.test.ts +222 -0
  132. package/src/domain/repro-layout.ts +311 -0
  133. package/src/domain/scaffold-layout.ts +104 -0
  134. package/src/folder-layout-hook.test.ts +214 -0
  135. package/src/folder-layout-hook.ts +141 -0
  136. package/src/folder-layout-rule.test.ts +235 -0
  137. package/src/folder-layout-rule.ts +151 -0
  138. package/src/hooks-settings.harness.ts +9 -8
  139. package/src/init.ts +1 -1
  140. package/src/layout-docs.test.ts +52 -0
  141. package/src/new-paper.harness.mjs +23 -1
  142. package/src/new-paper.test.ts +299 -6
  143. package/src/new-paper.ts +51 -5
  144. package/src/paper-files.test.ts +9 -6
  145. package/src/references.test.ts +5 -5
  146. package/src/references.ts +2 -2
  147. package/src/repro-layout-rule.test.ts +114 -0
  148. package/src/repro-layout-rule.ts +121 -0
  149. package/src/rule-cases.test.ts +98 -0
  150. package/templates/paper/PIPELINE-STATUS.md +41 -7
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);
@@ -0,0 +1,114 @@
1
+ /**
2
+ * `repro/allowed-files-only` (`src/repro-layout-rule.ts`) on paper folders held in memory, run the way
3
+ * ESLint runs it: `create`, then `root`. Both halves: each misplaced entry at the top of `repro/` is
4
+ * reported once, on the first line of `PIPELINE-STATUS.md`; the layout, a paper with no `repro/`, and
5
+ * any file but the scorecard are not.
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+ import { memoryFiles, memoryListDir } from "./adapters/memory/index.ts";
9
+ import { reproMessage, SCRIPT_CURE } from "./domain/repro-layout.ts";
10
+ import {
11
+ REPRO_LAYOUT_RULE_LEVELS,
12
+ reproLayoutRules,
13
+ } from "./repro-layout-rule.ts";
14
+
15
+ const DIR = "/work/papers/p";
16
+ const SCAFFOLD = {
17
+ [`${DIR}/PIPELINE-STATUS.md`]: "# status\n",
18
+ [`${DIR}/paper.tex`]: "\\documentclass{article}\n",
19
+ };
20
+
21
+ interface Reported {
22
+ readonly messageId: string;
23
+ readonly message: string;
24
+ readonly line: number;
25
+ }
26
+
27
+ /** The rule over the folder `tree` describes, reached through `file` (default the scorecard). */
28
+ function lint(
29
+ tree: Readonly<Record<string, string>>,
30
+ file = `${DIR}/PIPELINE-STATUS.md`,
31
+ ): Reported[] {
32
+ const files = memoryFiles(tree);
33
+ const rule = reproLayoutRules({ files, listDir: memoryListDir(files) })[
34
+ "allowed-files-only"
35
+ ];
36
+ const out: Reported[] = [];
37
+ const visitor = rule.create({
38
+ filename: file,
39
+ sourceCode: { getLocFromIndex: () => ({ line: 1, column: 0 }) },
40
+ report: (d) => {
41
+ const template = rule.meta.messages[d.messageId] ?? "";
42
+ const message = Object.entries(d.data).reduce(
43
+ (m, [k, v]) => m.split(`{{${k}}}`).join(String(v)),
44
+ template,
45
+ );
46
+ out.push({ messageId: d.messageId, message, line: d.loc.start.line });
47
+ },
48
+ });
49
+ visitor.root?.();
50
+ return out;
51
+ }
52
+
53
+ describe("repro/allowed-files-only — reports", () => {
54
+ it("a loose script, once, on the first line, with the one text", () => {
55
+ expect(lint({ ...SCAFFOLD, [`${DIR}/repro/run.py`]: "x" })).toEqual([
56
+ {
57
+ messageId: "misplaced",
58
+ message: reproMessage({
59
+ path: "repro/run.py",
60
+ problem: "stray",
61
+ cure: SCRIPT_CURE,
62
+ }),
63
+ line: 1,
64
+ },
65
+ ]);
66
+ });
67
+
68
+ it("an undated folder and a study without a README.md, each once, in name order", () => {
69
+ expect(
70
+ lint({
71
+ ...SCAFFOLD,
72
+ [`${DIR}/repro/spike/x.py`]: "x",
73
+ [`${DIR}/repro/ablation-2026-10-12/run.py`]: "x",
74
+ }).map((r) => r.message.split(" ", 1).join("")),
75
+ ).toEqual(["`repro/ablation-2026-10-12/`", "`repro/spike/`"]);
76
+ });
77
+ });
78
+
79
+ describe("repro/allowed-files-only — silent", () => {
80
+ it("on the layout, whatever tools/, data/ and the studies hold", () => {
81
+ expect(
82
+ lint({
83
+ ...SCAFFOLD,
84
+ [`${DIR}/repro/README.md`]: "x",
85
+ [`${DIR}/repro/.gitignore`]: "x",
86
+ [`${DIR}/repro/tools/paper_numbers.py`]: "x",
87
+ [`${DIR}/repro/data/numbers.tsv`]: "x",
88
+ [`${DIR}/repro/data/references-cache.json`]: "{}",
89
+ [`${DIR}/repro/ablation-2026-10-12/README.md`]: "x",
90
+ [`${DIR}/repro/ablation-2026-10-12/loose.py`]: "x",
91
+ [`${DIR}/repro/human-labels-2026-10/README.md`]: "x",
92
+ [`${DIR}/repro/__pycache__/x.pyc`]: "x",
93
+ }),
94
+ ).toEqual([]);
95
+ });
96
+
97
+ it("on a paper with no repro/", () => {
98
+ expect(lint(SCAFFOLD)).toEqual([]);
99
+ });
100
+
101
+ it("on any file but PIPELINE-STATUS.md", () => {
102
+ expect(
103
+ lint({ ...SCAFFOLD, [`${DIR}/repro/run.py`]: "x" }, `${DIR}/paper.tex`),
104
+ ).toEqual([]);
105
+ });
106
+ });
107
+
108
+ describe("repro/allowed-files-only — level", () => {
109
+ it("is an error from the start", () => {
110
+ expect(REPRO_LAYOUT_RULE_LEVELS).toEqual({
111
+ "repro/allowed-files-only": "error",
112
+ });
113
+ });
114
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `repro/allowed-files-only` — the top of a paper's `repro/` holds an index, the shared `tools/` and
3
+ * `data/`, and one dated folder per study, each with a `README.md`.
4
+ *
5
+ * What may stand there, and why, is `src/domain/repro-layout.ts` and docs/rules/repro/allowed-files-only.md.
6
+ * This file lists the folder and reports.
7
+ *
8
+ * ── WHY A RULE OF ITS OWN, NOT MORE OF `paper/allowed-files-only` ─────────────────────
9
+ * Each entry is judged by exactly one rule: `paper/allowed-files-only` judges the paper folder's root,
10
+ * where `repro/` is allowed, and this rule judges what is inside `repro/`. Neither reports an entry
11
+ * the other does, so a stray entry gets one message. A rule of its own also has its own page and its
12
+ * own switch: a paper whose `repro/` predates the layout can turn this one off and keep its root
13
+ * judged.
14
+ *
15
+ * ── WHICH FILE IT REPORTS ON ─────────────────────────────────────────────────────
16
+ * `PIPELINE-STATUS.md`, first line, like `paper/allowed-files-only`: every paper has it, and `repro/` may
17
+ * have no file ESLint lints at all.
18
+ */
19
+ import { basename, dirname, join } from "node:path";
20
+ import { callerPath } from "./caller-path.ts";
21
+ import {
22
+ README,
23
+ REPRO_DIR,
24
+ judgeRepro,
25
+ reproMessage,
26
+ type ReproEntry,
27
+ } from "./domain/repro-layout.ts";
28
+ import type {
29
+ FolderRuleContext,
30
+ FolderRuleModule,
31
+ } from "./folder-venue-rule.ts";
32
+ import type { FolderLayoutDeps } from "./folder-layout-rule.ts";
33
+ import type { Files } from "./ports/files.ts";
34
+ import type { LayoutEntry } from "./domain/paper-layout.ts";
35
+ import { rulePageUrl } from "./venue-rules.ts";
36
+
37
+ const STATUS_FILE = "PIPELINE-STATUS.md";
38
+
39
+ /** `entries` of `<dir>/repro/`, each folder with whether it holds a README.md. */
40
+ const withReadme = (
41
+ dir: string,
42
+ entries: readonly LayoutEntry[],
43
+ files: Files,
44
+ ): readonly ReproEntry[] =>
45
+ entries.map((e) => ({
46
+ ...e,
47
+ hasReadme:
48
+ e.kind === "dir" &&
49
+ files.isFile(callerPath(join(dir, REPRO_DIR, e.name, README))),
50
+ }));
51
+
52
+ /**
53
+ * The text of each finding among `entries` at the top of the paper folder `dir`'s `repro/` — THE one
54
+ * function behind both the rule and the `paper-folder-layout` hook, so the two cannot disagree.
55
+ */
56
+ export const reproTexts = (
57
+ dir: string,
58
+ entries: readonly LayoutEntry[],
59
+ files: Files,
60
+ ): readonly string[] =>
61
+ judgeRepro(withReadme(dir, entries, files)).map(reproMessage);
62
+
63
+ /** The entries at the top of `<dir>/repro/`, each a file or a folder; none without a `repro/`. */
64
+ export const reproEntriesOf = (
65
+ dir: string,
66
+ deps: FolderLayoutDeps,
67
+ ): readonly LayoutEntry[] =>
68
+ [...deps.listDir(callerPath(join(dir, REPRO_DIR)))].sort().map((name) => ({
69
+ name,
70
+ kind: deps.files.isFile(callerPath(join(dir, REPRO_DIR, name)))
71
+ ? "file"
72
+ : "dir",
73
+ }));
74
+
75
+ const META: FolderRuleModule["meta"] = {
76
+ type: "suggestion",
77
+ docs: {
78
+ description:
79
+ "the top of a paper's repro/ holds README.md, .gitignore, tools/, data/ and one <slug>-YYYY-MM-DD folder per study, each with a README.md",
80
+ url: rulePageUrl("repro/allowed-files-only"),
81
+ },
82
+ schema: [],
83
+ messages: {
84
+ // The whole text is `reproMessage` (src/domain/repro-layout.ts), the one the hook sends too.
85
+ misplaced: "{{text}}",
86
+ },
87
+ };
88
+
89
+ /** The rule, reading the folder through `deps`. */
90
+ export function reproLayoutRules(
91
+ deps: FolderLayoutDeps,
92
+ ): Readonly<Record<"allowed-files-only", FolderRuleModule>> {
93
+ return {
94
+ "allowed-files-only": {
95
+ meta: META,
96
+ create(context: FolderRuleContext) {
97
+ if (basename(context.filename) !== STATUS_FILE) return {};
98
+ return {
99
+ root() {
100
+ const dir = dirname(context.filename);
101
+ const at = context.sourceCode.getLocFromIndex(0);
102
+ reproTexts(dir, reproEntriesOf(dir, deps), deps.files).forEach(
103
+ (text) => {
104
+ context.report({
105
+ loc: { start: at, end: at },
106
+ messageId: "misplaced",
107
+ data: { text },
108
+ });
109
+ },
110
+ );
111
+ },
112
+ };
113
+ },
114
+ },
115
+ };
116
+ }
117
+
118
+ /** The level it is on at in paperlint's own config, for every paper's `PIPELINE-STATUS.md`. */
119
+ export const REPRO_LAYOUT_RULE_LEVELS: Readonly<
120
+ Record<"repro/allowed-files-only", "error">
121
+ > = { "repro/allowed-files-only": "error" };
@@ -39,6 +39,14 @@ const paper = (
39
39
  ...extra,
40
40
  });
41
41
 
42
+ /** `study/declare-sample` is off by default; a paper turns it on in its `paperlint.json`. */
43
+ const DECLARE_SAMPLE_ON: Readonly<Record<string, string>> = {
44
+ [`${P}/paperlint.json`]: JSON.stringify({
45
+ extends: null,
46
+ rules: { "study/declare-sample": "error" },
47
+ }),
48
+ };
49
+
42
50
  // ── the venues: presets of the tree's own, never the shipped ones ──────────────────────────────
43
51
  //
44
52
  // A case judges a RULE, so every number it is sized against is written here, beside the facts and
@@ -693,6 +701,78 @@ const CASES: Readonly<Record<string, RuleCases>> = {
693
701
  ...onBlindConf("papers/blindconf-2026"),
694
702
  },
695
703
  },
704
+ // A plan beside the paper: the root holds only paperlint's own files and folders.
705
+ "paper/allowed-files-only": {
706
+ reports: {
707
+ tree: paper(tex("x"), { [`${P}/PLAN.md`]: "# plan\n" }),
708
+ file: `${P}/PIPELINE-STATUS.md`,
709
+ severity: 2,
710
+ line: 1,
711
+ },
712
+ // The same material where the layout puts it.
713
+ silent: paper(tex("x"), {
714
+ [`${P}/research/plan.md`]: "# plan\n",
715
+ [`${P}/extra/PLAN.md`]: "# plan\n",
716
+ }),
717
+ },
718
+ // A loose script at the top of repro/: it belongs in repro/tools/.
719
+ "repro/allowed-files-only": {
720
+ reports: {
721
+ tree: paper(tex("x"), { [`${P}/repro/run.py`]: "print(1)\n" }),
722
+ file: `${P}/PIPELINE-STATUS.md`,
723
+ severity: 2,
724
+ line: 1,
725
+ },
726
+ // The same script where the layout puts it, and a dated study with its README.md.
727
+ silent: paper(tex("x"), {
728
+ [`${P}/repro/README.md`]: "# repro\n",
729
+ [`${P}/repro/tools/run.py`]: "print(1)\n",
730
+ [`${P}/repro/ablation-2026-10-12/README.md`]: "# ablation\n",
731
+ }),
732
+ },
733
+ // A study's prereg.json that declares none of population, strata, screens, coverage, author_check.
734
+ // Off by default: the paper turns it on, as both trees do.
735
+ "study/declare-sample": {
736
+ reports: {
737
+ tree: paper(tex("x"), {
738
+ ...DECLARE_SAMPLE_ON,
739
+ [`${P}/repro/README.md`]: "# repro\n",
740
+ [`${P}/repro/draw-2026-10-12/README.md`]: "# draw\n",
741
+ [`${P}/repro/draw-2026-10-12/prereg.json`]: JSON.stringify({
742
+ hypotheses: ["H1"],
743
+ }),
744
+ }),
745
+ file: `${P}/PIPELINE-STATUS.md`,
746
+ severity: 2,
747
+ line: 1,
748
+ },
749
+ // The same study, declaring them.
750
+ silent: paper(tex("x"), {
751
+ ...DECLARE_SAMPLE_ON,
752
+ [`${P}/repro/README.md`]: "# repro\n",
753
+ [`${P}/repro/draw-2026-10-12/README.md`]: "# draw\n",
754
+ [`${P}/repro/draw-2026-10-12/prereg.json`]: JSON.stringify({
755
+ hypotheses: ["H1"],
756
+ population: {
757
+ about: "rules in instruction files",
758
+ source: "a census, 2026-07-28",
759
+ size: 4210,
760
+ },
761
+ strata: { by: "rule type", values: ["crisp", "vague"] },
762
+ screens: [
763
+ { name: "fits one excerpt", removed: { crisp: 10, vague: 300 } },
764
+ ],
765
+ coverage: { share: 0.3, unreachable: ["vague"] },
766
+ author_check: {
767
+ who: "first author",
768
+ when: "2026-10-12",
769
+ units_seen: 12,
770
+ units_drawn: 12,
771
+ verdict: "representative",
772
+ },
773
+ }),
774
+ }),
775
+ },
696
776
  // EMNLP, a shipped venue's name (REALM's conference), in a BlindConf paper's text.
697
777
  "tex/venue-leftover": {
698
778
  reports: {
@@ -944,6 +1024,24 @@ describe("every registered rule has cases, and every case names a registered rul
944
1024
  });
945
1025
  });
946
1026
 
1027
+ describe("study/declare-sample is off by default", () => {
1028
+ it("is silent on an empty prereg.json until the paper turns it on", async () => {
1029
+ const study = {
1030
+ [`${P}/repro/README.md`]: "# repro\n",
1031
+ [`${P}/repro/draw-2026-10-12/README.md`]: "# draw\n",
1032
+ [`${P}/repro/draw-2026-10-12/prereg.json`]: "{}",
1033
+ };
1034
+ const ids = async (tree: Record<string, string>) =>
1035
+ (await lintTree(tree)).map((m) => m.ruleId);
1036
+ expect(await ids(paper(tex("x"), study))).not.toContain(
1037
+ "study/declare-sample",
1038
+ );
1039
+ expect(
1040
+ await ids(paper(tex("x"), { ...study, ...DECLARE_SAMPLE_ON })),
1041
+ ).toContain("study/declare-sample");
1042
+ });
1043
+ });
1044
+
947
1045
  /**
948
1046
  * A case lints its tree through the whole CLI: a body long enough for a rate to be judged (2 000
949
1047
  * words and more) takes seconds under coverage, on a machine running every suite at once.
@@ -13,7 +13,41 @@ Updated: <ISO>
13
13
  **Readiness verdict:** <one line — submit-ready? if not, the WORST failing gate first. Any failing
14
14
  gate caps "ready"; an accept probability alone is not a verdict.>
15
15
 
16
- **Gates at a glance (worst first — mark any failing gate ✗):** readability(persona stalls) <density/verdict> · structure(tighten-paper) <verdict> · claims-honest(claims) <clean?> · defects(panel) <accept-prob/decision> · writing <NN/60> · content(venue bar) <run?/levers>
16
+ **Gates at a glance (worst first — mark any failing gate ✗):** readability(persona stalls) <density/verdict> · structure(tighten-paper) <verdict> · claims-honest(claims) <clean?> · defects(panel) <accept-prob/decision> · writing <NN/60> · content(strong-accept scorecard) <run?/mode>
17
+
18
+ ## Plan / now
19
+
20
+ The paper's live plan: this file is the paper's one live-state file, so the plan lives here, not in a
21
+ `PLAN.md` beside it (`paper/allowed-files-only` reports one). Rewrite this section as the plan changes; a
22
+ decision's history goes to `research/` as a dated record.
23
+
24
+ - **Now (<ISO date>):** <what is being worked on, and the next step>
25
+ - **Decisions in force:** <the decision — who made it, ISO date, link to the record>
26
+ - **Schedule:** <ISO date — milestone, in the author's own zone>
27
+
28
+ ## Strong-accept scorecard (M1–M10)
29
+
30
+ Filled by `study-accepted-papers`: mode C scores every row, mode D checks M2 and M4 again. What each
31
+ mechanism is, and what it is not, is that skill's `references/mechanisms.md`. The verdicts live only
32
+ here; the dated report in `reviews/` keeps the reasoning and points here.
33
+
34
+ Status: `pass` · `partial` · `fail` · `—` (not scored). A `fail` names an owner and either a plan or
35
+ `accept ceiling: <why>`.
36
+
37
+ | # | mechanism | status | evidence (file:line) | owner | plan or accept ceiling |
38
+ | --- | ----------------------------------------------- | ------ | -------------------- | ----- | ---------------------- |
39
+ | M1 | a non-model oracle under the headline | — | — | — | — |
40
+ | M2 | the claim is aimed at a belief stated on record | — | — | — | — |
41
+ | M3 | the construct is the claim | — | — | — | — |
42
+ | M4 | the denominator in the claim sentence, no drift | — | — | — | — |
43
+ | M5 | a negative or "X alone" claim shape | — | — | — | — |
44
+ | M6 | the generalised-over factor is a column | — | — | — | — |
45
+ | M7 | the phenomenon measured directly | — | — | — | — |
46
+ | M8 | a reusable thing the field pays for once | — | — | — | — |
47
+ | M9 | relevance to every reviewer on the PC | — | — | — | — |
48
+ | M10 | where the paper's own result loses | — | — | — | — |
49
+
50
+ ## Stages
17
51
 
18
52
  ### SETUP
19
53
 
@@ -22,18 +56,18 @@ gate caps "ready"; an accept probability alone is not a verdict.>
22
56
  | idea | Validate idea | research-ideate | ☐ | — | — | — |
23
57
  | priorwork | Map competitors | map-prior-work | ☐ | — | — | siblings/ |
24
58
  | venue | Pick venue | find-venue | ☐ | — | — | — |
25
- | venuebar | Venue bar / levers | study-accepted-papers | ☐ | — | — | — |
59
+ | venuebar | What the venue rewards | study-accepted-papers | ☐ | — | — | — |
26
60
  | **access** | 🔴 **Can you physically submit?** | plan-paper-timeline | ☐ | — | account · profile ACTIVE since <ISO> · portal reachable · form fields known · artifact host known | — |
27
61
  | schedule | Schedule + calendar | plan-paper-timeline | ☐ | — | — | — |
28
62
  | **frame** | **Claim stated before the runs** | argument-arc (frame) | ☐ | — | one paragraph: what will this paper claim? | — |
29
63
 
30
64
  ### LOOP
31
65
 
32
- | id | Work | Skill | Status | Date | Result | Open |
33
- | ----- | ---------------- | --------------- | ------ | ---- | ------ | ---- |
34
- | study | Study + artifact | build-benchmark | ☐ | — | — | — |
35
- | draft | Draft | draft-paper | ☐ | — | — | — |
36
- | arc | Argument arc | argument-arc | ☐ | — | — | — |
66
+ | id | Work | Skill | Status | Date | Result | Open |
67
+ | ----- | ---------------- | --------------- | ------ | ---- | ------ | ------ |
68
+ | study | Study + artifact | build-benchmark | ☐ | — | — | repro/ |
69
+ | draft | Draft | draft-paper | ☐ | — | — | — |
70
+ | arc | Argument arc | argument-arc | ☐ | — | — | — |
37
71
 
38
72
  ### CONTINUOUS
39
73