paperlint 10.3.0 → 10.4.1
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.
- package/.github/workflows/ci.yml +15 -4
- package/README.md +1 -0
- package/dist/CLAUDE.md.spec.d.ts.map +1 -1
- package/dist/CLAUDE.md.spec.js +6 -3
- package/dist/CLAUDE.md.spec.js.map +1 -1
- package/dist/adapters/git/index.d.ts +10 -0
- package/dist/adapters/git/index.d.ts.map +1 -0
- package/dist/adapters/git/index.js +151 -0
- package/dist/adapters/git/index.js.map +1 -0
- package/dist/adapters/latex/captions.d.ts +9 -0
- package/dist/adapters/latex/captions.d.ts.map +1 -0
- package/dist/adapters/latex/captions.js +40 -0
- package/dist/adapters/latex/captions.js.map +1 -0
- package/dist/adapters/latex/index.d.ts +1 -0
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +1 -0
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/parse.d.ts.map +1 -1
- package/dist/adapters/latex/parse.js +6 -0
- package/dist/adapters/latex/parse.js.map +1 -1
- package/dist/adapters/memory/index.d.ts +6 -0
- package/dist/adapters/memory/index.d.ts.map +1 -1
- package/dist/adapters/memory/index.js +8 -0
- package/dist/adapters/memory/index.js.map +1 -1
- package/dist/adapters/node/index.d.ts +1 -0
- package/dist/adapters/node/index.d.ts.map +1 -1
- package/dist/adapters/node/index.js +1 -0
- package/dist/adapters/node/index.js.map +1 -1
- package/dist/adapters/node/list-entries.io.d.ts +3 -0
- package/dist/adapters/node/list-entries.io.d.ts.map +1 -0
- package/dist/adapters/node/list-entries.io.js +26 -0
- package/dist/adapters/node/list-entries.io.js.map +1 -0
- package/dist/adapters/study-registry/index.d.ts +70 -10
- package/dist/adapters/study-registry/index.d.ts.map +1 -1
- package/dist/adapters/study-registry/index.js +84 -10
- package/dist/adapters/study-registry/index.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +33 -3
- package/dist/cli.js.map +1 -1
- package/dist/domain/decide-first.d.ts +110 -0
- package/dist/domain/decide-first.d.ts.map +1 -0
- package/dist/domain/decide-first.js +108 -0
- package/dist/domain/decide-first.js.map +1 -0
- package/dist/domain/index-columns.d.ts +45 -0
- package/dist/domain/index-columns.d.ts.map +1 -0
- package/dist/domain/index-columns.js +64 -0
- package/dist/domain/index-columns.js.map +1 -0
- package/dist/domain/study-mode.d.ts +37 -0
- package/dist/domain/study-mode.d.ts.map +1 -0
- package/dist/domain/study-mode.js +45 -0
- package/dist/domain/study-mode.js.map +1 -0
- package/dist/domain/study-registry.d.ts +116 -12
- package/dist/domain/study-registry.d.ts.map +1 -1
- package/dist/domain/study-registry.js +164 -44
- package/dist/domain/study-registry.js.map +1 -1
- package/dist/outputs-before-decision-rule.d.ts +17 -0
- package/dist/outputs-before-decision-rule.d.ts.map +1 -0
- package/dist/outputs-before-decision-rule.js +94 -0
- package/dist/outputs-before-decision-rule.js.map +1 -0
- package/dist/ports/git-history.d.ts +44 -0
- package/dist/ports/git-history.d.ts.map +1 -0
- package/dist/ports/git-history.js +2 -0
- package/dist/ports/git-history.js.map +1 -0
- package/dist/ports/list-entries.d.ts +20 -0
- package/dist/ports/list-entries.d.ts.map +1 -0
- package/dist/ports/list-entries.js +2 -0
- package/dist/ports/list-entries.js.map +1 -0
- package/dist/ports/study-deps.d.ts +14 -0
- package/dist/ports/study-deps.d.ts.map +1 -0
- package/dist/ports/study-deps.js +2 -0
- package/dist/ports/study-deps.js.map +1 -0
- package/dist/ports/study-folder.d.ts +43 -0
- package/dist/ports/study-folder.d.ts.map +1 -0
- package/dist/ports/study-folder.js +2 -0
- package/dist/ports/study-folder.js.map +1 -0
- package/dist/ports/study-registry-codec.d.ts +6 -3
- package/dist/ports/study-registry-codec.d.ts.map +1 -1
- package/dist/presets.d.ts +1 -2
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +1 -2
- package/dist/presets.js.map +1 -1
- package/dist/studies.d.ts +4 -0
- package/dist/studies.d.ts.map +1 -1
- package/dist/studies.js +52 -21
- package/dist/studies.js.map +1 -1
- package/dist/study-folder.d.ts +14 -0
- package/dist/study-folder.d.ts.map +1 -0
- package/dist/study-folder.js +123 -0
- package/dist/study-folder.js.map +1 -0
- package/dist/study-gate.d.ts +14 -0
- package/dist/study-gate.d.ts.map +1 -0
- package/dist/study-gate.js +132 -0
- package/dist/study-gate.js.map +1 -0
- package/dist/study-index-input.d.ts +5 -0
- package/dist/study-index-input.d.ts.map +1 -0
- package/dist/study-index-input.js +18 -0
- package/dist/study-index-input.js.map +1 -0
- package/dist/study-modes.d.ts +56 -0
- package/dist/study-modes.d.ts.map +1 -0
- package/dist/study-modes.js +59 -0
- package/dist/study-modes.js.map +1 -0
- package/dist/study-registry-rule.d.ts +13 -1
- package/dist/study-registry-rule.d.ts.map +1 -1
- package/dist/study-registry-rule.js +33 -21
- package/dist/study-registry-rule.js.map +1 -1
- package/dist/study-run.d.ts +19 -0
- package/dist/study-run.d.ts.map +1 -0
- package/dist/study-run.js +9 -0
- package/dist/study-run.js.map +1 -0
- package/docs/design/study-modes.md +265 -0
- package/docs/design/study-registry.md +6 -0
- package/docs/e2e.md +8 -4
- package/docs/layout.md +22 -19
- package/docs/rules/repro/outputs-before-decision.md +122 -0
- package/docs/rules/repro/study-registered.md +49 -15
- package/docs/rules/tex/heading-case.md +1 -1
- package/docs/rules.md +54 -53
- package/docs/testing.md +1 -0
- package/eslint.config.mjs +36 -24
- package/package.json +2 -1
- package/scripts/check.ts +6 -0
- package/skills/camera-ready/SKILL.md +6 -1
- package/skills/camera-ready/SKILL.md.spec.ts +5 -0
- package/skills/draft-paper/SKILL.md +11 -3
- package/skills/draft-paper/SKILL.md.spec.ts +10 -2
- package/skills/grade-paper-writing/SKILL.md +4 -2
- package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -1
- package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +16 -3
- package/skills/grade-paper-writing/prose-lint.mjs +127 -95
- package/skills/grade-paper-writing/prose-lint.test.ts +119 -10
- package/skills/harden-paper/SKILL.md +12 -2
- package/skills/harden-paper/SKILL.md.spec.ts +11 -1
- package/skills/osf-artifact-upload/SKILL.md +7 -1
- package/skills/osf-artifact-upload/SKILL.md.spec.ts +6 -0
- package/skills/pc-panel-review/SKILL.md +10 -3
- package/skills/pc-panel-review/SKILL.md.spec.ts +9 -2
- package/skills/tighten-paper/SKILL.md +5 -1
- package/skills/tighten-paper/SKILL.md.spec.ts +4 -0
- package/src/CLAUDE.md +7 -4
- package/src/CLAUDE.md.spec.ts +6 -3
- package/src/adapters/git/history.test.ts +121 -0
- package/src/adapters/git/index.test.ts +268 -0
- package/src/adapters/git/index.ts +227 -0
- package/src/adapters/latex/captions.test.ts +57 -0
- package/src/adapters/latex/captions.ts +56 -0
- package/src/adapters/latex/index.ts +1 -0
- package/src/adapters/latex/parse.ts +7 -0
- package/src/adapters/memory/index.test.ts +15 -1
- package/src/adapters/memory/index.ts +13 -0
- package/src/adapters/node/index.ts +1 -0
- package/src/adapters/node/list-entries.io.ts +27 -0
- package/src/adapters/node/list-entries.test.ts +61 -0
- package/src/adapters/study-registry/index.test.ts +12 -9
- package/src/adapters/study-registry/index.ts +130 -24
- package/src/adapters/study-registry/mode.test.ts +162 -0
- package/src/cli.ts +47 -3
- package/src/domain/decide-first.test.ts +291 -0
- package/src/domain/decide-first.ts +258 -0
- package/src/domain/index-columns.test.ts +261 -0
- package/src/domain/index-columns.ts +107 -0
- package/src/domain/study-measurements.test.ts +16 -8
- package/src/domain/study-mode.test.ts +55 -0
- package/src/domain/study-mode.ts +78 -0
- package/src/domain/study-registry.test.ts +72 -37
- package/src/domain/study-registry.ts +281 -50
- package/src/new-paper.test.ts +5 -1
- package/src/outputs-before-decision-rule.test.ts +251 -0
- package/src/outputs-before-decision-rule.ts +133 -0
- package/src/ports/git-history.ts +44 -0
- package/src/ports/list-entries.ts +21 -0
- package/src/ports/study-deps.ts +14 -0
- package/src/ports/study-folder.ts +38 -0
- package/src/ports/study-registry-codec.ts +10 -3
- package/src/presets.ts +1 -2
- package/src/rule-cases.test.ts +78 -3
- package/src/studies-index-modes.test.ts +129 -0
- package/src/studies.test.ts +35 -11
- package/src/studies.ts +96 -26
- package/src/study-folder.test.ts +237 -0
- package/src/study-folder.ts +176 -0
- package/src/study-gate.test.ts +467 -0
- package/src/study-gate.ts +234 -0
- package/src/study-index-input.ts +31 -0
- package/src/study-modes.ts +107 -0
- package/src/study-registry-rule.test.ts +285 -11
- package/src/study-registry-rule.ts +64 -35
- package/src/study-run.ts +21 -0
|
@@ -0,0 +1,251 @@
|
|
|
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
|
+
nodeListEntries,
|
|
14
|
+
spawnProcess,
|
|
15
|
+
} from "./adapters/node/index.ts";
|
|
16
|
+
import { studyRegistryCodec } from "./adapters/study-registry/index.ts";
|
|
17
|
+
import type { StudyMode } from "./domain/study-mode.ts";
|
|
18
|
+
import { planText, registryText } from "./domain/study-registry.ts";
|
|
19
|
+
import { outputsBeforeDecisionRules } from "./outputs-before-decision-rule.ts";
|
|
20
|
+
import { commit, initRepo } from "../test/git-repo.ts";
|
|
21
|
+
import { useTempDir } from "../test/support.ts";
|
|
22
|
+
import { ROW, planOf } from "../test/study-tree.ts";
|
|
23
|
+
|
|
24
|
+
const root = useTempDir("outputs-before-decision-");
|
|
25
|
+
const DECIDED: StudyMode = {
|
|
26
|
+
kind: "decide_before_run",
|
|
27
|
+
outputs: ["out/**", "RESULTS.md"],
|
|
28
|
+
decision: { file: "repro/P1-S01-outcome/decision.md", note: "exclusions" },
|
|
29
|
+
};
|
|
30
|
+
const S = "p/repro/P1-S01-outcome";
|
|
31
|
+
const PAPER = {
|
|
32
|
+
"p/PIPELINE-STATUS.md": "# status\n",
|
|
33
|
+
"p/repro/studies.json": registryText({ prefix: "P1", studies: [ROW] }),
|
|
34
|
+
[`${S}/README.md`]: "# outcome\n",
|
|
35
|
+
[`${S}/plan.json`]: planText(planOf(DECIDED)),
|
|
36
|
+
};
|
|
37
|
+
const T = (h: number): string =>
|
|
38
|
+
`2026-10-16T${String(h).padStart(2, "0")}:00:00+05:00`;
|
|
39
|
+
|
|
40
|
+
/** The rule's message ids over the paper in `repo`, reached through `file`. */
|
|
41
|
+
const lint = (repo: string, file = "p/PIPELINE-STATUS.md"): string[] => {
|
|
42
|
+
const rule = outputsBeforeDecisionRules({
|
|
43
|
+
files: nodeFiles,
|
|
44
|
+
entries: nodeListEntries,
|
|
45
|
+
codec: studyRegistryCodec,
|
|
46
|
+
git: gitHistory(spawnProcess(), { PATH: String(process.env.PATH) }),
|
|
47
|
+
})["outputs-before-decision"];
|
|
48
|
+
const ids: string[] = [];
|
|
49
|
+
rule
|
|
50
|
+
.create({
|
|
51
|
+
filename: join(repo, file),
|
|
52
|
+
sourceCode: { getLocFromIndex: () => ({ line: 1, column: 0 }) },
|
|
53
|
+
report: (d) => ids.push(d.messageId),
|
|
54
|
+
})
|
|
55
|
+
.root?.();
|
|
56
|
+
return ids;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
it("silent when the decision was committed first and the outputs after it", () => {
|
|
60
|
+
const repo = join(root, "clean");
|
|
61
|
+
initRepo(repo);
|
|
62
|
+
commit(repo, PAPER, T(9));
|
|
63
|
+
commit(repo, { [`${S}/decision.md`]: "exclude run 7\n" }, T(10));
|
|
64
|
+
commit(
|
|
65
|
+
repo,
|
|
66
|
+
{ [`${S}/RESULTS.md`]: "41 of 60\n", [`${S}/out/a.csv`]: "1\n" },
|
|
67
|
+
T(11),
|
|
68
|
+
);
|
|
69
|
+
// Guards: the correct history — and the rule speaks only on PIPELINE-STATUS.md.
|
|
70
|
+
expect([lint(repo), lint(repo, "p/paper.tex")]).toEqual([[], []]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("loud on each output committed with or before the decision, and on outputs with no decision", () => {
|
|
74
|
+
const repo = join(root, "early");
|
|
75
|
+
initRepo(repo);
|
|
76
|
+
commit(repo, { ...PAPER, [`${S}/out/early.csv`]: "1\n" }, T(9));
|
|
77
|
+
commit(
|
|
78
|
+
repo,
|
|
79
|
+
{ [`${S}/decision.md`]: "d\n", [`${S}/RESULTS.md`]: "x\n" },
|
|
80
|
+
T(10),
|
|
81
|
+
);
|
|
82
|
+
// Guards: one finding per output — the earlier one and the one in the decision's own commit.
|
|
83
|
+
expect(lint(repo)).toEqual([
|
|
84
|
+
"output-before-decision",
|
|
85
|
+
"output-before-decision",
|
|
86
|
+
]);
|
|
87
|
+
const none = join(root, "undecided");
|
|
88
|
+
initRepo(none);
|
|
89
|
+
commit(none, PAPER, T(9));
|
|
90
|
+
commit(none, { [`${S}/RESULTS.md`]: "x\n" }, T(10));
|
|
91
|
+
// Guards: an output while the decision has never been committed.
|
|
92
|
+
expect(lint(none)).toEqual(["undecided-output"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("never reads through a row whose folder leads outside repro/: no study, so no question to git", () => {
|
|
96
|
+
const repo = join(root, "escaping");
|
|
97
|
+
initRepo(repo);
|
|
98
|
+
// The row names `../outside`; a plan and an output sit there, outside `repro/`.
|
|
99
|
+
commit(
|
|
100
|
+
repo,
|
|
101
|
+
{
|
|
102
|
+
...PAPER,
|
|
103
|
+
"p/repro/studies.json": registryText({
|
|
104
|
+
prefix: "P1",
|
|
105
|
+
studies: [{ ...ROW, folder: "../outside" }],
|
|
106
|
+
}),
|
|
107
|
+
"p/outside/plan.json": planText(planOf(DECIDED)),
|
|
108
|
+
"p/outside/RESULTS.md": "41 of 60\n",
|
|
109
|
+
},
|
|
110
|
+
T(9),
|
|
111
|
+
);
|
|
112
|
+
// Guards: an output outside the paper's `repro/` is no study's; `repro/study-registered` names the row.
|
|
113
|
+
expect(lint(repo)).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("loud on an output committed and then deleted while the decision was never committed", () => {
|
|
117
|
+
const repo = join(root, "deleted");
|
|
118
|
+
initRepo(repo);
|
|
119
|
+
commit(repo, { ...PAPER, [`${S}/RESULTS.md`]: "41 of 60\n" }, T(9));
|
|
120
|
+
commit(repo, { [`${S}/RESULTS.md`]: null }, T(10));
|
|
121
|
+
// Guards: the tree is clean now, and history still shows a result that came before any decision.
|
|
122
|
+
expect(lint(repo)).toEqual(["undecided-output"]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("loud on a decision that names a directory: git's answer for it is no decision's commit", () => {
|
|
126
|
+
const repo = join(root, "directory");
|
|
127
|
+
initRepo(repo);
|
|
128
|
+
const dir = "repro/P1-S01-outcome/decisions";
|
|
129
|
+
commit(
|
|
130
|
+
repo,
|
|
131
|
+
{
|
|
132
|
+
...PAPER,
|
|
133
|
+
[`${S}/plan.json`]: planText(
|
|
134
|
+
planOf({ ...DECIDED, decision: { file: dir, note: "n" } }),
|
|
135
|
+
),
|
|
136
|
+
[`p/${dir}/a.md`]: "exclude run 7\n",
|
|
137
|
+
},
|
|
138
|
+
T(9),
|
|
139
|
+
);
|
|
140
|
+
commit(repo, { [`${S}/RESULTS.md`]: "41 of 60\n" }, T(10));
|
|
141
|
+
// Guards: the order is never read from a directory's history — the plan is reported instead.
|
|
142
|
+
expect(lint(repo)).toEqual(["decision-not-file"]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("lists a symlink in the study's folder as a file, never following it: a loop or a link outside", () => {
|
|
146
|
+
const repo = join(root, "links");
|
|
147
|
+
initRepo(repo);
|
|
148
|
+
mkdirSync(join(repo, "outside"), { recursive: true });
|
|
149
|
+
writeFileSync(join(repo, "outside", "RESULTS.md"), "not this study's\n");
|
|
150
|
+
commit(repo, PAPER, T(9));
|
|
151
|
+
commit(repo, { [`${S}/decision.md`]: "exclude run 7\n" }, T(10));
|
|
152
|
+
symlinkSync(".", join(repo, S, "loop"));
|
|
153
|
+
symlinkSync(join(repo, "outside"), join(repo, S, "ext"));
|
|
154
|
+
commit(repo, { [`${S}/RESULTS.md`]: "41 of 60\n" }, T(11));
|
|
155
|
+
// Guards: a link to its own folder would recurse forever, and one outside would bring a foreign
|
|
156
|
+
// `RESULTS.md` into the study; both are entries git itself stores as links.
|
|
157
|
+
expect(lint(repo)).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("loud on a decision deleted after its outputs: a decision that no longer stands decides nothing", () => {
|
|
161
|
+
const repo = join(root, "decision-deleted");
|
|
162
|
+
initRepo(repo);
|
|
163
|
+
commit(repo, PAPER, T(9));
|
|
164
|
+
commit(repo, { [`${S}/decision.md`]: "exclude run 7\n" }, T(10));
|
|
165
|
+
commit(repo, { [`${S}/RESULTS.md`]: "41 of 60\n" }, T(11));
|
|
166
|
+
commit(repo, { [`${S}/decision.md`]: null }, T(12));
|
|
167
|
+
// Guards: the order was right once; with the decision gone from HEAD the study is reported.
|
|
168
|
+
expect(lint(repo)).toEqual(["decision-deleted"]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("loud on a decision that is a symbolic link: git records the link, not the decision", () => {
|
|
172
|
+
const repo = join(root, "decision-link");
|
|
173
|
+
initRepo(repo);
|
|
174
|
+
mkdirSync(join(repo, "p"), { recursive: true });
|
|
175
|
+
writeFileSync(join(repo, "p/elsewhere.md"), "exclude run 7\n");
|
|
176
|
+
mkdirSync(join(repo, S), { recursive: true });
|
|
177
|
+
symlinkSync("../../elsewhere.md", join(repo, S, "decision.md"));
|
|
178
|
+
commit(repo, PAPER, T(9));
|
|
179
|
+
commit(repo, { [`${S}/RESULTS.md`]: "41 of 60\n" }, T(10));
|
|
180
|
+
// Guards: a link committed first proves nothing of what it pointed to when the run started.
|
|
181
|
+
expect(lint(repo)).toEqual(["decision-not-file"]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("never reads through a study folder that is a symbolic link", () => {
|
|
185
|
+
const repo = join(root, "folder-link");
|
|
186
|
+
initRepo(repo);
|
|
187
|
+
const elsewhere = "p/elsewhere";
|
|
188
|
+
commit(
|
|
189
|
+
repo,
|
|
190
|
+
{
|
|
191
|
+
...Object.fromEntries(
|
|
192
|
+
Object.entries(PAPER).filter(([k]) => !k.startsWith(S)),
|
|
193
|
+
),
|
|
194
|
+
[`${elsewhere}/README.md`]: "# outcome\n",
|
|
195
|
+
[`${elsewhere}/plan.json`]: planText(planOf(DECIDED)),
|
|
196
|
+
[`${elsewhere}/RESULTS.md`]: "41 of 60\n",
|
|
197
|
+
},
|
|
198
|
+
T(9),
|
|
199
|
+
);
|
|
200
|
+
symlinkSync("../elsewhere", join(repo, S));
|
|
201
|
+
commit(repo, {}, T(10));
|
|
202
|
+
// Guards: the outputs behind the link are not this paper's study; nothing is read through it.
|
|
203
|
+
expect(lint(repo)).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("a plan that is a symbolic link declares no mode: the order of its study is not judged", () => {
|
|
207
|
+
const repo = join(root, "plan-link");
|
|
208
|
+
initRepo(repo);
|
|
209
|
+
mkdirSync(join(repo, "p/elsewhere"), { recursive: true });
|
|
210
|
+
writeFileSync(join(repo, "p/elsewhere/plan.json"), planText(planOf(DECIDED)));
|
|
211
|
+
mkdirSync(join(repo, S), { recursive: true });
|
|
212
|
+
symlinkSync("../../elsewhere/plan.json", join(repo, S, "plan.json"));
|
|
213
|
+
commit(
|
|
214
|
+
repo,
|
|
215
|
+
{
|
|
216
|
+
...Object.fromEntries(
|
|
217
|
+
Object.entries(PAPER).filter(([k]) => k !== `${S}/plan.json`),
|
|
218
|
+
),
|
|
219
|
+
[`${S}/RESULTS.md`]: "41 of 60\n",
|
|
220
|
+
},
|
|
221
|
+
T(9),
|
|
222
|
+
);
|
|
223
|
+
commit(repo, { [`${S}/decision.md`]: "exclude run 7\n" }, T(10));
|
|
224
|
+
// Guards: read through the link, the plan is a decide-first one and its output precedes its
|
|
225
|
+
// decision (`output-before-decision`); a link is read through by nothing, so the study declares no mode.
|
|
226
|
+
expect(lint(repo)).toEqual([]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("loud on a decision under a linked directory: git records the link, not the files behind it", () => {
|
|
230
|
+
const repo = join(root, "decision-dir-link");
|
|
231
|
+
initRepo(repo);
|
|
232
|
+
mkdirSync(join(repo, "p/elsewhere"), { recursive: true });
|
|
233
|
+
writeFileSync(join(repo, "p/elsewhere/decision.md"), "exclude run 7\n");
|
|
234
|
+
const file = "repro/P1-S01-outcome/sub/decision.md";
|
|
235
|
+
commit(
|
|
236
|
+
repo,
|
|
237
|
+
{
|
|
238
|
+
...PAPER,
|
|
239
|
+
[`${S}/plan.json`]: planText(
|
|
240
|
+
planOf({ ...DECIDED, decision: { file, note: "n" } }),
|
|
241
|
+
),
|
|
242
|
+
},
|
|
243
|
+
T(9),
|
|
244
|
+
);
|
|
245
|
+
symlinkSync("../../elsewhere", join(repo, S, "sub"));
|
|
246
|
+
commit(repo, {}, T(10));
|
|
247
|
+
commit(repo, { [`${S}/RESULTS.md`]: "41 of 60\n" }, T(11));
|
|
248
|
+
// Guards: the decision file exists, and is committed as a path through a link; the order proof
|
|
249
|
+
// would be read from the link's history. The study is reported instead.
|
|
250
|
+
expect(lint(repo)).toEqual(["decision-linked"]);
|
|
251
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
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 { filter, map, pipe } from "remeda";
|
|
18
|
+
import { callerPath } from "./caller-path.ts";
|
|
19
|
+
import { decisionEntry, modeStudies, type ModeStudy } from "./study-modes.ts";
|
|
20
|
+
import {
|
|
21
|
+
judgeOrder,
|
|
22
|
+
orderMessage,
|
|
23
|
+
type DecideFirstStudy,
|
|
24
|
+
type OrderFinding,
|
|
25
|
+
} from "./domain/decide-first.ts";
|
|
26
|
+
import { REPRO_DIR } from "./domain/repro-layout.ts";
|
|
27
|
+
import type { DecideFirst } from "./domain/study-mode.ts";
|
|
28
|
+
import type {
|
|
29
|
+
FolderRuleContext,
|
|
30
|
+
FolderRuleModule,
|
|
31
|
+
} from "./folder-venue-rule.ts";
|
|
32
|
+
import type { GitOrder } from "./ports/git-history.ts";
|
|
33
|
+
import type { StudyDeps } from "./ports/study-deps.ts";
|
|
34
|
+
import { studyFolder } from "./study-folder.ts";
|
|
35
|
+
import { registryOf } from "./study-registry-rule.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
|
+
const reader = studyFolder(paper, deps);
|
|
51
|
+
return pipe(
|
|
52
|
+
modeStudies(registryOf(paper, deps), reader, deps.codec),
|
|
53
|
+
filter(isDecidedAndReady),
|
|
54
|
+
map(({ row, plan, mode }): DecideFirstStudy => {
|
|
55
|
+
const folder = join(paper, REPRO_DIR, row.folder);
|
|
56
|
+
const decision = join(paper, mode.decision.file);
|
|
57
|
+
const on = decisionEntry(reader, mode.decision.file);
|
|
58
|
+
return {
|
|
59
|
+
id: row.id,
|
|
60
|
+
folder: row.folder,
|
|
61
|
+
plan,
|
|
62
|
+
mode,
|
|
63
|
+
tree: reader.list(row.folder),
|
|
64
|
+
facts:
|
|
65
|
+
on.kind === "ok"
|
|
66
|
+
? deps.git.order(callerPath(folder), callerPath(decision))
|
|
67
|
+
: on,
|
|
68
|
+
};
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Only a ready plan decides: a draft may name no outputs, or an empty decision file. */
|
|
74
|
+
const isDecidedAndReady = (
|
|
75
|
+
s: ModeStudy,
|
|
76
|
+
): s is ModeStudy & { readonly mode: DecideFirst } =>
|
|
77
|
+
s.mode.kind === "decide_before_run" && s.empty.length === 0;
|
|
78
|
+
|
|
79
|
+
const PROBLEMS: readonly OrderFinding["problem"][] = [
|
|
80
|
+
"decision-is-output",
|
|
81
|
+
"decision-deleted",
|
|
82
|
+
"decision-not-file",
|
|
83
|
+
"decision-linked",
|
|
84
|
+
"no-history",
|
|
85
|
+
"shallow",
|
|
86
|
+
"undecided-output",
|
|
87
|
+
"output-before-decision",
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
const NAME = "repro/outputs-before-decision";
|
|
91
|
+
|
|
92
|
+
/** On `PIPELINE-STATUS.md`, every finding of every decide-first study of its paper, at its first line. */
|
|
93
|
+
const reportOn = (context: FolderRuleContext, deps: OrderDeps) => {
|
|
94
|
+
if (basename(context.filename) !== STATUS_FILE) return {};
|
|
95
|
+
return {
|
|
96
|
+
root() {
|
|
97
|
+
const at = context.sourceCode.getLocFromIndex(0);
|
|
98
|
+
decideFirstStudies(dirname(context.filename), deps)
|
|
99
|
+
.flatMap(judgeOrder)
|
|
100
|
+
.forEach((f) => {
|
|
101
|
+
context.report({
|
|
102
|
+
loc: { start: at, end: at },
|
|
103
|
+
messageId: f.problem,
|
|
104
|
+
data: { text: orderMessage(f) },
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** The rule, reading the folder and git through `deps`. */
|
|
112
|
+
export const outputsBeforeDecisionRules = (
|
|
113
|
+
deps: OrderDeps,
|
|
114
|
+
): Readonly<Record<"outputs-before-decision", FolderRuleModule>> => ({
|
|
115
|
+
"outputs-before-decision": {
|
|
116
|
+
meta: {
|
|
117
|
+
type: "suggestion",
|
|
118
|
+
docs: {
|
|
119
|
+
description:
|
|
120
|
+
"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",
|
|
121
|
+
url: rulePageUrl(NAME),
|
|
122
|
+
},
|
|
123
|
+
schema: [],
|
|
124
|
+
messages: Object.fromEntries(PROBLEMS.map((p) => [p, "{{text}}"])),
|
|
125
|
+
},
|
|
126
|
+
create: (context) => reportOn(context, deps),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/** Its level in paperlint's own config: an error. */
|
|
131
|
+
export const ORDER_RULE_LEVELS: Readonly<{
|
|
132
|
+
"repro/outputs-before-decision": "error";
|
|
133
|
+
}> = { "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,21 @@
|
|
|
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 reader of a study's folder
|
|
5
|
+
* (`src/study-folder.ts`), outputs listing included (`StudyFolder.list`), reads with it.
|
|
6
|
+
*/
|
|
7
|
+
import type { AbsolutePath } from "../domain/paths.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One entry of a directory: a regular file, a directory, a symbolic link (not followed), or `other` —
|
|
11
|
+
* anything that is none of those (a FIFO, a socket, a device). An `other` entry is a name and nothing
|
|
12
|
+
* more: it is never read (opening a FIFO blocks) and never descended into, and an outputs listing
|
|
13
|
+
* still names it.
|
|
14
|
+
*/
|
|
15
|
+
export interface DirEntry {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly kind: "file" | "dir" | "symlink" | "other";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The entries of `dir`; none when it does not exist or is not a directory. */
|
|
21
|
+
export type ListEntries = (dir: AbsolutePath) => readonly DirEntry[];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the readers of a paper's studies are handed: the disk, the entries of a directory with what
|
|
3
|
+
* each is (no symbolic link followed), and the parsers of the registry and its plans. The study folder's
|
|
4
|
+
* reader (`src/study-folder.ts`), the gate and the order rule are built from it.
|
|
5
|
+
*/
|
|
6
|
+
import type { Files } from "./files.ts";
|
|
7
|
+
import type { ListEntries } from "./list-entries.ts";
|
|
8
|
+
import type { StudyRegistryCodec } from "./study-registry-codec.ts";
|
|
9
|
+
|
|
10
|
+
export interface StudyDeps {
|
|
11
|
+
readonly files: Files;
|
|
12
|
+
readonly entries: ListEntries;
|
|
13
|
+
readonly codec: StudyRegistryCodec;
|
|
14
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reads a paper's study folders allow, shaped by what the study readers need: a row's folder is
|
|
3
|
+
* entered or refused, a path inside it is located or read, the files under it are listed, and the
|
|
4
|
+
* decision file's path is walked — none of it by following a symbolic link. The reader of every plan,
|
|
5
|
+
* result file, output listing and decision (`src/study-folder.ts`) is this port's one implementation.
|
|
6
|
+
*/
|
|
7
|
+
import type { Located } from "../domain/study-registry.ts";
|
|
8
|
+
|
|
9
|
+
/** What reading a path inside a study's folder gave. */
|
|
10
|
+
export type FolderRead =
|
|
11
|
+
| { readonly kind: "text"; readonly text: string }
|
|
12
|
+
| { readonly kind: "absent" }
|
|
13
|
+
| { readonly kind: "linked"; readonly link: string };
|
|
14
|
+
|
|
15
|
+
/** What a path in the paper is: `Located`, a directory, or something that is no regular file (a FIFO, a socket). */
|
|
16
|
+
export type PathEntry =
|
|
17
|
+
Located | { readonly kind: "dir" } | { readonly kind: "other" };
|
|
18
|
+
|
|
19
|
+
/** Why a row's folder may not be entered: it is no one folder inside `repro/`, or it is a link. */
|
|
20
|
+
export type FolderRefusal = "not-a-folder" | "symbolic-link";
|
|
21
|
+
|
|
22
|
+
/** The reads a paper's study folders allow. */
|
|
23
|
+
export interface StudyFolder {
|
|
24
|
+
/** Why `folder` may not be entered; null when it may. */
|
|
25
|
+
readonly refusal: (folder: string) => FolderRefusal | null;
|
|
26
|
+
/** What `path` in `folder` is; nothing is read. */
|
|
27
|
+
readonly locate: (folder: string, path: string) => Located;
|
|
28
|
+
/** The text of the file at `path` in `folder`. */
|
|
29
|
+
readonly read: (folder: string, path: string) => FolderRead;
|
|
30
|
+
/**
|
|
31
|
+
* Every file under `folder`, relative to it and `/`-separated: a symbolic link is listed as a file
|
|
32
|
+
* (git stores it as one) and never descended into; a FIFO or a socket is listed by name too (it is
|
|
33
|
+
* never read). None for a folder that may not be entered.
|
|
34
|
+
*/
|
|
35
|
+
readonly list: (folder: string) => readonly string[];
|
|
36
|
+
/** What `path`, relative to the paper, is: a decision file is held to be a regular file. */
|
|
37
|
+
readonly entryAt: (path: string) => PathEntry;
|
|
38
|
+
}
|
|
@@ -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 {
|
|
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
|
-
/**
|
|
12
|
-
|
|
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
|
}
|
package/src/presets.ts
CHANGED
|
@@ -406,8 +406,7 @@ export type ShippedVenue = Pick<
|
|
|
406
406
|
>;
|
|
407
407
|
|
|
408
408
|
/**
|
|
409
|
-
* A resolved preset as a name knows it: its label, its aliases, its chain. Pure.
|
|
410
|
-
* `pick`: the app layer may not import it — `boundaries/dependencies`, `APP_EXTERNALS`.)
|
|
409
|
+
* A resolved preset as a name knows it: its label, its aliases, its chain. Pure.
|
|
411
410
|
*/
|
|
412
411
|
export const venueOf = ({
|
|
413
412
|
label,
|
package/src/rule-cases.test.ts
CHANGED
|
@@ -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(
|
|
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: {
|