paperlint 10.3.0 → 10.4.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 (139) hide show
  1. package/.github/workflows/ci.yml +15 -4
  2. package/README.md +1 -0
  3. package/dist/adapters/git/index.d.ts +10 -0
  4. package/dist/adapters/git/index.d.ts.map +1 -0
  5. package/dist/adapters/git/index.js +151 -0
  6. package/dist/adapters/git/index.js.map +1 -0
  7. package/dist/adapters/memory/index.d.ts +6 -0
  8. package/dist/adapters/memory/index.d.ts.map +1 -1
  9. package/dist/adapters/memory/index.js +8 -0
  10. package/dist/adapters/memory/index.js.map +1 -1
  11. package/dist/adapters/node/index.d.ts +1 -0
  12. package/dist/adapters/node/index.d.ts.map +1 -1
  13. package/dist/adapters/node/index.js +1 -0
  14. package/dist/adapters/node/index.js.map +1 -1
  15. package/dist/adapters/node/list-entries.io.d.ts +3 -0
  16. package/dist/adapters/node/list-entries.io.d.ts.map +1 -0
  17. package/dist/adapters/node/list-entries.io.js +24 -0
  18. package/dist/adapters/node/list-entries.io.js.map +1 -0
  19. package/dist/adapters/study-registry/index.d.ts +70 -10
  20. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  21. package/dist/adapters/study-registry/index.js +84 -10
  22. package/dist/adapters/study-registry/index.js.map +1 -1
  23. package/dist/cli.d.ts.map +1 -1
  24. package/dist/cli.js +33 -3
  25. package/dist/cli.js.map +1 -1
  26. package/dist/domain/decide-first.d.ts +100 -0
  27. package/dist/domain/decide-first.d.ts.map +1 -0
  28. package/dist/domain/decide-first.js +102 -0
  29. package/dist/domain/decide-first.js.map +1 -0
  30. package/dist/domain/index-columns.d.ts +45 -0
  31. package/dist/domain/index-columns.d.ts.map +1 -0
  32. package/dist/domain/index-columns.js +62 -0
  33. package/dist/domain/index-columns.js.map +1 -0
  34. package/dist/domain/study-mode.d.ts +37 -0
  35. package/dist/domain/study-mode.d.ts.map +1 -0
  36. package/dist/domain/study-mode.js +45 -0
  37. package/dist/domain/study-mode.js.map +1 -0
  38. package/dist/domain/study-registry.d.ts +57 -10
  39. package/dist/domain/study-registry.d.ts.map +1 -1
  40. package/dist/domain/study-registry.js +106 -30
  41. package/dist/domain/study-registry.js.map +1 -1
  42. package/dist/outputs-before-decision-rule.d.ts +17 -0
  43. package/dist/outputs-before-decision-rule.d.ts.map +1 -0
  44. package/dist/outputs-before-decision-rule.js +90 -0
  45. package/dist/outputs-before-decision-rule.js.map +1 -0
  46. package/dist/ports/git-history.d.ts +44 -0
  47. package/dist/ports/git-history.d.ts.map +1 -0
  48. package/dist/ports/git-history.js +2 -0
  49. package/dist/ports/git-history.js.map +1 -0
  50. package/dist/ports/list-entries.d.ts +17 -0
  51. package/dist/ports/list-entries.d.ts.map +1 -0
  52. package/dist/ports/list-entries.js +2 -0
  53. package/dist/ports/list-entries.js.map +1 -0
  54. package/dist/ports/study-registry-codec.d.ts +6 -3
  55. package/dist/ports/study-registry-codec.d.ts.map +1 -1
  56. package/dist/studies.d.ts +4 -0
  57. package/dist/studies.d.ts.map +1 -1
  58. package/dist/studies.js +50 -22
  59. package/dist/studies.js.map +1 -1
  60. package/dist/study-gate.d.ts +14 -0
  61. package/dist/study-gate.d.ts.map +1 -0
  62. package/dist/study-gate.js +95 -0
  63. package/dist/study-gate.js.map +1 -0
  64. package/dist/study-index-input.d.ts +5 -0
  65. package/dist/study-index-input.d.ts.map +1 -0
  66. package/dist/study-index-input.js +13 -0
  67. package/dist/study-index-input.js.map +1 -0
  68. package/dist/study-modes.d.ts +45 -0
  69. package/dist/study-modes.d.ts.map +1 -0
  70. package/dist/study-modes.js +70 -0
  71. package/dist/study-modes.js.map +1 -0
  72. package/dist/study-registry-rule.d.ts +17 -1
  73. package/dist/study-registry-rule.d.ts.map +1 -1
  74. package/dist/study-registry-rule.js +34 -13
  75. package/dist/study-registry-rule.js.map +1 -1
  76. package/dist/study-run.d.ts +19 -0
  77. package/dist/study-run.d.ts.map +1 -0
  78. package/dist/study-run.js +9 -0
  79. package/dist/study-run.js.map +1 -0
  80. package/docs/design/study-modes.md +247 -0
  81. package/docs/design/study-registry.md +6 -0
  82. package/docs/e2e.md +8 -4
  83. package/docs/layout.md +22 -19
  84. package/docs/rules/repro/outputs-before-decision.md +119 -0
  85. package/docs/rules/repro/study-registered.md +47 -14
  86. package/docs/rules.md +54 -53
  87. package/docs/testing.md +1 -0
  88. package/package.json +2 -1
  89. package/scripts/check.ts +6 -0
  90. package/skills/camera-ready/SKILL.md +6 -1
  91. package/skills/camera-ready/SKILL.md.spec.ts +5 -0
  92. package/skills/draft-paper/SKILL.md +11 -3
  93. package/skills/draft-paper/SKILL.md.spec.ts +10 -2
  94. package/skills/harden-paper/SKILL.md +12 -2
  95. package/skills/harden-paper/SKILL.md.spec.ts +11 -1
  96. package/skills/osf-artifact-upload/SKILL.md +7 -1
  97. package/skills/osf-artifact-upload/SKILL.md.spec.ts +6 -0
  98. package/skills/pc-panel-review/SKILL.md +10 -3
  99. package/skills/pc-panel-review/SKILL.md.spec.ts +9 -2
  100. package/skills/tighten-paper/SKILL.md +5 -1
  101. package/skills/tighten-paper/SKILL.md.spec.ts +4 -0
  102. package/src/adapters/git/history.test.ts +121 -0
  103. package/src/adapters/git/index.test.ts +268 -0
  104. package/src/adapters/git/index.ts +227 -0
  105. package/src/adapters/memory/index.test.ts +15 -1
  106. package/src/adapters/memory/index.ts +13 -0
  107. package/src/adapters/node/index.ts +1 -0
  108. package/src/adapters/node/list-entries.io.ts +26 -0
  109. package/src/adapters/node/list-entries.test.ts +43 -0
  110. package/src/adapters/study-registry/index.test.ts +12 -9
  111. package/src/adapters/study-registry/index.ts +130 -24
  112. package/src/adapters/study-registry/mode.test.ts +162 -0
  113. package/src/cli.ts +47 -3
  114. package/src/domain/decide-first.test.ts +258 -0
  115. package/src/domain/decide-first.ts +243 -0
  116. package/src/domain/index-columns.test.ts +216 -0
  117. package/src/domain/index-columns.ts +105 -0
  118. package/src/domain/study-measurements.test.ts +9 -5
  119. package/src/domain/study-mode.test.ts +55 -0
  120. package/src/domain/study-mode.ts +78 -0
  121. package/src/domain/study-registry.test.ts +38 -31
  122. package/src/domain/study-registry.ts +170 -31
  123. package/src/new-paper.test.ts +5 -1
  124. package/src/outputs-before-decision-rule.test.ts +206 -0
  125. package/src/outputs-before-decision-rule.ts +124 -0
  126. package/src/ports/git-history.ts +44 -0
  127. package/src/ports/list-entries.ts +18 -0
  128. package/src/ports/study-registry-codec.ts +10 -3
  129. package/src/rule-cases.test.ts +78 -3
  130. package/src/studies-index-modes.test.ts +129 -0
  131. package/src/studies.test.ts +35 -11
  132. package/src/studies.ts +88 -27
  133. package/src/study-gate.test.ts +316 -0
  134. package/src/study-gate.ts +161 -0
  135. package/src/study-index-input.ts +23 -0
  136. package/src/study-modes.ts +120 -0
  137. package/src/study-registry-rule.test.ts +198 -11
  138. package/src/study-registry-rule.ts +51 -14
  139. package/src/study-run.ts +21 -0
@@ -0,0 +1,78 @@
1
+ /**
2
+ * A STUDY'S MODE — how its outcomes are kept from the decisions about its inputs. A measurement study
3
+ * declares exactly one (docs/design/study-modes.md):
4
+ *
5
+ * decide_before_run the default: the author's decisions on the inputs (exclusions, veto, the
6
+ * sample's approval) are committed BEFORE any output exists. No key, no extra
7
+ * tool: git's history is the proof of order, and
8
+ * `repro/outputs-before-decision` re-checks it on every lint.
9
+ * exploratory no gate: the study is exploratory and is reported as such, with its reason.
10
+ *
11
+ * "None" and "two at once" cannot be written: the plan holds one `mode`, a union on its `kind`, and a
12
+ * measurement study's plan is not ready without it (the parser, `src/adapters/study-registry/`).
13
+ *
14
+ * Paths: `outputs` are globs relative to the study's folder (the study's results live there); the
15
+ * decision's `file` is relative to the PAPER's folder, so a decision may live beside the study or
16
+ * anywhere in the paper (`research/decisions.md`). Pure.
17
+ */
18
+ import { posix } from "node:path";
19
+ import { REPRO_DIR } from "./repro-layout.ts";
20
+ import type { StudyKind } from "./study-registry.ts";
21
+
22
+ /** A decision recorded in a file: where, and what it decides. */
23
+ export interface Decision {
24
+ /** Relative to the paper's folder. */
25
+ readonly file: string;
26
+ readonly note: string;
27
+ }
28
+
29
+ /** Decide first: the decision on the inputs is committed before any output exists. */
30
+ export interface DecideFirst {
31
+ readonly kind: "decide_before_run";
32
+ /** Globs relative to the study's folder: the files the run produces. */
33
+ readonly outputs: readonly string[];
34
+ readonly decision: Decision;
35
+ }
36
+
37
+ /** Exploratory: no gate, and reported as exploratory. */
38
+ export interface Exploratory {
39
+ readonly kind: "exploratory";
40
+ readonly reason: string;
41
+ }
42
+
43
+ export type StudyMode = DecideFirst | Exploratory;
44
+
45
+ /**
46
+ * The kinds of study whose plan must declare a mode: the ones that measure. A spike, a tool, a data
47
+ * set, an artifact or a salvage may omit it.
48
+ */
49
+ export const MODE_REQUIRED: readonly StudyKind[] = ["measurement"];
50
+
51
+ /** Whether a study of `kind` must declare its mode. */
52
+ export const needsMode = (kind: StudyKind): boolean =>
53
+ MODE_REQUIRED.includes(kind);
54
+
55
+ /** `paperPath` relative to the study folder `folder`, when it lies inside it; else null. */
56
+ export const inStudy = (folder: string, paperPath: string): string | null => {
57
+ const prefix = `${REPRO_DIR}/${folder}/`;
58
+ const n = posix.normalize(paperPath);
59
+ return n.startsWith(prefix) ? n.slice(prefix.length) : null;
60
+ };
61
+
62
+ /** Whether the file at `path` (relative to the study's folder) is one of `outputs`. */
63
+ export const isOutput = (outputs: readonly string[], path: string): boolean =>
64
+ outputs.some((g) => posix.matchesGlob(path, g));
65
+
66
+ /**
67
+ * The output glob that matches the study's own decision file, when the file lies in the study's
68
+ * folder: the decision would be "produced" by the run it gates.
69
+ */
70
+ export const decisionAsOutput = (
71
+ folder: string,
72
+ m: DecideFirst,
73
+ ): string | null => {
74
+ const rel = inStudy(folder, m.decision.file);
75
+ return rel === null
76
+ ? null
77
+ : (m.outputs.find((g) => posix.matchesGlob(rel, g)) ?? null);
78
+ };
@@ -25,8 +25,12 @@ import {
25
25
  type Study,
26
26
  type StudyInput,
27
27
  type StudyRegistry,
28
+ type IndexInput,
28
29
  } from "./study-registry.ts";
29
30
 
31
+ /** No plan and no history: the index with every mode column `—`. */
32
+ const NO_FACTS: IndexInput = { plans: new Map(), history: new Map() };
33
+
30
34
  const study = (over: Partial<Study> = {}): Study => ({
31
35
  id: "P1-S01",
32
36
  folder: "P1-S01-baseline",
@@ -56,7 +60,7 @@ const input = (
56
60
  ): StudyInput => ({
57
61
  folders: r.studies.map((s) => s.folder),
58
62
  registry: read(r),
59
- readme: withIndex("# Reproduction index\n", r),
63
+ readme: withIndex("# Reproduction index\n", r, NO_FACTS),
60
64
  existingResults: new Set(),
61
65
  plans: new Map(),
62
66
  ...over,
@@ -310,16 +314,16 @@ describe("judgeStudies — the index", () => {
310
314
 
311
315
  it("a table prettier padded by display width, with wide characters, is in sync", async () => {
312
316
  const wide = reg(study({ title: "測定 A" }));
313
- const readme = await format(withIndex("# index\n", wide), {
317
+ const readme = await format(withIndex("# index\n", wide, NO_FACTS), {
314
318
  parser: "markdown",
315
319
  });
316
- expect(readme).not.toBe(withIndex("# index\n", wide));
320
+ expect(readme).not.toBe(withIndex("# index\n", wide, NO_FACTS));
317
321
  expect(judgeStudies(input(wide, { readme }))).toEqual([]);
318
322
  });
319
323
 
320
324
  it("a title with a backslash before a pipe stays one cell through prettier", async () => {
321
325
  const bs = reg(study({ title: "foo\\|bar" }));
322
- const readme = await format(withIndex("# index\n", bs), {
326
+ const readme = await format(withIndex("# index\n", bs, NO_FACTS), {
323
327
  parser: "markdown",
324
328
  });
325
329
  expect(judgeStudies(input(bs, { readme }))).toEqual([]);
@@ -328,22 +332,25 @@ describe("judgeStudies — the index", () => {
328
332
 
329
333
  it("a title carrying the end marker's text: in sync, and regenerating changes nothing", () => {
330
334
  const m = reg(study({ title: `x ${INDEX_END} y` }));
331
- const readme = withIndex("# index\n", m);
335
+ const readme = withIndex("# index\n", m, NO_FACTS);
332
336
  expect(judgeStudies(input(m, { readme }))).toEqual([]);
333
- expect(withIndex(readme, m)).toBe(readme);
337
+ expect(withIndex(readme, m, NO_FACTS)).toBe(readme);
334
338
  });
335
339
 
336
340
  it("prose that mentions the begin marker is kept, and the real block is the one judged", () => {
337
341
  const r1 = reg(study());
338
342
  const prose = `# index\n\nThe table between \`${INDEX_BEGIN}\` and its end marker is generated.\n`;
339
- const readme = withIndex(prose, r1);
343
+ const readme = withIndex(prose, r1, NO_FACTS);
340
344
  expect(judgeStudies(input(r1, { readme }))).toEqual([]);
341
- expect(withIndex(readme, r1)).toBe(readme);
345
+ expect(withIndex(readme, r1, NO_FACTS)).toBe(readme);
342
346
  expect(readme.startsWith(prose.trimEnd())).toBe(true);
343
347
  });
344
348
 
345
349
  it("a block that differs from the registry by one character", () => {
346
- const readme = withIndex("# index\n", r).replace("designing", "running");
350
+ const readme = withIndex("# index\n", r, NO_FACTS).replace(
351
+ "designing",
352
+ "running",
353
+ );
347
354
  expect(judgeStudies(input(r, { readme }))).toEqual([
348
355
  { problem: "stale-index", why: "differs" },
349
356
  ]);
@@ -363,12 +370,12 @@ describe("the index", () => {
363
370
  in_paper: "yes",
364
371
  }),
365
372
  );
366
- expect(studyIndex(r)).toBe(
373
+ expect(studyIndex(r, NO_FACTS)).toBe(
367
374
  [
368
- "| id | title | status | result | in paper | folder |",
369
- "| ------ | ------------------- | --------- | -------- | -------- | ------------------------------------ |",
370
- "| P1-S01 | Baseline pass rate | designing | — | no | [P1-S01-baseline/](P1-S01-baseline/) |",
371
- "| P1-S02 | Pipes \\| and breaks | done | 41 of 60 | yes | [P1-S02-a-b/](P1-S02-a-b/) |",
375
+ "| id | title | status | result | in paper | mode | decision | first output | folder |",
376
+ "| ------ | ------------------- | --------- | -------- | -------- | ---- | -------- | ------------ | ------------------------------------ |",
377
+ "| P1-S01 | Baseline pass rate | designing | — | no | — | — | — | [P1-S01-baseline/](P1-S01-baseline/) |",
378
+ "| P1-S02 | Pipes \\| and breaks | done | 41 of 60 | yes | — | — | — | [P1-S02-a-b/](P1-S02-a-b/) |",
372
379
  ].join("\n"),
373
380
  );
374
381
  });
@@ -390,30 +397,30 @@ describe("the index", () => {
390
397
  }),
391
398
  ].slice(0, n),
392
399
  );
393
- const md = withIndex("# Reproduction index\n\nSome text.\n", r);
400
+ const md = withIndex("# Reproduction index\n\nSome text.\n", r, NO_FACTS);
394
401
  expect(await format(md, { parser: "markdown" })).toBe(md);
395
402
  });
396
403
  });
397
404
 
398
405
  describe("the index of an empty registry, and its markers", () => {
399
406
  it("of an empty registry is the header alone", () => {
400
- expect(studyIndex(reg())).toBe(
407
+ expect(studyIndex(reg(), NO_FACTS)).toBe(
401
408
  [
402
- "| id | title | status | result | in paper | folder |",
403
- "| --- | ----- | ------ | ------ | -------- | ------ |",
409
+ "| id | title | status | result | in paper | mode | decision | first output | folder |",
410
+ "| --- | ----- | ------ | ------ | -------- | ---- | -------- | ------------ | ------ |",
404
411
  ].join("\n"),
405
412
  );
406
413
  });
407
414
 
408
415
  it("sits between the two markers, a blank line on each side", () => {
409
- expect(indexBlock(reg())).toBe(
410
- `${INDEX_BEGIN}\n\n${studyIndex(reg())}\n\n${INDEX_END}`,
416
+ expect(indexBlock(reg(), NO_FACTS)).toBe(
417
+ `${INDEX_BEGIN}\n\n${studyIndex(reg(), NO_FACTS)}\n\n${INDEX_END}`,
411
418
  );
412
419
  });
413
420
 
414
421
  it("is read back from a README as the text between the markers, or null", () => {
415
- expect(embeddedIndex(`# x\n\n${indexBlock(reg())}\n`)).toBe(
416
- `\n\n${studyIndex(reg())}\n\n`,
422
+ expect(embeddedIndex(`# x\n\n${indexBlock(reg(), NO_FACTS)}\n`)).toBe(
423
+ `\n\n${studyIndex(reg(), NO_FACTS)}\n\n`,
417
424
  );
418
425
  expect(embeddedIndex("# x\n")).toBeNull();
419
426
  expect(embeddedIndex(`${INDEX_END}\n${INDEX_BEGIN}\n`)).toBeNull();
@@ -424,27 +431,27 @@ describe("withIndex", () => {
424
431
  const r = reg(study());
425
432
 
426
433
  it("replaces the block in place and keeps the rest", () => {
427
- const before = `# Index\n\nintro\n\n${indexBlock(reg())}\n\ntail\n`;
428
- expect(withIndex(before, r)).toBe(
429
- `# Index\n\nintro\n\n${indexBlock(r)}\n\ntail\n`,
434
+ const before = `# Index\n\nintro\n\n${indexBlock(reg(), NO_FACTS)}\n\ntail\n`;
435
+ expect(withIndex(before, r, NO_FACTS)).toBe(
436
+ `# Index\n\nintro\n\n${indexBlock(r, NO_FACTS)}\n\ntail\n`,
430
437
  );
431
438
  });
432
439
 
433
440
  it("appends the block to a README without markers", () => {
434
- expect(withIndex("# Index\n\nintro", r)).toBe(
435
- `# Index\n\nintro\n\n${indexBlock(r)}\n`,
441
+ expect(withIndex("# Index\n\nintro", r, NO_FACTS)).toBe(
442
+ `# Index\n\nintro\n\n${indexBlock(r, NO_FACTS)}\n`,
436
443
  );
437
444
  });
438
445
 
439
446
  it("writes a README when there is none", () => {
440
- expect(withIndex(null, r)).toBe(
441
- `# Reproduction index\n\n${indexBlock(r)}\n`,
447
+ expect(withIndex(null, r, NO_FACTS)).toBe(
448
+ `# Reproduction index\n\n${indexBlock(r, NO_FACTS)}\n`,
442
449
  );
443
450
  });
444
451
 
445
452
  it("is a fixed point: a second run changes nothing", () => {
446
- const once = withIndex("# Index\n", r);
447
- expect(withIndex(once, r)).toBe(once);
453
+ const once = withIndex("# Index\n", r, NO_FACTS);
454
+ expect(withIndex(once, r, NO_FACTS)).toBe(once);
448
455
  });
449
456
  });
450
457
 
@@ -10,12 +10,22 @@
10
10
  * ── JSON, NEVER MARKDOWN ─────────────────────────────────────────────────────────
11
11
  * The registry is data: its text is parsed once, where it enters, against the schema in
12
12
  * `src/adapters/study-registry/` (`StudyRegistryCodec`). The index in `repro/README.md` is OUTPUT: it is
13
- * generated from the registry and compared as a string, between two fixed markers. Nothing parses it.
13
+ * generated from the registry, the plans' modes and git's history (src/domain/index-columns.ts), and
14
+ * compared cell by cell between two fixed markers, the two columns read from git left out. Nothing
15
+ * else parses it.
14
16
  *
15
17
  * Pure: the caller lists `repro/`, reads the registry and the README, and says which result files exist.
16
18
  */
17
19
  import { isAbsolute, posix, win32 } from "node:path";
18
20
  import { STUDIES_FILE, STUDY_SLUG } from "./repro-layout.ts";
21
+ import { needsMode, type StudyMode } from "./study-mode.ts";
22
+ import {
23
+ decisionCell,
24
+ firstOutputCell,
25
+ modeCell,
26
+ type IndexFacts,
27
+ type IndexHistory,
28
+ } from "./index-columns.ts";
19
29
 
20
30
  /** The registry, at the top of the paper's `repro/`; the layout allows it there. */
21
31
  export { STUDIES_FILE };
@@ -172,6 +182,11 @@ export interface StudyPlan {
172
182
  readonly author_check: AuthorCheck | null;
173
183
  /** The paper's own keys: hypotheses, models, budget … Kept, never read; none is a plan field. */
174
184
  readonly extra?: Readonly<Record<string, unknown>> | undefined;
185
+ /**
186
+ * How the outcomes are kept from the decisions on the inputs: decided before the run, or
187
+ * exploratory (src/domain/study-mode.ts). A measurement study's plan is not ready without it.
188
+ */
189
+ readonly mode?: StudyMode | undefined;
175
190
  }
176
191
 
177
192
  /**
@@ -200,6 +215,7 @@ export const PLAN_FIELDS = [
200
215
  "primary_outcome",
201
216
  "author_check",
202
217
  "extra",
218
+ "mode",
203
219
  ] as const;
204
220
 
205
221
  /** A plan with empty required texts: the author has not approved it. */
@@ -211,6 +227,11 @@ export const PLAN_TEXT_FIELDS = [
211
227
  "population.source",
212
228
  "population.draw.method",
213
229
  "primary_outcome",
230
+ "mode",
231
+ "mode.outputs",
232
+ "mode.decision.file",
233
+ "mode.decision.note",
234
+ "mode.reason",
214
235
  ] as const;
215
236
  export type PlanTextField = (typeof PLAN_TEXT_FIELDS)[number];
216
237
 
@@ -230,16 +251,34 @@ export type ParsedPlan =
230
251
  /** What a study's folder holds of the plan its row names: absent, or what its text parsed to. */
231
252
  export type PlanState = { readonly kind: "absent" } | ParsedPlan;
232
253
 
254
+ /** The decision file `paperlint studies new` names for a study in `folder`, relative to the paper. */
255
+ export const decisionPath = (folder: string): string =>
256
+ `repro/${folder}/${DECISION_FILE}`;
257
+
258
+ /** The decision file's name, beside the plan. */
259
+ export const DECISION_FILE = "decision.md";
260
+
233
261
  /**
234
- * The plan `paperlint studies new` writes: every field present, its value to fill. Its draw is of
235
- * all the population (a census); a sampled study changes it to `{ "kind": "seeded", "method", "seed" }`.
262
+ * The plan `paperlint studies new` writes for a study of `kind` in `folder`: every field present, its
263
+ * value to fill. Its draw is of all the population (a census); a sampled study changes it to
264
+ * `{ "kind": "seeded", "method", "seed" }`. A study that must declare its mode gets the default one,
265
+ * decided before the run, its decision file named and its outputs and note to fill.
236
266
  */
237
- export const planTemplate = (): DraftPlan => ({
267
+ export const planTemplate = (kind: StudyKind, folder: string): DraftPlan => ({
238
268
  question: "",
239
269
  population: { source: "", draw: { kind: "all", method: "" } },
240
270
  arms: [],
241
271
  primary_outcome: "",
242
272
  author_check: null,
273
+ ...(needsMode(kind)
274
+ ? {
275
+ mode: {
276
+ kind: "decide_before_run",
277
+ outputs: [],
278
+ decision: { file: decisionPath(folder), note: "" },
279
+ },
280
+ }
281
+ : {}),
243
282
  });
244
283
 
245
284
  /** A plan's text as `paperlint studies new` writes it: JSON, two-space indented, a final newline. */
@@ -425,20 +464,40 @@ export const titleWords = (title: string): number =>
425
464
  * Whether `path` stays inside the folder it is relative to. A backslash or a Windows absolute path
426
465
  * is refused outright: `posix.normalize` does not resolve `..\\`, while the host's `join` may.
427
466
  */
428
- const isInside = (path: string): boolean => {
467
+ export const isInside = (path: string): boolean => {
429
468
  if (path.includes("\\") || isAbsolute(path) || win32.isAbsolute(path))
430
469
  return false;
431
470
  const n = posix.normalize(path);
432
471
  return n !== ".." && !n.startsWith("../");
433
472
  };
434
473
 
474
+ /**
475
+ * Whether `folder` names one folder directly inside `repro/`: not empty, not `.` or `..`, and no
476
+ * separator or NUL. Every command and rule that reads a study's folder reads only such a one: a row
477
+ * naming `../x` would have them read and write outside the paper.
478
+ */
479
+ export const isFolderName = (folder: string): boolean =>
480
+ folder !== "." && folder !== ".." && /^[^/\\\0]+$/u.test(folder);
481
+
435
482
  /**
436
483
  * The `<folder>/<plan>` of each row whose plan lies inside its folder: the files the judge needs read
437
484
  * (`StudyInput.plans`). A plan outside its folder is reported without being read.
438
485
  */
439
486
  export const planKeys = (r: StudyRegistry): readonly string[] =>
487
+ planRefs(r).map((p) => p.key);
488
+
489
+ /** Each plan `planKeys` names, with the kind of the study whose row names it: what its parser needs. */
490
+ export const planRefs = (
491
+ r: StudyRegistry,
492
+ ): readonly {
493
+ readonly key: string;
494
+ readonly folder: string;
495
+ readonly kind: StudyKind;
496
+ }[] =>
440
497
  r.studies.flatMap((s) =>
441
- s.plan !== undefined && isInside(s.plan) ? [`${s.folder}/${s.plan}`] : [],
498
+ s.plan !== undefined && isInside(s.plan) && isFolderName(s.folder)
499
+ ? [{ key: `${s.folder}/${s.plan}`, folder: s.folder, kind: s.kind }]
500
+ : [],
442
501
  );
443
502
 
444
503
  /** One row of a registry, with what the judge read beside it. */
@@ -695,11 +754,12 @@ const judgeRow = (row: Row): readonly StudyFinding[] =>
695
754
  function judgeIndex(
696
755
  r: StudyRegistry,
697
756
  readme: string | null,
757
+ plans: ReadonlyMap<string, PlanState>,
698
758
  ): StudyFinding | null {
699
759
  if (readme === null) return { problem: "stale-index", why: "no-readme" };
700
760
  const block = embeddedIndex(readme);
701
761
  if (block === null) return { problem: "stale-index", why: "no-markers" };
702
- return indexInSync(readme, r)
762
+ return indexInSync(readme, r, { plans, history: new Map() })
703
763
  ? null
704
764
  : { problem: "stale-index", why: "differs" };
705
765
  }
@@ -718,7 +778,7 @@ export function judgeStudies(input: StudyInput): readonly StudyFinding[] {
718
778
  return [{ problem: "unreadable", why: state.why }];
719
779
  const r = state.registry;
720
780
  const next = nextStudyId(r);
721
- const index = judgeIndex(r, input.readme);
781
+ const index = judgeIndex(r, input.readme, input.plans);
722
782
  return [
723
783
  ...input.folders
724
784
  .filter((f) => !r.studies.some((s) => s.folder === f))
@@ -739,19 +799,65 @@ export function judgeStudies(input: StudyInput): readonly StudyFinding[] {
739
799
  const cell = (s: string): string =>
740
800
  s.replace(/\r?\n/gu, " ").replace(/\\/gu, "\\\\").replace(/\|/gu, "\\|");
741
801
 
742
- /** The columns of the index: each one's header, and its cell for a study. */
802
+ /**
803
+ * What the index is generated from beyond the registry: the plans by `<folder>/<plan>` (as
804
+ * `StudyInput.plans`), and the history of each decide-first study by id. A study with no history
805
+ * listed shows `not read` — the lint, which never compares those columns, asks none.
806
+ */
807
+ export interface IndexInput {
808
+ readonly plans: ReadonlyMap<string, PlanState>;
809
+ readonly history: ReadonlyMap<string, IndexHistory>;
810
+ }
811
+
812
+ /** The mode the plan of `s` declares; null when it names none, or the plan is absent or unreadable. */
813
+ const declaredMode = (
814
+ s: Study,
815
+ plans: ReadonlyMap<string, PlanState>,
816
+ ): StudyMode | null => {
817
+ const state =
818
+ s.plan !== undefined && isInside(s.plan)
819
+ ? plans.get(`${s.folder}/${s.plan}`)
820
+ : undefined;
821
+ return state === undefined ||
822
+ state.kind === "absent" ||
823
+ state.kind === "unreadable"
824
+ ? null
825
+ : (state.plan.mode ?? null);
826
+ };
827
+
828
+ /** What the index shows of `s` beyond its row. */
829
+ const factsOf = (s: Study, at: IndexInput): IndexFacts => ({
830
+ mode: declaredMode(s, at.plans),
831
+ history: at.history.get(s.id) ?? null,
832
+ });
833
+
834
+ /**
835
+ * The columns of the index: each one's header, its cell for a study, and whether git's history is
836
+ * where the cell comes from — those two are never compared (src/domain/index-columns.ts).
837
+ */
743
838
  const COLUMNS: readonly {
744
839
  readonly head: string;
745
- readonly of: (s: Study) => string;
840
+ readonly of: (s: Study, f: IndexFacts) => string;
841
+ readonly fromGit: boolean;
746
842
  }[] = [
747
- { head: "id", of: (s) => s.id },
748
- { head: "title", of: (s) => cell(s.title) },
749
- { head: "status", of: (s) => s.status },
750
- { head: "result", of: (s) => cell(s.result ?? "—") },
751
- { head: "in paper", of: (s) => s.in_paper },
752
- { head: "folder", of: (s) => `[${s.folder}/](${s.folder}/)` },
843
+ { head: "id", of: (s) => s.id, fromGit: false },
844
+ { head: "title", of: (s) => cell(s.title), fromGit: false },
845
+ { head: "status", of: (s) => s.status, fromGit: false },
846
+ { head: "result", of: (s) => cell(s.result ?? "—"), fromGit: false },
847
+ { head: "in paper", of: (s) => s.in_paper, fromGit: false },
848
+ { head: "mode", of: (_, f) => cell(modeCell(f)), fromGit: false },
849
+ { head: "decision", of: (_, f) => decisionCell(f), fromGit: true },
850
+ { head: "first output", of: (_, f) => firstOutputCell(f), fromGit: true },
851
+ {
852
+ head: "folder",
853
+ of: (s) => `[${s.folder}/](${s.folder}/)`,
854
+ fromGit: false,
855
+ },
753
856
  ];
754
857
 
858
+ /** The positions of the columns read from git: left out when an index is compared. */
859
+ const FROM_GIT = COLUMNS.flatMap((c, i) => (c.fromGit ? [i] : []));
860
+
755
861
  /** The width a cell takes: its code points. */
756
862
  const widthOf = (s: string): number => Array.from(s).length;
757
863
 
@@ -764,20 +870,23 @@ const padded = (t: string, width: number): string =>
764
870
  * widest cell (at least 3), the way prettier writes a table — so formatting the README leaves it as
765
871
  * it is.
766
872
  */
767
- export function studyIndex(r: StudyRegistry): string {
873
+ export function studyIndex(r: StudyRegistry, at: IndexInput): string {
874
+ const studies = r.studies.map((s) => ({ s, f: factsOf(s, at) }));
768
875
  const sized = COLUMNS.map((c) => ({
769
876
  ...c,
770
877
  width: Math.max(
771
878
  3,
772
879
  widthOf(c.head),
773
- ...r.studies.map((s) => widthOf(c.of(s))),
880
+ ...studies.map(({ s, f }) => widthOf(c.of(s, f))),
774
881
  ),
775
882
  }));
776
883
  const line = (cells: readonly string[]): string => `| ${cells.join(" | ")} |`;
777
884
  return [
778
885
  line(sized.map((c) => padded(c.head, c.width))),
779
886
  line(sized.map((c) => "-".repeat(c.width))),
780
- ...r.studies.map((s) => line(sized.map((c) => padded(c.of(s), c.width)))),
887
+ ...studies.map(({ s, f }) =>
888
+ line(sized.map((c) => padded(c.of(s, f), c.width))),
889
+ ),
781
890
  ].join("\n");
782
891
  }
783
892
 
@@ -786,7 +895,7 @@ export function studyIndex(r: StudyRegistry): string {
786
895
  * `---`. The index is judged on this, because the padding belongs to the formatter: prettier sizes a
787
896
  * column by display width, so a wide character pads differently from a code-point count.
788
897
  */
789
- const cellsOf = (table: string): string =>
898
+ const cellsOf = (table: string, skip: readonly number[]): string =>
790
899
  table
791
900
  .trim()
792
901
  .split("\n")
@@ -796,19 +905,44 @@ const cellsOf = (table: string): string =>
796
905
  .replace(/^\||\|$/gu, "")
797
906
  .split(/(?<!\\)\|/u)
798
907
  .map((c) => c.trim().replace(/^:?-{3,}:?$/u, "---"))
908
+ .filter((_, i) => !skip.includes(i))
799
909
  .join("|"),
800
910
  )
801
911
  .join("\n");
802
912
 
803
- /** Whether `readme` holds the index of `r`, whatever padding a formatter gave its table. */
804
- export function indexInSync(readme: string | null, r: StudyRegistry): boolean {
913
+ /** Whether `readme` holds the index of `r` in every column but those at `skip`, whatever its padding. */
914
+ const sameCells = (
915
+ readme: string | null,
916
+ index: string,
917
+ skip: readonly number[],
918
+ ): boolean => {
805
919
  const block = readme === null ? null : embeddedIndex(readme);
806
- return block !== null && cellsOf(block) === cellsOf(studyIndex(r));
807
- }
920
+ return block !== null && cellsOf(block, skip) === cellsOf(index, skip);
921
+ };
922
+
923
+ /**
924
+ * Whether `readme` holds the index of `r`, whatever padding a formatter gave its table and whatever
925
+ * its two columns read from git say: what `repro/study-registered` checks.
926
+ */
927
+ export const indexInSync = (
928
+ readme: string | null,
929
+ r: StudyRegistry,
930
+ at: IndexInput,
931
+ ): boolean => sameCells(readme, studyIndex(r, at), FROM_GIT);
932
+
933
+ /**
934
+ * Whether `readme` holds the index of `r` in every column, whatever its padding: when it does,
935
+ * `studies index` leaves the README as it is, so the padding a formatter gave the table stays.
936
+ */
937
+ export const indexCurrent = (
938
+ readme: string | null,
939
+ r: StudyRegistry,
940
+ at: IndexInput,
941
+ ): boolean => sameCells(readme, studyIndex(r, at), []);
808
942
 
809
943
  /** The index between its markers, a blank line on each side. */
810
- export const indexBlock = (r: StudyRegistry): string =>
811
- `${INDEX_BEGIN}\n\n${studyIndex(r)}\n\n${INDEX_END}`;
944
+ export const indexBlock = (r: StudyRegistry, at: IndexInput): string =>
945
+ `${INDEX_BEGIN}\n\n${studyIndex(r, at)}\n\n${INDEX_END}`;
812
946
 
813
947
  /**
814
948
  * Where `marker` stands alone on a line of `text`, at or after `from`; -1 when it does not. A marker
@@ -832,13 +966,18 @@ export function embeddedIndex(readme: string): string | null {
832
966
  * `readme` with its index regenerated from `r`: the block replaced in place, appended when the README
833
967
  * has none, a README of its own when there is none.
834
968
  */
835
- export function withIndex(readme: string | null, r: StudyRegistry): string {
836
- if (readme === null) return `${INDEX_HEADING}\n\n${indexBlock(r)}\n`;
969
+ export function withIndex(
970
+ readme: string | null,
971
+ r: StudyRegistry,
972
+ at: IndexInput,
973
+ ): string {
974
+ const fresh = indexBlock(r, at);
975
+ if (readme === null) return `${INDEX_HEADING}\n\n${fresh}\n`;
837
976
  const block = embeddedIndex(readme);
838
- if (block === null) return `${readme.trimEnd()}\n\n${indexBlock(r)}\n`;
977
+ if (block === null) return `${readme.trimEnd()}\n\n${fresh}\n`;
839
978
  const begin = markerAt(readme, INDEX_BEGIN);
840
979
  const after = begin + INDEX_BEGIN.length + block.length + INDEX_END.length;
841
- return `${readme.slice(0, begin)}${indexBlock(r)}${readme.slice(after)}`;
980
+ return `${readme.slice(0, begin)}${fresh}${readme.slice(after)}`;
842
981
  }
843
982
 
844
983
  /** The registry's text as `paperlint studies new` writes it: JSON, two-space indented, a final newline. */
@@ -923,7 +1062,7 @@ const HEADS: { readonly [P in Problem]: (f: FindingOf[P]) => string } = {
923
1062
  case "outside":
924
1063
  return `${REGISTRY}: ${of} is outside its folder. What to do: keep the plan in the study's folder, as \`${PLAN_FILE}\`, and name it relative to the folder.`;
925
1064
  case "unreadable":
926
- return `${plan} is not a study plan: ${f.reasons}. What to do: give it the fields on the rule's page — \`question\`, \`population\`, \`arms\`, \`primary_outcome\`.`;
1065
+ return `${plan} is not a study plan: ${f.reasons}. What to do: give it the fields on the rule's page — \`question\`, \`population\`, \`arms\`, \`primary_outcome\`, and for a measurement its \`mode\`.`;
927
1066
  case "unfilled":
928
1067
  return `${plan} leaves ${listed(f.fields)} empty, and the study is ${tick(f.status)}: a plan is written before the runs. What to do: fill them in, or set the study back to \`designing\`.`;
929
1068
  }
@@ -42,9 +42,13 @@ import {
42
42
  registryText,
43
43
  withIndex,
44
44
  type StudyRegistry,
45
+ type IndexInput,
45
46
  } from "./domain/study-registry.ts";
46
47
  import { statSync } from "node:fs";
47
48
 
49
+ /** No plan and no history: the index with every mode column `—`. */
50
+ const NO_FACTS: IndexInput = { plans: new Map(), history: new Map() };
51
+
48
52
  const dirs: string[] = [];
49
53
  afterEach(() => {
50
54
  for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
@@ -813,7 +817,7 @@ describe("paperlint lint — a scaffolded paper has no layout finding", () => {
813
817
  "talk/.gitkeep": "",
814
818
  "sections/.gitkeep": "",
815
819
  "repro/studies.json": registryText(SOME_STUDY),
816
- "repro/README.md": withIndex(REPRO_INDEX_STUB, SOME_STUDY),
820
+ "repro/README.md": withIndex(REPRO_INDEX_STUB, SOME_STUDY, NO_FACTS),
817
821
  "repro/P1-S01-some-study/README.md": "# s\n",
818
822
  "repro/P1-S01-some-study/.gitkeep": "",
819
823
  });