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.
- package/.github/workflows/ci.yml +15 -4
- package/README.md +1 -0
- 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/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 +24 -0
- package/dist/adapters/node/list-entries.io.js.map +1 -0
- package/dist/adapters/study-registry/index.d.ts +412 -1
- package/dist/adapters/study-registry/index.d.ts.map +1 -1
- package/dist/adapters/study-registry/index.js +245 -8
- package/dist/adapters/study-registry/index.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +38 -6
- package/dist/cli.js.map +1 -1
- package/dist/domain/decide-first.d.ts +100 -0
- package/dist/domain/decide-first.d.ts.map +1 -0
- package/dist/domain/decide-first.js +102 -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 +62 -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 +253 -10
- package/dist/domain/study-registry.d.ts.map +1 -1
- package/dist/domain/study-registry.js +325 -27
- 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 +90 -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 +17 -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-registry-codec.d.ts +9 -2
- package/dist/ports/study-registry-codec.d.ts.map +1 -1
- package/dist/studies.d.ts +4 -0
- package/dist/studies.d.ts.map +1 -1
- package/dist/studies.js +55 -24
- package/dist/studies.js.map +1 -1
- package/dist/study-gate.d.ts +14 -0
- package/dist/study-gate.d.ts.map +1 -0
- package/dist/study-gate.js +95 -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 +13 -0
- package/dist/study-index-input.js.map +1 -0
- package/dist/study-modes.d.ts +45 -0
- package/dist/study-modes.d.ts.map +1 -0
- package/dist/study-modes.js +70 -0
- package/dist/study-modes.js.map +1 -0
- package/dist/study-registry-rule.d.ts +29 -6
- package/dist/study-registry-rule.d.ts.map +1 -1
- package/dist/study-registry-rule.js +91 -40
- 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 +247 -0
- package/docs/design/study-registry.md +137 -15
- package/docs/e2e.md +8 -4
- package/docs/layout.md +26 -21
- package/docs/rules/repro/outputs-before-decision.md +119 -0
- package/docs/rules/repro/study-plan-name.md +70 -0
- package/docs/rules/repro/study-registered.md +206 -15
- package/docs/rules.md +54 -52
- package/docs/testing.md +1 -0
- package/package.json +2 -1
- package/scripts/check.ts +6 -0
- package/skills/build-benchmark/SKILL.md +24 -12
- package/skills/build-benchmark/SKILL.md.spec.ts +23 -11
- package/skills/build-label-page/SKILL.md +9 -7
- package/skills/build-label-page/SKILL.md.spec.ts +8 -6
- 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/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/run-agent-grid/SKILL.md +10 -5
- package/skills/run-agent-grid/SKILL.md.spec.ts +9 -4
- package/skills/tighten-paper/SKILL.md +5 -1
- package/skills/tighten-paper/SKILL.md.spec.ts +4 -0
- 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/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 +26 -0
- package/src/adapters/node/list-entries.test.ts +43 -0
- package/src/adapters/study-registry/index.test.ts +343 -0
- package/src/adapters/study-registry/index.ts +374 -10
- package/src/adapters/study-registry/mode.test.ts +162 -0
- package/src/cli.ts +52 -6
- package/src/domain/decide-first.test.ts +258 -0
- package/src/domain/decide-first.ts +243 -0
- package/src/domain/index-columns.test.ts +216 -0
- package/src/domain/index-columns.ts +105 -0
- package/src/domain/study-measurements.test.ts +633 -0
- package/src/domain/study-mode.test.ts +55 -0
- package/src/domain/study-mode.ts +78 -0
- package/src/domain/study-registry.test.ts +48 -32
- package/src/domain/study-registry.ts +621 -36
- package/src/new-paper.test.ts +6 -1
- package/src/outputs-before-decision-rule.test.ts +206 -0
- package/src/outputs-before-decision-rule.ts +124 -0
- package/src/ports/git-history.ts +44 -0
- package/src/ports/list-entries.ts +18 -0
- package/src/ports/study-registry-codec.ts +13 -2
- package/src/rule-cases.test.ts +103 -1
- package/src/studies-cli.test.ts +2 -2
- package/src/studies-index-modes.test.ts +129 -0
- package/src/studies.test.ts +39 -9
- package/src/studies.ts +99 -29
- package/src/study-gate.test.ts +316 -0
- package/src/study-gate.ts +161 -0
- package/src/study-index-input.ts +23 -0
- package/src/study-modes.ts +120 -0
- package/src/study-registry-rule.test.ts +333 -7
- package/src/study-registry-rule.ts +145 -41
- package/src/study-run.ts +21 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `paperlint studies gate <paper-dir> <id>` (`src/study-gate.ts`) on papers held in memory, git a
|
|
3
|
+
* stand-in: 0 once the decision is committed as it stands, 1 with the reason otherwise — and 0 for a
|
|
4
|
+
* study whose run waits on nothing, saying why.
|
|
5
|
+
*/
|
|
6
|
+
import { expect, it } from "vitest";
|
|
7
|
+
import type { StudyMode } from "./domain/study-mode.ts";
|
|
8
|
+
import {
|
|
9
|
+
DATE,
|
|
10
|
+
DECIDED,
|
|
11
|
+
DIR,
|
|
12
|
+
ROW,
|
|
13
|
+
SHA,
|
|
14
|
+
planOf,
|
|
15
|
+
studies,
|
|
16
|
+
tree,
|
|
17
|
+
} from "../test/study-tree.ts";
|
|
18
|
+
const DECISION = `${DIR}/repro/P1-S01-outcome/decision.md`;
|
|
19
|
+
const decided = (mode: StudyMode = DECIDED) => tree({}, planOf(mode));
|
|
20
|
+
|
|
21
|
+
it("open once the decision is committed as it stands: exit 0 with its commit and date", () => {
|
|
22
|
+
const r = studies(
|
|
23
|
+
"gate",
|
|
24
|
+
decided(),
|
|
25
|
+
{},
|
|
26
|
+
{
|
|
27
|
+
git: { [DECISION]: { kind: "committed", commit: SHA, date: DATE } },
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
// Guards: the line a runner's log keeps, and the exit code `gate && run` reads.
|
|
31
|
+
expect([r.code, r.log, r.err]).toEqual([
|
|
32
|
+
0,
|
|
33
|
+
[
|
|
34
|
+
`P1-S01: the decision repro/P1-S01-outcome/decision.md is committed in ${SHA} (${DATE}); the run may start.`,
|
|
35
|
+
],
|
|
36
|
+
[],
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("closed while the decision is uncommitted, changed since its commit, or unknown to git", () => {
|
|
41
|
+
const gate = (state: Parameters<typeof studies>[3]) =>
|
|
42
|
+
studies("gate", decided(), {}, state);
|
|
43
|
+
// Guards: each reason the run must wait is said, exit 1.
|
|
44
|
+
expect(
|
|
45
|
+
[
|
|
46
|
+
gate({}),
|
|
47
|
+
gate({ git: { [DECISION]: { kind: "changed", commit: SHA } } }),
|
|
48
|
+
gate({
|
|
49
|
+
git: { [DECISION]: { kind: "unknown", why: "git is not installed" } },
|
|
50
|
+
}),
|
|
51
|
+
].map((r) => [r.code, r.err]),
|
|
52
|
+
).toEqual([
|
|
53
|
+
[
|
|
54
|
+
1,
|
|
55
|
+
[
|
|
56
|
+
"P1-S01: the decision repro/P1-S01-outcome/decision.md is not committed; the run waits for it. Write the decision there, commit it, then run this again.",
|
|
57
|
+
],
|
|
58
|
+
],
|
|
59
|
+
[
|
|
60
|
+
1,
|
|
61
|
+
[
|
|
62
|
+
`P1-S01: the decision repro/P1-S01-outcome/decision.md has changed since its last commit, ${SHA}; the run waits for the decision as committed. Commit it, then run this again.`,
|
|
63
|
+
],
|
|
64
|
+
],
|
|
65
|
+
[
|
|
66
|
+
1,
|
|
67
|
+
[
|
|
68
|
+
"P1-S01: git could not say whether the decision repro/P1-S01-outcome/decision.md is committed: git is not installed.",
|
|
69
|
+
],
|
|
70
|
+
],
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("refuses a plan that names no decision file, or one its own outputs match", () => {
|
|
75
|
+
// Guards: a gate on nothing, or on a file the run itself produces, is no gate.
|
|
76
|
+
expect([
|
|
77
|
+
studies("gate", decided({ ...DECIDED, decision: { file: " ", note: "n" } }))
|
|
78
|
+
.err,
|
|
79
|
+
studies("gate", decided({ ...DECIDED, outputs: ["*.md"] })).err,
|
|
80
|
+
]).toEqual([
|
|
81
|
+
[
|
|
82
|
+
"P1-S01: its plan.json is a draft — `mode.decision.file` is empty; the run waits for a ready plan. Fill it in, then run this again.",
|
|
83
|
+
],
|
|
84
|
+
[
|
|
85
|
+
"P1-S01: its decision repro/P1-S01-outcome/decision.md is matched by its output `*.md`; a decision cannot be what its own run produces.",
|
|
86
|
+
],
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("an exploratory study's run waits on nothing: exit 0, saying so", () => {
|
|
91
|
+
// Guards: the runner may call gate on every study; the answer names the mode.
|
|
92
|
+
expect(
|
|
93
|
+
studies("gate", decided({ kind: "exploratory", reason: "a first look" })),
|
|
94
|
+
).toMatchObject({
|
|
95
|
+
code: 0,
|
|
96
|
+
log: [
|
|
97
|
+
"P1-S01 is exploratory (a first look): it has no gate, and its results are reported as exploratory.",
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("refuses a study that declares no mode, and one whose row names a folder outside repro/", () => {
|
|
103
|
+
// Guards: a measurement with no mode is not gated by default — it is refused until it says how;
|
|
104
|
+
// and a row naming `../x` is never read through, so no file outside the paper decides the gate.
|
|
105
|
+
expect(
|
|
106
|
+
[
|
|
107
|
+
studies("gate", tree({}, planOf(undefined))),
|
|
108
|
+
studies(
|
|
109
|
+
"gate",
|
|
110
|
+
tree(
|
|
111
|
+
{
|
|
112
|
+
[`${DIR}/x/plan.json`]: JSON.stringify(planOf(DECIDED)),
|
|
113
|
+
},
|
|
114
|
+
planOf(DECIDED),
|
|
115
|
+
{ ...ROW, folder: "../x" },
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
].map((r) => [r.code, r.err]),
|
|
119
|
+
).toEqual([
|
|
120
|
+
[
|
|
121
|
+
1,
|
|
122
|
+
[
|
|
123
|
+
"P1-S01 declares no mode in papers/example: no row of papers/example/repro/studies.json with this id names a plan.json that parses with `mode`. Lint the paper (`npx paperlint lint`) to see why.",
|
|
124
|
+
],
|
|
125
|
+
],
|
|
126
|
+
[
|
|
127
|
+
1,
|
|
128
|
+
[
|
|
129
|
+
"P1-S01: its row names the folder `../x`, which is not one folder inside papers/example/repro/; nothing is read through it. Give the row its own folder's name.",
|
|
130
|
+
],
|
|
131
|
+
],
|
|
132
|
+
]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("refuses a command line without the study's id, or with an argument too many", () => {
|
|
136
|
+
// Guards: exit 2 is the command line's, never the paper's state.
|
|
137
|
+
expect(
|
|
138
|
+
[
|
|
139
|
+
studies("gate", decided(), { title: undefined }),
|
|
140
|
+
studies("gate", decided(), { extra: ["P1-S02"] }),
|
|
141
|
+
].map((r) => [r.code, r.err]),
|
|
142
|
+
).toEqual([
|
|
143
|
+
[
|
|
144
|
+
2,
|
|
145
|
+
[
|
|
146
|
+
"studies gate needs the study's id: npx paperlint studies gate <paper-dir> <id>",
|
|
147
|
+
],
|
|
148
|
+
],
|
|
149
|
+
[
|
|
150
|
+
2,
|
|
151
|
+
[
|
|
152
|
+
"studies gate takes the paper's folder and one id — `P1-S02` is one argument too many",
|
|
153
|
+
],
|
|
154
|
+
],
|
|
155
|
+
]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("a plan it cannot read through declares no mode: no registry, a plan outside its folder, a NUL, not JSON", () => {
|
|
159
|
+
const noMode = (t: Readonly<Record<string, string>>) =>
|
|
160
|
+
studies("gate", t).err[0]?.split(":")[0];
|
|
161
|
+
const withoutRegistry = Object.fromEntries(
|
|
162
|
+
Object.entries(decided()).filter(([k]) => !k.endsWith("studies.json")),
|
|
163
|
+
);
|
|
164
|
+
// Guards: each is refused as declaring no mode — none is read past, none opens the gate.
|
|
165
|
+
expect([
|
|
166
|
+
noMode(withoutRegistry),
|
|
167
|
+
noMode(tree({}, planOf(DECIDED), { ...ROW, plan: "../plan.json" })),
|
|
168
|
+
noMode(tree({}, planOf(DECIDED), { ...ROW, plan: "a\0b.json" })),
|
|
169
|
+
noMode({ ...decided(), [`${DIR}/repro/P1-S01-outcome/plan.json`]: "{" }),
|
|
170
|
+
]).toEqual(Array(4).fill("P1-S01 declares no mode in papers/example"));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("refuses a decision that names a directory, whatever git says of it", () => {
|
|
174
|
+
const dir = "repro/P1-S01-outcome/decisions";
|
|
175
|
+
const r = studies(
|
|
176
|
+
"gate",
|
|
177
|
+
tree(
|
|
178
|
+
{ [`${DIR}/${dir}/a.md`]: "exclude run 7\n" },
|
|
179
|
+
planOf({ ...DECIDED, decision: { file: dir, note: "n" } }),
|
|
180
|
+
),
|
|
181
|
+
{},
|
|
182
|
+
{
|
|
183
|
+
git: {
|
|
184
|
+
[`${DIR}/${dir}`]: { kind: "committed", commit: SHA, date: DATE },
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
// Guards: git answers for a directory with the last commit under it — never a decision as committed.
|
|
189
|
+
expect([r.code, r.err]).toEqual([
|
|
190
|
+
1,
|
|
191
|
+
[
|
|
192
|
+
`P1-S01: its decision ${dir} is a directory, not a file; the run cannot be gated on it. Name the decision file in \`mode.decision.file\`.`,
|
|
193
|
+
],
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("closed when the decision was committed and then deleted: it is not in HEAD", () => {
|
|
198
|
+
const r = studies(
|
|
199
|
+
"gate",
|
|
200
|
+
decided(),
|
|
201
|
+
{},
|
|
202
|
+
{ git: { [DECISION]: { kind: "deleted", commit: SHA } } },
|
|
203
|
+
);
|
|
204
|
+
// Guards: a decision deleted in a later commit leaves a clean tree, and still gates nothing.
|
|
205
|
+
expect([r.code, r.err]).toEqual([
|
|
206
|
+
1,
|
|
207
|
+
[
|
|
208
|
+
`P1-S01: the decision repro/P1-S01-outcome/decision.md is not in HEAD: it was deleted in ${SHA}; the run waits for a decision that stands. Restore it, commit it, then run this again.`,
|
|
209
|
+
],
|
|
210
|
+
]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("refuses a plan that is still a draft: a mode is no decision until its plan is ready", () => {
|
|
214
|
+
const r = studies(
|
|
215
|
+
"gate",
|
|
216
|
+
decided({ ...DECIDED, outputs: [] }),
|
|
217
|
+
{},
|
|
218
|
+
{ git: { [DECISION]: { kind: "committed", commit: SHA, date: DATE } } },
|
|
219
|
+
);
|
|
220
|
+
const blankReason = studies(
|
|
221
|
+
"gate",
|
|
222
|
+
decided({ kind: "exploratory", reason: " " }),
|
|
223
|
+
);
|
|
224
|
+
const twoEmpty = studies(
|
|
225
|
+
"gate",
|
|
226
|
+
decided({
|
|
227
|
+
...DECIDED,
|
|
228
|
+
outputs: [],
|
|
229
|
+
decision: { ...DECIDED.decision, note: "" },
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
// Guards: a draft names no outputs to keep from the decision, and an exploratory plan with no
|
|
233
|
+
// reason is no declaration; the gate opens on a ready plan only, whatever its mode.
|
|
234
|
+
expect([
|
|
235
|
+
[r.code, r.err],
|
|
236
|
+
[blankReason.code, blankReason.err],
|
|
237
|
+
[twoEmpty.code, twoEmpty.err],
|
|
238
|
+
]).toEqual([
|
|
239
|
+
[
|
|
240
|
+
1,
|
|
241
|
+
[
|
|
242
|
+
"P1-S01: its plan.json is a draft — `mode.outputs` is empty; the run waits for a ready plan. Fill it in, then run this again.",
|
|
243
|
+
],
|
|
244
|
+
],
|
|
245
|
+
[
|
|
246
|
+
1,
|
|
247
|
+
[
|
|
248
|
+
"P1-S01: its plan.json is a draft — `mode.reason` is empty; the run waits for a ready plan. Fill it in, then run this again.",
|
|
249
|
+
],
|
|
250
|
+
],
|
|
251
|
+
[
|
|
252
|
+
1,
|
|
253
|
+
[
|
|
254
|
+
"P1-S01: its plan.json is a draft — `mode.outputs`, `mode.decision.note` are empty; the run waits for a ready plan. Fill it in, then run this again.",
|
|
255
|
+
],
|
|
256
|
+
],
|
|
257
|
+
]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("refuses a decision or a study folder that is a symbolic link", () => {
|
|
261
|
+
const committed = {
|
|
262
|
+
[DECISION]: { kind: "committed", commit: SHA, date: DATE },
|
|
263
|
+
} as const;
|
|
264
|
+
const decisionLink = studies(
|
|
265
|
+
"gate",
|
|
266
|
+
{ ...decided(), [DECISION]: "exclude run 7\n" },
|
|
267
|
+
{},
|
|
268
|
+
{ git: committed, links: [DECISION] },
|
|
269
|
+
);
|
|
270
|
+
const folderLink = studies(
|
|
271
|
+
"gate",
|
|
272
|
+
decided(),
|
|
273
|
+
{},
|
|
274
|
+
{ git: committed, links: [`${DIR}/repro/P1-S01-outcome`] },
|
|
275
|
+
);
|
|
276
|
+
// Guards: git records a link, not the file it points to; a linked folder leads out of `repro/`.
|
|
277
|
+
expect([
|
|
278
|
+
[decisionLink.code, decisionLink.err],
|
|
279
|
+
[folderLink.code, folderLink.err],
|
|
280
|
+
]).toEqual([
|
|
281
|
+
[
|
|
282
|
+
1,
|
|
283
|
+
[
|
|
284
|
+
"P1-S01: its decision repro/P1-S01-outcome/decision.md is a symbolic link, not a file; the run cannot be gated on it. Name the decision file in `mode.decision.file`.",
|
|
285
|
+
],
|
|
286
|
+
],
|
|
287
|
+
[
|
|
288
|
+
1,
|
|
289
|
+
[
|
|
290
|
+
"P1-S01: its row names the folder `P1-S01-outcome`, which is not one folder inside papers/example/repro/; nothing is read through it. Give the row its own folder's name.",
|
|
291
|
+
],
|
|
292
|
+
],
|
|
293
|
+
]);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("never reads a plan through a backslash: `..\\x\\plan.json` is outside the folder on Windows", () => {
|
|
297
|
+
const plan = "..\\x\\plan.json";
|
|
298
|
+
const r = studies(
|
|
299
|
+
"gate",
|
|
300
|
+
tree(
|
|
301
|
+
{
|
|
302
|
+
[`${DIR}/repro/P1-S01-outcome/${plan}`]: JSON.stringify(
|
|
303
|
+
planOf(DECIDED),
|
|
304
|
+
),
|
|
305
|
+
},
|
|
306
|
+
planOf(DECIDED),
|
|
307
|
+
{ ...ROW, plan },
|
|
308
|
+
),
|
|
309
|
+
{},
|
|
310
|
+
{ git: { [DECISION]: { kind: "committed", commit: SHA, date: DATE } } },
|
|
311
|
+
);
|
|
312
|
+
// Guards: the containment test is the registry rule's own `isInside`, the same on every platform.
|
|
313
|
+
expect(r.err[0]?.split(":")[0]).toBe(
|
|
314
|
+
"P1-S01 declares no mode in papers/example",
|
|
315
|
+
);
|
|
316
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `paperlint studies gate <paper-dir> <id>` — may the study's run start? For a study decided before
|
|
3
|
+
* its run (the default mode), only once its decision file exists and is committed as it stands: exit 0
|
|
4
|
+
* with the commit and its date, else exit 1 saying why. A runner calls it first:
|
|
5
|
+
*
|
|
6
|
+
* npx paperlint studies gate papers/my-paper P1-S03 && ./run.sh
|
|
7
|
+
*
|
|
8
|
+
* An exploratory study has no gate: it exits 0 and says so. A study that declares no mode, names no
|
|
9
|
+
* decision file, or whose row names a folder that is not one inside `repro/`, is refused.
|
|
10
|
+
*/
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { callerPath } from "./caller-path.ts";
|
|
13
|
+
import {
|
|
14
|
+
modeStudies,
|
|
15
|
+
onDisk,
|
|
16
|
+
type ModeStudy,
|
|
17
|
+
type StudyDeps,
|
|
18
|
+
} from "./study-modes.ts";
|
|
19
|
+
import { REPRO_DIR } from "./domain/repro-layout.ts";
|
|
20
|
+
import { decisionAsOutput, type DecideFirst } from "./domain/study-mode.ts";
|
|
21
|
+
import type { Study } from "./domain/study-registry.ts";
|
|
22
|
+
import type { Committed, GitHistory } from "./ports/git-history.ts";
|
|
23
|
+
import { readableFolders, registryOf } from "./study-registry-rule.ts";
|
|
24
|
+
import {
|
|
25
|
+
refusal,
|
|
26
|
+
refused,
|
|
27
|
+
type StudyRefusal,
|
|
28
|
+
type StudyRun,
|
|
29
|
+
} from "./study-run.ts";
|
|
30
|
+
|
|
31
|
+
export interface GateDeps extends StudyDeps {
|
|
32
|
+
readonly git: GitHistory;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The line of a committed state that holds the run back. */
|
|
36
|
+
const shutLine = (
|
|
37
|
+
id: string,
|
|
38
|
+
file: string,
|
|
39
|
+
c: Exclude<Committed, { readonly kind: "committed" }>,
|
|
40
|
+
): string => {
|
|
41
|
+
switch (c.kind) {
|
|
42
|
+
case "changed":
|
|
43
|
+
return `${id}: the decision ${file} has changed since its last commit, ${c.commit}; the run waits for the decision as committed. Commit it, then run this again.`;
|
|
44
|
+
case "deleted":
|
|
45
|
+
return `${id}: the decision ${file} is not in HEAD: it was deleted in ${c.commit}; the run waits for a decision that stands. Restore it, commit it, then run this again.`;
|
|
46
|
+
case "not-committed":
|
|
47
|
+
return `${id}: the decision ${file} is not committed; the run waits for it. Write the decision there, commit it, then run this again.`;
|
|
48
|
+
case "unknown":
|
|
49
|
+
return `${id}: git could not say whether the decision ${file} is committed: ${c.why}.`;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** What a decision's committed state means for the run. */
|
|
54
|
+
export const gateLine = (
|
|
55
|
+
id: string,
|
|
56
|
+
file: string,
|
|
57
|
+
c: Committed,
|
|
58
|
+
): { readonly open: boolean; readonly line: string } =>
|
|
59
|
+
c.kind === "committed"
|
|
60
|
+
? {
|
|
61
|
+
open: true,
|
|
62
|
+
line: `${id}: the decision ${file} is committed in ${c.commit} (${c.date}); the run may start.`,
|
|
63
|
+
}
|
|
64
|
+
: { open: false, line: shutLine(id, file, c) };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Why the decision cannot gate the run though it is named: its own outputs match it, or it is a
|
|
68
|
+
* directory (git answers for a directory with the last commit of anything under it); null when usable.
|
|
69
|
+
*/
|
|
70
|
+
const unusable = (
|
|
71
|
+
paper: string,
|
|
72
|
+
{ id, folder }: Pick<Study, "id" | "folder">,
|
|
73
|
+
mode: DecideFirst,
|
|
74
|
+
deps: GateDeps,
|
|
75
|
+
): StudyRefusal | null => {
|
|
76
|
+
const file = mode.decision.file;
|
|
77
|
+
const glob = decisionAsOutput(folder, mode);
|
|
78
|
+
if (glob !== null)
|
|
79
|
+
return refusal(
|
|
80
|
+
`${id}: its decision ${file} is matched by its output \`${glob}\`; a decision cannot be what its own run produces.`,
|
|
81
|
+
);
|
|
82
|
+
const on = onDisk(join(paper, file), deps);
|
|
83
|
+
return on === "directory" || on === "symbolic link"
|
|
84
|
+
? refusal(
|
|
85
|
+
`${id}: its decision ${file} is a ${on}, not a file; the run cannot be gated on it. Name the decision file in \`mode.decision.file\`.`,
|
|
86
|
+
)
|
|
87
|
+
: null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** The gate of a decide-first study. */
|
|
91
|
+
const gateDecided = (
|
|
92
|
+
paper: string,
|
|
93
|
+
{ id, folder }: Study,
|
|
94
|
+
mode: DecideFirst,
|
|
95
|
+
deps: GateDeps,
|
|
96
|
+
): StudyRun => {
|
|
97
|
+
const file = mode.decision.file;
|
|
98
|
+
const blocked = unusable(paper, { id, folder }, mode, deps);
|
|
99
|
+
if (blocked !== null) return refused(blocked);
|
|
100
|
+
const g = gateLine(
|
|
101
|
+
id,
|
|
102
|
+
file,
|
|
103
|
+
deps.git.committed(callerPath(paper), callerPath(join(paper, file))),
|
|
104
|
+
);
|
|
105
|
+
return g.open ? { ok: true, lines: [g.line] } : refused(refusal(g.line));
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** The refusal when the row of `id` names a folder that is not one inside `repro/`; null when none does. */
|
|
109
|
+
const escapingRow = (
|
|
110
|
+
paper: string,
|
|
111
|
+
shown: string,
|
|
112
|
+
id: string,
|
|
113
|
+
deps: GateDeps,
|
|
114
|
+
): StudyRefusal | null => {
|
|
115
|
+
const r = registryOf(paper, deps);
|
|
116
|
+
const readable = readableFolders(paper, deps);
|
|
117
|
+
const row = (r.kind === "read" ? r.registry.studies : []).find(
|
|
118
|
+
(x) => x.id === id && !readable(x.folder),
|
|
119
|
+
);
|
|
120
|
+
return row === undefined
|
|
121
|
+
? null
|
|
122
|
+
: refusal(
|
|
123
|
+
`${id}: its row names the folder \`${row.folder}\`, which is not one folder inside ${shown}/${REPRO_DIR}/; nothing is read through it. Give the row its own folder's name.`,
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** The refusal of a study whose plan is still a draft: a mode decides nothing until its plan is ready. */
|
|
128
|
+
const draftRefusal = (id: string, s: ModeStudy): StudyRefusal => {
|
|
129
|
+
const names = s.empty.map((f) => `\`${f}\``).join(", ");
|
|
130
|
+
const verb = s.empty.length === 1 ? "is" : "are";
|
|
131
|
+
return refusal(
|
|
132
|
+
`${id}: its ${s.plan} is a draft — ${names} ${verb} empty; the run waits for a ready plan. Fill it in, then run this again.`,
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** `studies gate`: 0 when the run may start, 1 with the reason when not. */
|
|
137
|
+
export function runGate(
|
|
138
|
+
paper: string,
|
|
139
|
+
shown: string,
|
|
140
|
+
id: string,
|
|
141
|
+
deps: GateDeps,
|
|
142
|
+
): StudyRun {
|
|
143
|
+
const escaping = escapingRow(paper, shown, id, deps);
|
|
144
|
+
if (escaping !== null) return refused(escaping);
|
|
145
|
+
const s = modeStudies(paper, deps).find((m) => m.row.id === id);
|
|
146
|
+
if (s === undefined)
|
|
147
|
+
return refused(
|
|
148
|
+
refusal(
|
|
149
|
+
`${id} declares no mode in ${shown}: no row of ${shown}/${REPRO_DIR}/studies.json with this id names a plan.json that parses with \`mode\`. Lint the paper (\`npx paperlint lint\`) to see why.`,
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
if (s.empty.length > 0) return refused(draftRefusal(id, s));
|
|
153
|
+
return s.mode.kind === "decide_before_run"
|
|
154
|
+
? gateDecided(paper, s.row, s.mode, deps)
|
|
155
|
+
: {
|
|
156
|
+
ok: true,
|
|
157
|
+
lines: [
|
|
158
|
+
`${id} is exploratory (${s.mode.reason}): it has no gate, and its results are reported as exploratory.`,
|
|
159
|
+
],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `paperlint studies index` and `studies new` generate the index from beyond the registry: each
|
|
3
|
+
* row's plan, and each decide-first study's history as git gives it now (src/domain/index-columns.ts).
|
|
4
|
+
*/
|
|
5
|
+
import { indexHistory } from "./domain/index-columns.ts";
|
|
6
|
+
import type { IndexInput, RegistryState } from "./domain/study-registry.ts";
|
|
7
|
+
import {
|
|
8
|
+
decideFirstStudies,
|
|
9
|
+
type OrderDeps,
|
|
10
|
+
} from "./outputs-before-decision-rule.ts";
|
|
11
|
+
import { plansOf, readableFolders } from "./study-registry-rule.ts";
|
|
12
|
+
|
|
13
|
+
/** The plans and the histories of the paper folder `paper`, whose registry is `r`. */
|
|
14
|
+
export const indexInputOf = (
|
|
15
|
+
paper: string,
|
|
16
|
+
r: RegistryState,
|
|
17
|
+
deps: OrderDeps,
|
|
18
|
+
): IndexInput => ({
|
|
19
|
+
plans: plansOf(paper, r, deps, readableFolders(paper, deps)),
|
|
20
|
+
history: new Map(
|
|
21
|
+
decideFirstStudies(paper, deps).map((s) => [s.id, indexHistory(s)]),
|
|
22
|
+
),
|
|
23
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a paper's studies with their modes: each registered study whose `plan.json` parses with a
|
|
3
|
+
* `mode`, and the files under a study's folder. What `repro/outputs-before-decision`, `studies gate`
|
|
4
|
+
* and the index's mode columns read with (docs/design/study-modes.md). A row whose folder is not one
|
|
5
|
+
* folder inside `repro/` is never read through.
|
|
6
|
+
*/
|
|
7
|
+
import { basename, dirname, join } from "node:path";
|
|
8
|
+
import { callerPath } from "./caller-path.ts";
|
|
9
|
+
import { REPRO_DIR } from "./domain/repro-layout.ts";
|
|
10
|
+
import type { StudyMode } from "./domain/study-mode.ts";
|
|
11
|
+
import type { NotAFile } from "./domain/decide-first.ts";
|
|
12
|
+
import {
|
|
13
|
+
isInside,
|
|
14
|
+
type PlanTextField,
|
|
15
|
+
type Study,
|
|
16
|
+
} from "./domain/study-registry.ts";
|
|
17
|
+
import type { Files } from "./ports/files.ts";
|
|
18
|
+
import type { StudyRegistryCodec } from "./ports/study-registry-codec.ts";
|
|
19
|
+
import type { DirEntry, ListEntries } from "./ports/list-entries.ts";
|
|
20
|
+
import type { ListDir } from "./ports/talk-media.ts";
|
|
21
|
+
import { readableFolders, registryOf, textAt } from "./study-registry-rule.ts";
|
|
22
|
+
|
|
23
|
+
/** What reading a paper's studies needs: the disk, its listings with and without kinds, and the parsers. */
|
|
24
|
+
export interface StudyDeps {
|
|
25
|
+
readonly files: Files;
|
|
26
|
+
readonly listDir: ListDir;
|
|
27
|
+
readonly entries: ListEntries;
|
|
28
|
+
readonly codec: StudyRegistryCodec;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What a decision path names on disk: a file, nothing, or something git cannot vouch for. */
|
|
32
|
+
export type OnDisk = "file" | "absent" | NotAFile;
|
|
33
|
+
|
|
34
|
+
const ON_DISK: Readonly<Record<DirEntry["kind"], OnDisk>> = {
|
|
35
|
+
file: "file",
|
|
36
|
+
dir: "directory",
|
|
37
|
+
symlink: "symbolic link",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What `p` is on disk, read from its directory's entries without following a link: a decision must
|
|
42
|
+
* be a regular file — git answers for a directory with the commits of anything under it, and for a
|
|
43
|
+
* link with the link, not the file it points to.
|
|
44
|
+
*/
|
|
45
|
+
export const onDisk = (p: string, deps: Pick<StudyDeps, "entries">): OnDisk => {
|
|
46
|
+
const e = deps
|
|
47
|
+
.entries(callerPath(dirname(p)))
|
|
48
|
+
.find((x) => x.name === basename(p));
|
|
49
|
+
return e === undefined ? "absent" : ON_DISK[e.kind];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Every file under `dir`, relative to it, `/`-separated; none when it is not a directory. A symbolic
|
|
54
|
+
* link is listed as a file — git stores it as one — and never followed: a link to the folder itself
|
|
55
|
+
* would recurse forever, and one to a tree outside would bring its files into the study.
|
|
56
|
+
*/
|
|
57
|
+
export const filesUnder = (
|
|
58
|
+
dir: string,
|
|
59
|
+
deps: Pick<StudyDeps, "entries">,
|
|
60
|
+
prefix = "",
|
|
61
|
+
): readonly string[] =>
|
|
62
|
+
deps.entries(callerPath(dir)).flatMap(({ name, kind }) => {
|
|
63
|
+
const rel = prefix === "" ? name : `${prefix}/${name}`;
|
|
64
|
+
return kind === "dir" ? filesUnder(join(dir, name), deps, rel) : [rel];
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A study whose plan parsed with a mode: its row, the plan's path in its folder, the mode, and the
|
|
69
|
+
* plan's empty texts — none when it is ready. Only a ready plan's mode decides anything: a draft may
|
|
70
|
+
* name no outputs, or an empty decision file.
|
|
71
|
+
*/
|
|
72
|
+
export interface ModeStudy {
|
|
73
|
+
readonly row: Study;
|
|
74
|
+
readonly plan: string;
|
|
75
|
+
readonly mode: StudyMode;
|
|
76
|
+
readonly empty: readonly PlanTextField[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The study `s`'s plan, when it is a plan inside a folder every reader may read through
|
|
81
|
+
* (`readableFolders`), by the registry rule's own containment test (`isInside`), that parsed with a
|
|
82
|
+
* mode; else null.
|
|
83
|
+
*/
|
|
84
|
+
const modeStudyOf = (
|
|
85
|
+
paper: string,
|
|
86
|
+
s: Study,
|
|
87
|
+
deps: Pick<StudyDeps, "files" | "codec">,
|
|
88
|
+
readable: (folder: string) => boolean,
|
|
89
|
+
): ModeStudy | null => {
|
|
90
|
+
const plan = s.plan;
|
|
91
|
+
const text =
|
|
92
|
+
plan === undefined ||
|
|
93
|
+
!readable(s.folder) ||
|
|
94
|
+
!isInside(plan) ||
|
|
95
|
+
plan.includes("\0")
|
|
96
|
+
? null
|
|
97
|
+
: textAt(deps.files, join(paper, REPRO_DIR, s.folder, plan));
|
|
98
|
+
if (plan === undefined || text === null) return null;
|
|
99
|
+
const parsed = deps.codec.parsePlan(text, s.kind);
|
|
100
|
+
if (parsed.kind === "unreadable" || parsed.plan.mode === undefined)
|
|
101
|
+
return null;
|
|
102
|
+
const empty = parsed.kind === "draft" ? parsed.empty : [];
|
|
103
|
+
return { row: s, plan, mode: parsed.plan.mode, empty };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every study of the paper folder `paper` whose plan declares a mode, in registry order. A plan that
|
|
108
|
+
* does not parse is `repro/study-registered`'s to report; here it declares nothing.
|
|
109
|
+
*/
|
|
110
|
+
export const modeStudies = (
|
|
111
|
+
paper: string,
|
|
112
|
+
deps: Pick<StudyDeps, "files" | "codec" | "entries">,
|
|
113
|
+
): readonly ModeStudy[] => {
|
|
114
|
+
const r = registryOf(paper, deps);
|
|
115
|
+
const readable = readableFolders(paper, deps);
|
|
116
|
+
return (r.kind === "read" ? r.registry.studies : []).flatMap((s) => {
|
|
117
|
+
const m = modeStudyOf(paper, s, deps, readable);
|
|
118
|
+
return m === null ? [] : [m];
|
|
119
|
+
});
|
|
120
|
+
};
|