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,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[];
@@ -3,13 +3,20 @@
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 { ParsedPlan, 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;
11
- /** A study's `plan.json` text, strict: ready, a draft naming its empty texts, or why it is no plan. */
12
- readonly parsePlan: (text: string) => ParsedPlan;
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;
13
20
  /** A plan still named `prereg.json`, loose: the same three outcomes. */
14
21
  readonly parseLegacyPlan: (text: string) => ParsedPlan;
15
22
  }
@@ -17,16 +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 {
21
22
  planTemplate,
22
23
  planText,
23
24
  registryText,
24
25
  withIndex,
26
+ type StudyPlan,
25
27
  type StudyRegistry,
28
+ type IndexInput,
26
29
  } from "./domain/study-registry.ts";
27
30
  import { recordText } from "../test/recorded-fixture.ts";
28
31
  import { useTempDir, venuePreset, writeTree } from "../test/support.ts";
29
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
+
30
36
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
31
37
  const fixture = (p: string): string =>
32
38
  readFileSync(join(ROOT, "fixtures", p), "utf8");
@@ -64,7 +70,7 @@ const REGISTRY: StudyRegistry = {
64
70
  };
65
71
  const REGISTERED_STUDY: Readonly<Record<string, string>> = {
66
72
  [`${P}/repro/studies.json`]: registryText(REGISTRY),
67
- [`${P}/repro/README.md`]: withIndex("# repro\n", REGISTRY),
73
+ [`${P}/repro/README.md`]: withIndex("# repro\n", REGISTRY, NO_FACTS),
68
74
  [`${P}/repro/P1-S01-ablation/README.md`]: "# ablation\n",
69
75
  };
70
76
 
@@ -76,9 +82,47 @@ const STUDY_WITH_PLAN = (name: string): Readonly<Record<string, string>> => {
76
82
  };
77
83
  return {
78
84
  [`${P}/repro/studies.json`]: registryText(r),
79
- [`${P}/repro/README.md`]: withIndex("# repro\n", r),
85
+ [`${P}/repro/README.md`]: withIndex("# repro\n", r, NO_FACTS),
80
86
  [`${P}/repro/P1-S01-ablation/README.md`]: "# ablation\n",
81
- [`${P}/repro/P1-S01-ablation/${name}`]: planText(planTemplate()),
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
+ ),
82
126
  };
83
127
  };
84
128
 
@@ -787,6 +831,37 @@ const CASES: Readonly<Record<string, RuleCases>> = {
787
831
  // Every folder registered, and the index the registry generates.
788
832
  silent: paper(tex("x"), REGISTERED_STUDY),
789
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
+ },
790
865
  // A study's plan still named prereg.json: read as its plan, and a warning to rename it.
791
866
  "repro/study-plan-name": {
792
867
  reports: {
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `paperlint studies index` and `studies new` (`src/studies.ts`) writing the index's mode columns from
3
+ * the plans and git's history (a stand-in), and refusing a registry whose row names a folder outside
4
+ * `repro/` before reading or writing through it.
5
+ */
6
+ import { expect, it } from "vitest";
7
+ import {
8
+ DECIDED,
9
+ DIR,
10
+ ROW,
11
+ planOf,
12
+ studies,
13
+ tree,
14
+ } from "../test/study-tree.ts";
15
+
16
+ const D = { commit: "d".repeat(40), date: "2026-10-14T09:12:00+05:00" };
17
+ const A = { commit: "a".repeat(40), date: "2026-10-16T11:00:00+05:00" };
18
+ const README = `${DIR}/repro/README.md`;
19
+
20
+ /** The row of P1-S01 in the README `studies index` wrote, its cells trimmed. */
21
+ const rowOf = (after: Readonly<Record<string, string>>): string[] =>
22
+ (after[README] ?? "")
23
+ .split("\n")
24
+ .filter((l) => l.startsWith("| P1-S01"))
25
+ .flatMap((l) => l.split("|").map((c) => c.trim()))
26
+ .filter((c) => c !== "");
27
+
28
+ it("writes the decision's first commit and the first output's, read from git", () => {
29
+ const r = studies(
30
+ "index",
31
+ tree({ [`${DIR}/repro/P1-S01-outcome/RESULTS.md`]: "41 of 60\n" }),
32
+ { title: undefined },
33
+ {
34
+ order: {
35
+ kind: "read",
36
+ decision: D,
37
+ decisionDeleted: false,
38
+ files: [
39
+ { path: "decision.md", ...D, order: "same" },
40
+ { path: "RESULTS.md", ...A, order: "after" },
41
+ ],
42
+ },
43
+ },
44
+ );
45
+ // Guards: the cells the review skills read the Methods sentence's facts from.
46
+ expect([r.code, r.log, rowOf(r.after)]).toEqual([
47
+ 0,
48
+ ["wrote the index of 1 study in papers/example/repro/README.md"],
49
+ [
50
+ "P1-S01",
51
+ "Outcome",
52
+ "running",
53
+ "—",
54
+ "no",
55
+ "decide_before_run",
56
+ "dddddddddddd 2026-10-14",
57
+ "aaaaaaaaaaaa 2026-10-16",
58
+ "[P1-S01-outcome/](P1-S01-outcome/)",
59
+ ],
60
+ ]);
61
+ });
62
+
63
+ it("rewrites the index when only git's columns moved, and leaves it when nothing did", () => {
64
+ const before = studies("index", tree(), { title: undefined }).after;
65
+ const order = {
66
+ kind: "read",
67
+ decision: D,
68
+ decisionDeleted: false,
69
+ files: [],
70
+ } as const;
71
+ const moved = studies("index", before, { title: undefined }, { order });
72
+ const again = studies("index", moved.after, { title: undefined }, { order });
73
+ // Guards: the lint ignores those two columns, so the command is what keeps them current.
74
+ expect([rowOf(before)[6], moved.log, again.log]).toEqual([
75
+ "unknown (git could not answer)",
76
+ ["wrote the index of 1 study in papers/example/repro/README.md"],
77
+ ["the index in papers/example/repro/README.md is in sync with 1 study"],
78
+ ]);
79
+ });
80
+
81
+ it("an exploratory study's mode carries its reason, and no commit", () => {
82
+ const r = studies(
83
+ "index",
84
+ tree({}, planOf({ kind: "exploratory", reason: "a first look" })),
85
+ { title: undefined },
86
+ );
87
+ expect(rowOf(r.after).slice(5, 8)).toEqual([
88
+ "exploratory: a first look",
89
+ "—",
90
+ "—",
91
+ ]);
92
+ });
93
+
94
+ it("refuses a registry whose row names a folder outside repro/, for index and for new", () => {
95
+ const escaping = tree(
96
+ { [`${DIR}/x/plan.json`]: JSON.stringify(planOf(DECIDED)) },
97
+ planOf(DECIDED),
98
+ { ...ROW, folder: "../x" },
99
+ );
100
+ const line =
101
+ "P1-S01 in papers/example/repro/studies.json names the folder `../x`, which is not one folder inside repro/: nothing is read or written through it. Give the row its own folder's name, then run this again.";
102
+ const index = studies("index", escaping, { title: undefined });
103
+ const added = studies("new", escaping, { title: "Second" });
104
+ // Guards: nothing is written — no README, no new folder — when a row could lead outside the paper.
105
+ expect([
106
+ [index.code, index.err, README in index.after],
107
+ [added.code, added.err, Object.keys(added.after).length],
108
+ ]).toEqual([
109
+ [1, [line], false],
110
+ [1, [line], Object.keys(escaping).length],
111
+ ]);
112
+ });
113
+
114
+ it("refuses a registry whose study folder is a symbolic link, for index", () => {
115
+ const r = studies(
116
+ "index",
117
+ tree(),
118
+ { title: undefined },
119
+ { links: [`${DIR}/repro/P1-S01-outcome`] },
120
+ );
121
+ // Guards: a linked folder leads the reads out of `repro/`, like a row naming `../x`.
122
+ expect([r.code, r.err, README in r.after]).toEqual([
123
+ 1,
124
+ [
125
+ "P1-S01 in papers/example/repro/studies.json names the folder `P1-S01-outcome`, which is not one folder inside repro/: nothing is read or written through it. Give the row its own folder's name, then run this again.",
126
+ ],
127
+ false,
128
+ ]);
129
+ });