paperlint 10.2.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 (147) 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 +412 -1
  20. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  21. package/dist/adapters/study-registry/index.js +245 -8
  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 +38 -6
  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 +253 -10
  39. package/dist/domain/study-registry.d.ts.map +1 -1
  40. package/dist/domain/study-registry.js +325 -27
  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 +9 -2
  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 +55 -24
  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 +29 -6
  73. package/dist/study-registry-rule.d.ts.map +1 -1
  74. package/dist/study-registry-rule.js +91 -40
  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 +137 -15
  82. package/docs/e2e.md +8 -4
  83. package/docs/layout.md +26 -21
  84. package/docs/rules/repro/outputs-before-decision.md +119 -0
  85. package/docs/rules/repro/study-plan-name.md +70 -0
  86. package/docs/rules/repro/study-registered.md +206 -15
  87. package/docs/rules.md +54 -52
  88. package/docs/testing.md +1 -0
  89. package/package.json +2 -1
  90. package/scripts/check.ts +6 -0
  91. package/skills/build-benchmark/SKILL.md +24 -12
  92. package/skills/build-benchmark/SKILL.md.spec.ts +23 -11
  93. package/skills/build-label-page/SKILL.md +9 -7
  94. package/skills/build-label-page/SKILL.md.spec.ts +8 -6
  95. package/skills/camera-ready/SKILL.md +6 -1
  96. package/skills/camera-ready/SKILL.md.spec.ts +5 -0
  97. package/skills/draft-paper/SKILL.md +11 -3
  98. package/skills/draft-paper/SKILL.md.spec.ts +10 -2
  99. package/skills/harden-paper/SKILL.md +12 -2
  100. package/skills/harden-paper/SKILL.md.spec.ts +11 -1
  101. package/skills/osf-artifact-upload/SKILL.md +7 -1
  102. package/skills/osf-artifact-upload/SKILL.md.spec.ts +6 -0
  103. package/skills/pc-panel-review/SKILL.md +10 -3
  104. package/skills/pc-panel-review/SKILL.md.spec.ts +9 -2
  105. package/skills/run-agent-grid/SKILL.md +10 -5
  106. package/skills/run-agent-grid/SKILL.md.spec.ts +9 -4
  107. package/skills/tighten-paper/SKILL.md +5 -1
  108. package/skills/tighten-paper/SKILL.md.spec.ts +4 -0
  109. package/src/adapters/git/history.test.ts +121 -0
  110. package/src/adapters/git/index.test.ts +268 -0
  111. package/src/adapters/git/index.ts +227 -0
  112. package/src/adapters/memory/index.test.ts +15 -1
  113. package/src/adapters/memory/index.ts +13 -0
  114. package/src/adapters/node/index.ts +1 -0
  115. package/src/adapters/node/list-entries.io.ts +26 -0
  116. package/src/adapters/node/list-entries.test.ts +43 -0
  117. package/src/adapters/study-registry/index.test.ts +343 -0
  118. package/src/adapters/study-registry/index.ts +374 -10
  119. package/src/adapters/study-registry/mode.test.ts +162 -0
  120. package/src/cli.ts +52 -6
  121. package/src/domain/decide-first.test.ts +258 -0
  122. package/src/domain/decide-first.ts +243 -0
  123. package/src/domain/index-columns.test.ts +216 -0
  124. package/src/domain/index-columns.ts +105 -0
  125. package/src/domain/study-measurements.test.ts +633 -0
  126. package/src/domain/study-mode.test.ts +55 -0
  127. package/src/domain/study-mode.ts +78 -0
  128. package/src/domain/study-registry.test.ts +48 -32
  129. package/src/domain/study-registry.ts +621 -36
  130. package/src/new-paper.test.ts +6 -1
  131. package/src/outputs-before-decision-rule.test.ts +206 -0
  132. package/src/outputs-before-decision-rule.ts +124 -0
  133. package/src/ports/git-history.ts +44 -0
  134. package/src/ports/list-entries.ts +18 -0
  135. package/src/ports/study-registry-codec.ts +13 -2
  136. package/src/rule-cases.test.ts +103 -1
  137. package/src/studies-cli.test.ts +2 -2
  138. package/src/studies-index-modes.test.ts +129 -0
  139. package/src/studies.test.ts +39 -9
  140. package/src/studies.ts +99 -29
  141. package/src/study-gate.test.ts +316 -0
  142. package/src/study-gate.ts +161 -0
  143. package/src/study-index-input.ts +23 -0
  144. package/src/study-modes.ts +120 -0
  145. package/src/study-registry-rule.test.ts +333 -7
  146. package/src/study-registry-rule.ts +145 -41
  147. package/src/study-run.ts +21 -0
@@ -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 });
@@ -793,6 +797,7 @@ describe("paperlint lint — a scaffolded paper has no layout finding", () => {
793
797
  "paper/allowed-files-only",
794
798
  "repro/allowed-files-only",
795
799
  "repro/study-registered",
800
+ "repro/study-plan-name",
796
801
  "review/frontmatter",
797
802
  "sibling/frontmatter",
798
803
  "paper/stages",
@@ -812,7 +817,7 @@ describe("paperlint lint — a scaffolded paper has no layout finding", () => {
812
817
  "talk/.gitkeep": "",
813
818
  "sections/.gitkeep": "",
814
819
  "repro/studies.json": registryText(SOME_STUDY),
815
- "repro/README.md": withIndex(REPRO_INDEX_STUB, SOME_STUDY),
820
+ "repro/README.md": withIndex(REPRO_INDEX_STUB, SOME_STUDY, NO_FACTS),
816
821
  "repro/P1-S01-some-study/README.md": "# s\n",
817
822
  "repro/P1-S01-some-study/.gitkeep": "",
818
823
  });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * `repro/outputs-before-decision` (`src/outputs-before-decision-rule.ts`) over a REAL git repository
3
+ * in a scratch directory, the files read from disk and the history from git: silent on a study whose
4
+ * outputs entered history after its decision, loud on each output that did not, on an output while
5
+ * the decision is uncommitted; and only on `PIPELINE-STATUS.md`.
6
+ */
7
+ import { mkdirSync, symlinkSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { expect, it } from "vitest";
10
+ import { gitHistory } from "./adapters/git/index.ts";
11
+ import {
12
+ nodeFiles,
13
+ nodeListDir,
14
+ nodeListEntries,
15
+ spawnProcess,
16
+ } from "./adapters/node/index.ts";
17
+ import { studyRegistryCodec } from "./adapters/study-registry/index.ts";
18
+ import type { StudyMode } from "./domain/study-mode.ts";
19
+ import { planText, registryText } from "./domain/study-registry.ts";
20
+ import { outputsBeforeDecisionRules } from "./outputs-before-decision-rule.ts";
21
+ import { commit, initRepo } from "../test/git-repo.ts";
22
+ import { useTempDir } from "../test/support.ts";
23
+ import { ROW, planOf } from "../test/study-tree.ts";
24
+
25
+ const root = useTempDir("outputs-before-decision-");
26
+ const DECIDED: StudyMode = {
27
+ kind: "decide_before_run",
28
+ outputs: ["out/**", "RESULTS.md"],
29
+ decision: { file: "repro/P1-S01-outcome/decision.md", note: "exclusions" },
30
+ };
31
+ const S = "p/repro/P1-S01-outcome";
32
+ const PAPER = {
33
+ "p/PIPELINE-STATUS.md": "# status\n",
34
+ "p/repro/studies.json": registryText({ prefix: "P1", studies: [ROW] }),
35
+ [`${S}/README.md`]: "# outcome\n",
36
+ [`${S}/plan.json`]: planText(planOf(DECIDED)),
37
+ };
38
+ const T = (h: number): string =>
39
+ `2026-10-16T${String(h).padStart(2, "0")}:00:00+05:00`;
40
+
41
+ /** The rule's message ids over the paper in `repo`, reached through `file`. */
42
+ const lint = (repo: string, file = "p/PIPELINE-STATUS.md"): string[] => {
43
+ const rule = outputsBeforeDecisionRules({
44
+ files: nodeFiles,
45
+ listDir: nodeListDir,
46
+ entries: nodeListEntries,
47
+ codec: studyRegistryCodec,
48
+ git: gitHistory(spawnProcess(), { PATH: String(process.env.PATH) }),
49
+ })["outputs-before-decision"];
50
+ const ids: string[] = [];
51
+ rule
52
+ .create({
53
+ filename: join(repo, file),
54
+ sourceCode: { getLocFromIndex: () => ({ line: 1, column: 0 }) },
55
+ report: (d) => ids.push(d.messageId),
56
+ })
57
+ .root?.();
58
+ return ids;
59
+ };
60
+
61
+ it("silent when the decision was committed first and the outputs after it", () => {
62
+ const repo = join(root, "clean");
63
+ initRepo(repo);
64
+ commit(repo, PAPER, T(9));
65
+ commit(repo, { [`${S}/decision.md`]: "exclude run 7\n" }, T(10));
66
+ commit(
67
+ repo,
68
+ { [`${S}/RESULTS.md`]: "41 of 60\n", [`${S}/out/a.csv`]: "1\n" },
69
+ T(11),
70
+ );
71
+ // Guards: the correct history — and the rule speaks only on PIPELINE-STATUS.md.
72
+ expect([lint(repo), lint(repo, "p/paper.tex")]).toEqual([[], []]);
73
+ });
74
+
75
+ it("loud on each output committed with or before the decision, and on outputs with no decision", () => {
76
+ const repo = join(root, "early");
77
+ initRepo(repo);
78
+ commit(repo, { ...PAPER, [`${S}/out/early.csv`]: "1\n" }, T(9));
79
+ commit(
80
+ repo,
81
+ { [`${S}/decision.md`]: "d\n", [`${S}/RESULTS.md`]: "x\n" },
82
+ T(10),
83
+ );
84
+ // Guards: one finding per output — the earlier one and the one in the decision's own commit.
85
+ expect(lint(repo)).toEqual([
86
+ "output-before-decision",
87
+ "output-before-decision",
88
+ ]);
89
+ const none = join(root, "undecided");
90
+ initRepo(none);
91
+ commit(none, PAPER, T(9));
92
+ commit(none, { [`${S}/RESULTS.md`]: "x\n" }, T(10));
93
+ // Guards: an output while the decision has never been committed.
94
+ expect(lint(none)).toEqual(["undecided-output"]);
95
+ });
96
+
97
+ it("never reads through a row whose folder leads outside repro/: no study, so no question to git", () => {
98
+ const repo = join(root, "escaping");
99
+ initRepo(repo);
100
+ // The row names `../outside`; a plan and an output sit there, outside `repro/`.
101
+ commit(
102
+ repo,
103
+ {
104
+ ...PAPER,
105
+ "p/repro/studies.json": registryText({
106
+ prefix: "P1",
107
+ studies: [{ ...ROW, folder: "../outside" }],
108
+ }),
109
+ "p/outside/plan.json": planText(planOf(DECIDED)),
110
+ "p/outside/RESULTS.md": "41 of 60\n",
111
+ },
112
+ T(9),
113
+ );
114
+ // Guards: an output outside the paper's `repro/` is no study's; `repro/study-registered` names the row.
115
+ expect(lint(repo)).toEqual([]);
116
+ });
117
+
118
+ it("loud on an output committed and then deleted while the decision was never committed", () => {
119
+ const repo = join(root, "deleted");
120
+ initRepo(repo);
121
+ commit(repo, { ...PAPER, [`${S}/RESULTS.md`]: "41 of 60\n" }, T(9));
122
+ commit(repo, { [`${S}/RESULTS.md`]: null }, T(10));
123
+ // Guards: the tree is clean now, and history still shows a result that came before any decision.
124
+ expect(lint(repo)).toEqual(["undecided-output"]);
125
+ });
126
+
127
+ it("loud on a decision that names a directory: git's answer for it is no decision's commit", () => {
128
+ const repo = join(root, "directory");
129
+ initRepo(repo);
130
+ const dir = "repro/P1-S01-outcome/decisions";
131
+ commit(
132
+ repo,
133
+ {
134
+ ...PAPER,
135
+ [`${S}/plan.json`]: planText(
136
+ planOf({ ...DECIDED, decision: { file: dir, note: "n" } }),
137
+ ),
138
+ [`p/${dir}/a.md`]: "exclude run 7\n",
139
+ },
140
+ T(9),
141
+ );
142
+ commit(repo, { [`${S}/RESULTS.md`]: "41 of 60\n" }, T(10));
143
+ // Guards: the order is never read from a directory's history — the plan is reported instead.
144
+ expect(lint(repo)).toEqual(["decision-not-file"]);
145
+ });
146
+
147
+ it("lists a symlink in the study's folder as a file, never following it: a loop or a link outside", () => {
148
+ const repo = join(root, "links");
149
+ initRepo(repo);
150
+ mkdirSync(join(repo, "outside"), { recursive: true });
151
+ writeFileSync(join(repo, "outside", "RESULTS.md"), "not this study's\n");
152
+ commit(repo, PAPER, T(9));
153
+ commit(repo, { [`${S}/decision.md`]: "exclude run 7\n" }, T(10));
154
+ symlinkSync(".", join(repo, S, "loop"));
155
+ symlinkSync(join(repo, "outside"), join(repo, S, "ext"));
156
+ commit(repo, { [`${S}/RESULTS.md`]: "41 of 60\n" }, T(11));
157
+ // Guards: a link to its own folder would recurse forever, and one outside would bring a foreign
158
+ // `RESULTS.md` into the study; both are entries git itself stores as links.
159
+ expect(lint(repo)).toEqual([]);
160
+ });
161
+
162
+ it("loud on a decision deleted after its outputs: a decision that no longer stands decides nothing", () => {
163
+ const repo = join(root, "decision-deleted");
164
+ initRepo(repo);
165
+ commit(repo, PAPER, T(9));
166
+ commit(repo, { [`${S}/decision.md`]: "exclude run 7\n" }, T(10));
167
+ commit(repo, { [`${S}/RESULTS.md`]: "41 of 60\n" }, T(11));
168
+ commit(repo, { [`${S}/decision.md`]: null }, T(12));
169
+ // Guards: the order was right once; with the decision gone from HEAD the study is reported.
170
+ expect(lint(repo)).toEqual(["decision-deleted"]);
171
+ });
172
+
173
+ it("loud on a decision that is a symbolic link: git records the link, not the decision", () => {
174
+ const repo = join(root, "decision-link");
175
+ initRepo(repo);
176
+ mkdirSync(join(repo, "p"), { recursive: true });
177
+ writeFileSync(join(repo, "p/elsewhere.md"), "exclude run 7\n");
178
+ mkdirSync(join(repo, S), { recursive: true });
179
+ symlinkSync("../../elsewhere.md", join(repo, S, "decision.md"));
180
+ commit(repo, PAPER, T(9));
181
+ commit(repo, { [`${S}/RESULTS.md`]: "41 of 60\n" }, T(10));
182
+ // Guards: a link committed first proves nothing of what it pointed to when the run started.
183
+ expect(lint(repo)).toEqual(["decision-not-file"]);
184
+ });
185
+
186
+ it("never reads through a study folder that is a symbolic link", () => {
187
+ const repo = join(root, "folder-link");
188
+ initRepo(repo);
189
+ const elsewhere = "p/elsewhere";
190
+ commit(
191
+ repo,
192
+ {
193
+ ...Object.fromEntries(
194
+ Object.entries(PAPER).filter(([k]) => !k.startsWith(S)),
195
+ ),
196
+ [`${elsewhere}/README.md`]: "# outcome\n",
197
+ [`${elsewhere}/plan.json`]: planText(planOf(DECIDED)),
198
+ [`${elsewhere}/RESULTS.md`]: "41 of 60\n",
199
+ },
200
+ T(9),
201
+ );
202
+ symlinkSync("../elsewhere", join(repo, S));
203
+ commit(repo, {}, T(10));
204
+ // Guards: the outputs behind the link are not this paper's study; nothing is read through it.
205
+ expect(lint(repo)).toEqual([]);
206
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `repro/outputs-before-decision` — a study decided before its run shows that order in git's
3
+ * history: every output entered history after the decision file did, and no output exists while the
4
+ * decision is uncommitted. What is judged, and why, is `src/domain/decide-first.ts` and
5
+ * docs/rules/repro/outputs-before-decision.md; this file finds the decide-first studies, lists their
6
+ * folders, asks git through the `GitOrder` port, and reports.
7
+ *
8
+ * ── WHY A RULE MAY ASK GIT HERE ────────────────────────────────────────────────
9
+ * `eslint-rules/` may not run git (eslint.config.mjs, `GIT_IN_A_RULE`): a pinned sha is deleted by a
10
+ * squash or a gc, and a shallow CI checkout has none. This rule pins no sha. It asks the history the
11
+ * checkout has for an ORDER, which a squash merge keeps when the decision was merged before the run
12
+ * (the decide-first workflow), and it reports a shallow clone by name instead of judging it.
13
+ *
14
+ * Reported on `PIPELINE-STATUS.md`, first line, like the other `repro/` rules.
15
+ */
16
+ import { basename, dirname, join } from "node:path";
17
+ import { callerPath } from "./caller-path.ts";
18
+ import {
19
+ filesUnder,
20
+ modeStudies,
21
+ onDisk,
22
+ type StudyDeps,
23
+ } from "./study-modes.ts";
24
+ import {
25
+ judgeOrder,
26
+ orderMessage,
27
+ type DecideFirstStudy,
28
+ type OrderFinding,
29
+ } from "./domain/decide-first.ts";
30
+ import { REPRO_DIR } from "./domain/repro-layout.ts";
31
+ import type {
32
+ FolderRuleContext,
33
+ FolderRuleModule,
34
+ } from "./folder-venue-rule.ts";
35
+ import type { GitOrder } from "./ports/git-history.ts";
36
+ import { rulePageUrl } from "./venue-rules.ts";
37
+
38
+ const STATUS_FILE = "PIPELINE-STATUS.md";
39
+
40
+ /** What the rule reads with: the registry's, and git's order. */
41
+ export interface OrderDeps extends StudyDeps {
42
+ readonly git: GitOrder;
43
+ }
44
+
45
+ /** Every decide-first study of the paper folder `paper`, with its tree and its history's order. */
46
+ export const decideFirstStudies = (
47
+ paper: string,
48
+ deps: OrderDeps,
49
+ ): readonly DecideFirstStudy[] =>
50
+ modeStudies(paper, deps).flatMap(({ row, plan, mode, empty }) => {
51
+ // Only a ready plan decides: a draft may name no outputs, or an empty decision file.
52
+ if (mode.kind !== "decide_before_run" || empty.length > 0) return [];
53
+ const folder = join(paper, REPRO_DIR, row.folder);
54
+ const decision = join(paper, mode.decision.file);
55
+ const on = onDisk(decision, deps);
56
+ return [
57
+ {
58
+ id: row.id,
59
+ folder: row.folder,
60
+ plan,
61
+ mode,
62
+ tree: filesUnder(folder, deps),
63
+ facts:
64
+ on === "directory" || on === "symbolic link"
65
+ ? { kind: "not-a-file", what: on }
66
+ : deps.git.order(callerPath(folder), callerPath(decision)),
67
+ },
68
+ ];
69
+ });
70
+
71
+ const PROBLEMS: readonly OrderFinding["problem"][] = [
72
+ "decision-is-output",
73
+ "decision-deleted",
74
+ "decision-not-file",
75
+ "no-history",
76
+ "shallow",
77
+ "undecided-output",
78
+ "output-before-decision",
79
+ ];
80
+
81
+ const NAME = "repro/outputs-before-decision";
82
+
83
+ /** On `PIPELINE-STATUS.md`, every finding of every decide-first study of its paper, at its first line. */
84
+ const reportOn = (context: FolderRuleContext, deps: OrderDeps) => {
85
+ if (basename(context.filename) !== STATUS_FILE) return {};
86
+ return {
87
+ root() {
88
+ const at = context.sourceCode.getLocFromIndex(0);
89
+ decideFirstStudies(dirname(context.filename), deps)
90
+ .flatMap(judgeOrder)
91
+ .forEach((f) => {
92
+ context.report({
93
+ loc: { start: at, end: at },
94
+ messageId: f.problem,
95
+ data: { text: orderMessage(f) },
96
+ });
97
+ });
98
+ },
99
+ };
100
+ };
101
+
102
+ /** The rule, reading the folder and git through `deps`. */
103
+ export const outputsBeforeDecisionRules = (
104
+ deps: OrderDeps,
105
+ ): Readonly<Record<"outputs-before-decision", FolderRuleModule>> => ({
106
+ "outputs-before-decision": {
107
+ meta: {
108
+ type: "suggestion",
109
+ docs: {
110
+ description:
111
+ "a study decided before its run shows it in git's history: every output entered history after its decision file, and none exists while the decision is uncommitted",
112
+ url: rulePageUrl(NAME),
113
+ },
114
+ schema: [],
115
+ messages: Object.fromEntries(PROBLEMS.map((p) => [p, "{{text}}"])),
116
+ },
117
+ create: (context) => reportOn(context, deps),
118
+ },
119
+ });
120
+
121
+ /** Its level in paperlint's own config: an error. */
122
+ export const ORDER_RULE_LEVELS: Readonly<{
123
+ "repro/outputs-before-decision": "error";
124
+ }> = { "repro/outputs-before-decision": "error" };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * What git says of a study's files — shaped by what the decide-first gate needs: whether a decision
3
+ * file is committed as it stands, and in which commit (`studies gate`); and whether
4
+ * each output entered history after the decision (`repro/outputs-before-decision`). The one
5
+ * implementation is `src/adapters/git/`; `src/cli.ts` wires it.
6
+ */
7
+ import type { OrderFacts } from "../domain/decide-first.ts";
8
+ import type { AbsolutePath } from "../domain/paths.ts";
9
+
10
+ export type {
11
+ CommitAt,
12
+ FirstCommit,
13
+ Order,
14
+ OrderFacts,
15
+ } from "../domain/decide-first.ts";
16
+
17
+ export type Committed =
18
+ /** No commit has the file: untracked, ignored, only staged, or absent. */
19
+ | { readonly kind: "not-committed" }
20
+ /** Committed, and changed since (edited, staged, deleted): the commit is not what is on disk. */
21
+ | { readonly kind: "changed"; readonly commit: string }
22
+ /** Committed, then deleted in a later commit: it is not in HEAD, so there is nothing to gate on. */
23
+ | { readonly kind: "deleted"; readonly commit: string }
24
+ /** Committed as it stands: the last commit that touched it, and that commit's date. */
25
+ | {
26
+ readonly kind: "committed";
27
+ readonly commit: string;
28
+ readonly date: string;
29
+ }
30
+ /** git could not answer: not installed, not a repository. */
31
+ | { readonly kind: "unknown"; readonly why: string };
32
+
33
+ export interface GitHistory {
34
+ /** `file` (absolute, in the repository that holds `paper`), asked from the paper's folder. */
35
+ readonly committed: (paper: AbsolutePath, file: AbsolutePath) => Committed;
36
+ }
37
+
38
+ export interface GitOrder {
39
+ /** The order of `decision`'s first commit against every file's under `studyDir` (a folder that exists). */
40
+ readonly order: (
41
+ studyDir: AbsolutePath,
42
+ decision: AbsolutePath,
43
+ ) => OrderFacts;
44
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A directory's entries with what each one is, read without following a symbolic link — so a walk
3
+ * that descends only into `dir` entries can neither loop on a link to an ancestor nor wander into a
4
+ * tree outside through one. The walker of a study's folder (`src/study-modes.ts`) reads with it.
5
+ */
6
+ import type { AbsolutePath } from "../domain/paths.ts";
7
+
8
+ /**
9
+ * One entry of a directory: a directory, a symbolic link (not followed), or a file — anything else on
10
+ * disk (a socket, a FIFO) reads as a file: a name, never descended into.
11
+ */
12
+ export interface DirEntry {
13
+ readonly name: string;
14
+ readonly kind: "file" | "dir" | "symlink";
15
+ }
16
+
17
+ /** The entries of `dir`; none when it does not exist or is not a directory. */
18
+ export type ListEntries = (dir: AbsolutePath) => readonly DirEntry[];
@@ -1,11 +1,22 @@
1
1
  /**
2
- * How a `repro/studies.json` becomes a value: its text parsed once, where it enters, against the schema
2
+ * How a `repro/studies.json`, and a study's `plan.json`, become values: its text parsed once, where it enters, against the schema
3
3
  * of `StudyRegistry` (`src/domain/study-registry.ts`). The one implementation is
4
4
  * `src/adapters/study-registry/` (zod); `src/cli.ts` wires it.
5
5
  */
6
- import type { ParsedRegistry } from "../domain/study-registry.ts";
6
+ import type {
7
+ ParsedPlan,
8
+ ParsedRegistry,
9
+ StudyKind,
10
+ } from "../domain/study-registry.ts";
7
11
 
8
12
  export interface StudyRegistryCodec {
9
13
  /** A `studies.json`'s text: the registry, or the one reason it is not one of this schema. */
10
14
  readonly parse: (text: string) => ParsedRegistry;
15
+ /**
16
+ * A study's `plan.json` text, strict: ready, a draft naming its empty texts, or why it is no plan. A
17
+ * study of a `kind` that must declare its mode (a measurement) is ready only with one.
18
+ */
19
+ readonly parsePlan: (text: string, kind: StudyKind) => ParsedPlan;
20
+ /** A plan still named `prereg.json`, loose: the same three outcomes. */
21
+ readonly parseLegacyPlan: (text: string) => ParsedPlan;
11
22
  }
@@ -17,14 +17,22 @@ import { describe, expect, it } from "vitest";
17
17
  import { z } from "zod";
18
18
  import { run, rulePlugins } from "./cli.ts";
19
19
  import { sha256Hex } from "./domain/sha256.ts";
20
+ import type { StudyMode } from "./domain/study-mode.ts";
20
21
  import {
22
+ planTemplate,
23
+ planText,
21
24
  registryText,
22
25
  withIndex,
26
+ type StudyPlan,
23
27
  type StudyRegistry,
28
+ type IndexInput,
24
29
  } from "./domain/study-registry.ts";
25
30
  import { recordText } from "../test/recorded-fixture.ts";
26
31
  import { useTempDir, venuePreset, writeTree } from "../test/support.ts";
27
32
 
33
+ /** No plan and no history: the index with every mode column `—`. */
34
+ const NO_FACTS: IndexInput = { plans: new Map(), history: new Map() };
35
+
28
36
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
29
37
  const fixture = (p: string): string =>
30
38
  readFileSync(join(ROOT, "fixtures", p), "utf8");
@@ -62,10 +70,62 @@ const REGISTRY: StudyRegistry = {
62
70
  };
63
71
  const REGISTERED_STUDY: Readonly<Record<string, string>> = {
64
72
  [`${P}/repro/studies.json`]: registryText(REGISTRY),
65
- [`${P}/repro/README.md`]: withIndex("# repro\n", REGISTRY),
73
+ [`${P}/repro/README.md`]: withIndex("# repro\n", REGISTRY, NO_FACTS),
66
74
  [`${P}/repro/P1-S01-ablation/README.md`]: "# ablation\n",
67
75
  };
68
76
 
77
+ /** The registered study with its plan named `name`, the plan's text beside its README. */
78
+ const STUDY_WITH_PLAN = (name: string): Readonly<Record<string, string>> => {
79
+ const r: StudyRegistry = {
80
+ ...REGISTRY,
81
+ studies: REGISTRY.studies.map((s) => ({ ...s, plan: name })),
82
+ };
83
+ return {
84
+ [`${P}/repro/studies.json`]: registryText(r),
85
+ [`${P}/repro/README.md`]: withIndex("# repro\n", r, NO_FACTS),
86
+ [`${P}/repro/P1-S01-ablation/README.md`]: "# ablation\n",
87
+ [`${P}/repro/P1-S01-ablation/${name}`]: planText(
88
+ planTemplate("spike", "P1-S01-ablation"),
89
+ ),
90
+ };
91
+ };
92
+
93
+ /** The registered study as a measurement in `mode`, its plan ready, `files` beside it. */
94
+ const STUDY_IN_MODE = (
95
+ mode: StudyMode,
96
+ files: Readonly<Record<string, string>> = {},
97
+ ): Readonly<Record<string, string>> => {
98
+ const r: StudyRegistry = {
99
+ ...REGISTRY,
100
+ studies: REGISTRY.studies.map((s) => ({ ...s, plan: "plan.json" })),
101
+ };
102
+ const plan: StudyPlan = {
103
+ question: "Which step does the work?",
104
+ population: { source: "tasks", draw: { kind: "all", method: "census" } },
105
+ arms: [],
106
+ primary_outcome: "pass rate",
107
+ author_check: null,
108
+ mode,
109
+ };
110
+ // The index carries the plan's mode, as the lint reads it; no history is asked.
111
+ const facts: IndexInput = {
112
+ plans: new Map([["P1-S01-ablation/plan.json", { kind: "ready", plan }]]),
113
+ history: new Map(),
114
+ };
115
+ return {
116
+ [`${P}/repro/studies.json`]: registryText(r),
117
+ [`${P}/repro/README.md`]: withIndex("# repro\n", r, facts),
118
+ [`${P}/repro/P1-S01-ablation/README.md`]: "# ablation\n",
119
+ [`${P}/repro/P1-S01-ablation/plan.json`]: planText(plan),
120
+ ...Object.fromEntries(
121
+ Object.entries(files).map(([k, v]) => [
122
+ `${P}/repro/P1-S01-ablation/${k}`,
123
+ v,
124
+ ]),
125
+ ),
126
+ };
127
+ };
128
+
69
129
  /** `study/declare-sample` is off by default; a paper turns it on in its `paperlint.json`. */
70
130
  const DECLARE_SAMPLE_ON: Readonly<Record<string, string>> = {
71
131
  [`${P}/paperlint.json`]: JSON.stringify({
@@ -771,6 +831,48 @@ const CASES: Readonly<Record<string, RuleCases>> = {
771
831
  // Every folder registered, and the index the registry generates.
772
832
  silent: paper(tex("x"), REGISTERED_STUDY),
773
833
  },
834
+ // A study decided before its run, linted where git has no history of it: said, never a pass. The
835
+ // order itself is judged on real repositories in src/outputs-before-decision-rule.test.ts.
836
+ "repro/outputs-before-decision": {
837
+ reports: {
838
+ tree: paper(
839
+ tex("x"),
840
+ STUDY_IN_MODE(
841
+ {
842
+ kind: "decide_before_run",
843
+ outputs: ["RESULTS.md"],
844
+ decision: {
845
+ file: "repro/P1-S01-ablation/decision.md",
846
+ note: "exclusions",
847
+ },
848
+ },
849
+ { "RESULTS.md": "41 of 60\n" },
850
+ ),
851
+ ),
852
+ file: `${P}/PIPELINE-STATUS.md`,
853
+ severity: 2,
854
+ line: 1,
855
+ },
856
+ // The same study declared exploratory: no gate, nothing to order.
857
+ silent: paper(
858
+ tex("x"),
859
+ STUDY_IN_MODE(
860
+ { kind: "exploratory", reason: "a first look" },
861
+ { "RESULTS.md": "41 of 60\n" },
862
+ ),
863
+ ),
864
+ },
865
+ // A study's plan still named prereg.json: read as its plan, and a warning to rename it.
866
+ "repro/study-plan-name": {
867
+ reports: {
868
+ tree: paper(tex("x"), STUDY_WITH_PLAN("prereg.json")),
869
+ file: `${P}/PIPELINE-STATUS.md`,
870
+ severity: 1,
871
+ line: 1,
872
+ },
873
+ // The same plan as plan.json.
874
+ silent: paper(tex("x"), STUDY_WITH_PLAN("plan.json")),
875
+ },
774
876
  // A study's prereg.json that declares none of population, strata, screens, coverage, author_check.
775
877
  // Off by default: the paper turns it on, as both trees do.
776
878
  "study/declare-sample": {
@@ -83,7 +83,7 @@ describe("paperlint studies index — through the CLI", () => {
83
83
  writeTree(repro, {
84
84
  "studies.json": readFileSync(file, "utf8").replace(
85
85
  '"designing"',
86
- '"running"',
86
+ '"dropped"',
87
87
  ),
88
88
  });
89
89
  expect(await registryFindings(dir)).toEqual([
@@ -96,7 +96,7 @@ describe("paperlint studies index — through the CLI", () => {
96
96
  [],
97
97
  ]);
98
98
  expect(readFileSync(join(repro, "README.md"), "utf8")).toContain(
99
- "| P1-S01 | Baseline pass rate | running |",
99
+ "| P1-S01 | Baseline pass rate | dropped |",
100
100
  );
101
101
  expect(await registryFindings(dir)).toEqual([]);
102
102
  });