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.
- 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 +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 +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 +57 -10
- package/dist/domain/study-registry.d.ts.map +1 -1
- package/dist/domain/study-registry.js +106 -30
- 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 +6 -3
- 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 +50 -22
- 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 +17 -1
- package/dist/study-registry-rule.d.ts.map +1 -1
- package/dist/study-registry-rule.js +34 -13
- 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 +6 -0
- package/docs/e2e.md +8 -4
- package/docs/layout.md +22 -19
- package/docs/rules/repro/outputs-before-decision.md +119 -0
- package/docs/rules/repro/study-registered.md +47 -14
- package/docs/rules.md +54 -53
- package/docs/testing.md +1 -0
- 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/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/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 +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 +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 +9 -5
- package/src/domain/study-mode.test.ts +55 -0
- package/src/domain/study-mode.ts +78 -0
- package/src/domain/study-registry.test.ts +38 -31
- package/src/domain/study-registry.ts +170 -31
- package/src/new-paper.test.ts +5 -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 +10 -3
- 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 +88 -27
- 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 +198 -11
- package/src/study-registry-rule.ts +51 -14
- package/src/study-run.ts +21 -0
package/src/studies.test.ts
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
* state).
|
|
6
6
|
*/
|
|
7
7
|
import { describe, expect, it } from "vitest";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
memoryFiles,
|
|
10
|
+
memoryListDir,
|
|
11
|
+
memoryListEntries,
|
|
12
|
+
} from "./adapters/memory/index.ts";
|
|
9
13
|
import { studyRegistryCodec } from "./adapters/study-registry/index.ts";
|
|
10
14
|
import {
|
|
11
15
|
indexBlock,
|
|
@@ -16,9 +20,13 @@ import {
|
|
|
16
20
|
withIndex,
|
|
17
21
|
type Study,
|
|
18
22
|
type StudyRegistry,
|
|
23
|
+
type IndexInput,
|
|
19
24
|
} from "./domain/study-registry.ts";
|
|
20
25
|
import { runStudies, type StudiesArgs } from "./studies.ts";
|
|
21
26
|
|
|
27
|
+
/** No plan and no history: the index with every mode column `—`. */
|
|
28
|
+
const NO_FACTS: IndexInput = { plans: new Map(), history: new Map() };
|
|
29
|
+
|
|
22
30
|
const CWD = "/work";
|
|
23
31
|
const DIR = "/work/papers/example";
|
|
24
32
|
const REPRO = `${DIR}/repro`;
|
|
@@ -68,9 +76,24 @@ function studies(
|
|
|
68
76
|
{
|
|
69
77
|
files,
|
|
70
78
|
listDir: memoryListDir(files),
|
|
79
|
+
entries: memoryListEntries(files),
|
|
71
80
|
codec: studyRegistryCodec,
|
|
72
81
|
cwd,
|
|
73
82
|
today: "2026-10-15",
|
|
83
|
+
git: {
|
|
84
|
+
committed: () => {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"new and index never ask whether a file is committed",
|
|
87
|
+
);
|
|
88
|
+
},
|
|
89
|
+
// A history with no decision and no output committed yet.
|
|
90
|
+
order: () => ({
|
|
91
|
+
kind: "read",
|
|
92
|
+
decision: null,
|
|
93
|
+
decisionDeleted: false,
|
|
94
|
+
files: [],
|
|
95
|
+
}),
|
|
96
|
+
},
|
|
74
97
|
log: (l) => log.push(l),
|
|
75
98
|
err: (l) => err.push(l),
|
|
76
99
|
},
|
|
@@ -110,10 +133,11 @@ describe("studies new", () => {
|
|
|
110
133
|
expect(after).toEqual({
|
|
111
134
|
...before,
|
|
112
135
|
[`${REPRO}/P1-S02-ablation-of-the-guard/README.md`]: studyReadme(added),
|
|
113
|
-
[`${REPRO}/P1-S02-ablation-of-the-guard/plan.json`]:
|
|
114
|
-
|
|
136
|
+
[`${REPRO}/P1-S02-ablation-of-the-guard/plan.json`]: planText(
|
|
137
|
+
planTemplate("spike", "x"),
|
|
138
|
+
),
|
|
115
139
|
[`${REPRO}/studies.json`]: registryText(r),
|
|
116
|
-
[`${REPRO}/README.md`]: `# Reproduction index\n\nIntro.\n\n${indexBlock(r)}\n`,
|
|
140
|
+
[`${REPRO}/README.md`]: `# Reproduction index\n\nIntro.\n\n${indexBlock(r, NO_FACTS)}\n`,
|
|
117
141
|
});
|
|
118
142
|
expect(log).toEqual([
|
|
119
143
|
"created papers/example/repro/P1-S02-ablation-of-the-guard/README.md",
|
|
@@ -221,7 +245,7 @@ describe("studies index", () => {
|
|
|
221
245
|
it("regenerates the block in place", () => {
|
|
222
246
|
const tree = paper({
|
|
223
247
|
[`${REPRO}/studies.json`]: registryText(r),
|
|
224
|
-
[`${REPRO}/README.md`]: `# I\n\n${indexBlock(P1())}\n\nTail.\n`,
|
|
248
|
+
[`${REPRO}/README.md`]: `# I\n\n${indexBlock(P1(), NO_FACTS)}\n\nTail.\n`,
|
|
225
249
|
});
|
|
226
250
|
const { code, log, err, after } = studies(
|
|
227
251
|
{ sub: "index", paperDir: "papers/example" },
|
|
@@ -234,14 +258,14 @@ describe("studies index", () => {
|
|
|
234
258
|
});
|
|
235
259
|
expect(after).toEqual({
|
|
236
260
|
...tree,
|
|
237
|
-
[`${REPRO}/README.md`]: `# I\n\n${indexBlock(r)}\n\nTail.\n`,
|
|
261
|
+
[`${REPRO}/README.md`]: `# I\n\n${indexBlock(r, NO_FACTS)}\n\nTail.\n`,
|
|
238
262
|
});
|
|
239
263
|
});
|
|
240
264
|
|
|
241
265
|
it("says so and writes nothing when the index is in sync", () => {
|
|
242
266
|
const tree = paper({
|
|
243
267
|
[`${REPRO}/studies.json`]: registryText(r),
|
|
244
|
-
[`${REPRO}/README.md`]: withIndex("# I\n", r),
|
|
268
|
+
[`${REPRO}/README.md`]: withIndex("# I\n", r, NO_FACTS),
|
|
245
269
|
});
|
|
246
270
|
const out = studies({ sub: "index" }, tree);
|
|
247
271
|
expect([out.code, out.log, out.after]).toEqual([
|
|
@@ -266,7 +290,7 @@ describe("studies index", () => {
|
|
|
266
290
|
describe("studies index — a table a formatter padded", () => {
|
|
267
291
|
it("leaves an index in sync as it is, whatever its padding", () => {
|
|
268
292
|
const r = P1(study({ title: "測定 A" }));
|
|
269
|
-
const generated = withIndex("# I\n", r);
|
|
293
|
+
const generated = withIndex("# I\n", r, NO_FACTS);
|
|
270
294
|
const padded = generated.replace("測定 A", "測定 A ");
|
|
271
295
|
expect(padded).not.toBe(generated);
|
|
272
296
|
const tree = paper({
|
|
@@ -315,7 +339,7 @@ describe("studies — run from inside the paper folder", () => {
|
|
|
315
339
|
it("names the folder `.`", () => {
|
|
316
340
|
const tree = paper({
|
|
317
341
|
[`${REPRO}/studies.json`]: registryText(P1()),
|
|
318
|
-
[`${REPRO}/README.md`]: withIndex("# I\n", P1()),
|
|
342
|
+
[`${REPRO}/README.md`]: withIndex("# I\n", P1(), NO_FACTS),
|
|
319
343
|
});
|
|
320
344
|
const out = studies({ sub: "index", paperDir: "." }, tree, DIR);
|
|
321
345
|
expect([out.code, out.log]).toEqual([
|
|
@@ -330,12 +354,12 @@ describe("studies — the command line", () => {
|
|
|
330
354
|
[
|
|
331
355
|
"no subcommand",
|
|
332
356
|
{ sub: undefined },
|
|
333
|
-
'studies needs a subcommand: new or
|
|
357
|
+
'studies needs a subcommand: new, index or gate — npx paperlint studies new <paper-dir> "<title>" [--kind <kind>] · npx paperlint studies index <paper-dir> · npx paperlint studies gate <paper-dir> <id>',
|
|
334
358
|
],
|
|
335
359
|
[
|
|
336
360
|
"an unknown subcommand",
|
|
337
361
|
{ sub: "add" },
|
|
338
|
-
'unknown subcommand `studies add`: new or
|
|
362
|
+
'unknown subcommand `studies add`: new, index or gate — npx paperlint studies new <paper-dir> "<title>" [--kind <kind>] · npx paperlint studies index <paper-dir> · npx paperlint studies gate <paper-dir> <id>',
|
|
339
363
|
],
|
|
340
364
|
[
|
|
341
365
|
"new with no paper folder",
|
package/src/studies.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `paperlint studies new|index <paper-dir>` — the two commands that write a paper's study registry,
|
|
3
|
-
* `repro/studies.json`, and its index in `repro/README.md` (docs/design/study-registry.md)
|
|
3
|
+
* `repro/studies.json`, and its index in `repro/README.md` (docs/design/study-registry.md) — and
|
|
4
|
+
* `studies gate <paper-dir> <id>` (src/study-gate.ts), over the studies' modes
|
|
5
|
+
* (docs/design/study-modes.md).
|
|
4
6
|
*
|
|
5
7
|
* new <paper-dir> "<title>" [--kind <kind>]
|
|
6
8
|
* the next id; `repro/<id>-<slug>/README.md` with the id and title in its frontmatter, and
|
|
@@ -23,7 +25,7 @@ import {
|
|
|
23
25
|
STUDIES_FILE,
|
|
24
26
|
STUDY_KINDS,
|
|
25
27
|
addStudy,
|
|
26
|
-
|
|
28
|
+
indexCurrent,
|
|
27
29
|
isStudyKind,
|
|
28
30
|
planTemplate,
|
|
29
31
|
planText,
|
|
@@ -36,8 +38,12 @@ import {
|
|
|
36
38
|
} from "./domain/study-registry.ts";
|
|
37
39
|
import type { Files } from "./ports/files.ts";
|
|
38
40
|
import type { StudyRegistryCodec } from "./ports/study-registry-codec.ts";
|
|
41
|
+
import type { ListEntries } from "./ports/list-entries.ts";
|
|
39
42
|
import type { ListDir } from "./ports/talk-media.ts";
|
|
40
|
-
import { registryOf } from "./study-registry-rule.ts";
|
|
43
|
+
import { readableFolders, registryOf } from "./study-registry-rule.ts";
|
|
44
|
+
import { runGate } from "./study-gate.ts";
|
|
45
|
+
import { indexInputOf } from "./study-index-input.ts";
|
|
46
|
+
import type { GitHistory, GitOrder } from "./ports/git-history.ts";
|
|
41
47
|
|
|
42
48
|
/** What one `studies` run was asked, after the command line. */
|
|
43
49
|
export interface StudiesArgs {
|
|
@@ -52,8 +58,10 @@ export interface StudiesArgs {
|
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
export interface StudiesDeps {
|
|
61
|
+
readonly git: GitHistory & GitOrder;
|
|
55
62
|
readonly files: Files;
|
|
56
63
|
readonly listDir: ListDir;
|
|
64
|
+
readonly entries: ListEntries;
|
|
57
65
|
readonly codec: StudyRegistryCodec;
|
|
58
66
|
readonly cwd: string;
|
|
59
67
|
/** Today, `YYYY-MM-DD`: the root reads the clock. */
|
|
@@ -65,7 +73,8 @@ export interface StudiesDeps {
|
|
|
65
73
|
const NEW_USAGE =
|
|
66
74
|
'npx paperlint studies new <paper-dir> "<title>" [--kind <kind>]';
|
|
67
75
|
const INDEX_USAGE = "npx paperlint studies index <paper-dir>";
|
|
68
|
-
const
|
|
76
|
+
const GATE_USAGE = "npx paperlint studies gate <paper-dir> <id>";
|
|
77
|
+
const SUBS = `new, index or gate — ${[NEW_USAGE, INDEX_USAGE, GATE_USAGE].join(" · ")}`;
|
|
69
78
|
|
|
70
79
|
const STATUS_FILE = "PIPELINE-STATUS.md";
|
|
71
80
|
|
|
@@ -93,10 +102,26 @@ function registryIn(p: Paper, deps: StudiesDeps): StudyRegistry | Refusal {
|
|
|
93
102
|
case "unreadable":
|
|
94
103
|
return refuse(1, `${file} is not a study registry: ${r.why}`);
|
|
95
104
|
case "read":
|
|
96
|
-
return r.registry;
|
|
105
|
+
return escaping(p, r.registry, deps) ?? r.registry;
|
|
97
106
|
}
|
|
98
107
|
}
|
|
99
108
|
|
|
109
|
+
/** The refusal when a row names a folder that is not one inside `repro/`; null when none does. */
|
|
110
|
+
function escaping(
|
|
111
|
+
p: Paper,
|
|
112
|
+
r: StudyRegistry,
|
|
113
|
+
deps: StudiesDeps,
|
|
114
|
+
): Refusal | null {
|
|
115
|
+
const readable = readableFolders(p.dir, deps);
|
|
116
|
+
const s = r.studies.find((row) => !readable(row.folder));
|
|
117
|
+
return s === undefined
|
|
118
|
+
? null
|
|
119
|
+
: refuse(
|
|
120
|
+
1,
|
|
121
|
+
`${s.id} in ${p.shown}/${REPRO_DIR}/${STUDIES_FILE} names the folder \`${s.folder}\`, which is not one folder inside ${REPRO_DIR}/: nothing is read or written through it. Give the row its own folder's name, then run this again.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
100
125
|
const isRefusal = (v: unknown): v is Refusal =>
|
|
101
126
|
typeof v === "object" && v !== null && "code" in v && "line" in v;
|
|
102
127
|
|
|
@@ -115,12 +140,13 @@ const studiesCount = (r: StudyRegistry): string =>
|
|
|
115
140
|
* Write the index of `r` into the README; whether it wrote. A block already in sync is left as it
|
|
116
141
|
* is, so the padding a formatter gave the table is never undone.
|
|
117
142
|
*/
|
|
118
|
-
function writeIndex(dir: string, r: StudyRegistry,
|
|
119
|
-
const before = readmeOf(dir, files);
|
|
120
|
-
|
|
121
|
-
|
|
143
|
+
function writeIndex(dir: string, r: StudyRegistry, deps: StudiesDeps): boolean {
|
|
144
|
+
const before = readmeOf(dir, deps.files);
|
|
145
|
+
const at = indexInputOf(dir, { kind: "read", registry: r }, deps);
|
|
146
|
+
if (indexCurrent(before, r, at)) return false;
|
|
147
|
+
deps.files.writeAtomic(
|
|
122
148
|
callerPath(join(dir, REPRO_DIR, README)),
|
|
123
|
-
text(withIndex(before, r)),
|
|
149
|
+
text(withIndex(before, r, at)),
|
|
124
150
|
);
|
|
125
151
|
return true;
|
|
126
152
|
}
|
|
@@ -155,7 +181,7 @@ function runIndex(
|
|
|
155
181
|
if (blocked !== null) return blocked;
|
|
156
182
|
const readme = `${p.shown}/${REPRO_DIR}/${README}`;
|
|
157
183
|
return [
|
|
158
|
-
writeIndex(p.dir, r, deps
|
|
184
|
+
writeIndex(p.dir, r, deps)
|
|
159
185
|
? `wrote the index of ${studiesCount(r)} in ${readme}`
|
|
160
186
|
: `the index in ${readme} is in sync with ${studiesCount(r)}`,
|
|
161
187
|
];
|
|
@@ -186,18 +212,19 @@ function newArgs(
|
|
|
186
212
|
function writeStudy(
|
|
187
213
|
p: Paper,
|
|
188
214
|
added: { readonly registry: StudyRegistry; readonly study: Study },
|
|
189
|
-
|
|
215
|
+
deps: StudiesDeps,
|
|
190
216
|
): readonly string[] {
|
|
191
217
|
const { study, registry } = added;
|
|
218
|
+
const { files } = deps;
|
|
192
219
|
const at = (...names: readonly string[]): AbsolutePath =>
|
|
193
220
|
callerPath(join(p.dir, REPRO_DIR, ...names));
|
|
194
221
|
files.writeAtomic(at(study.folder, README), text(studyReadme(study)));
|
|
195
222
|
files.writeAtomic(
|
|
196
223
|
at(study.folder, PLAN_FILE),
|
|
197
|
-
text(planText(planTemplate())),
|
|
224
|
+
text(planText(planTemplate(study.kind, study.folder))),
|
|
198
225
|
);
|
|
199
226
|
files.writeAtomic(at(STUDIES_FILE), text(registryText(registry)));
|
|
200
|
-
writeIndex(p.dir, registry,
|
|
227
|
+
writeIndex(p.dir, registry, deps);
|
|
201
228
|
const repro = `${p.shown}/${REPRO_DIR}`;
|
|
202
229
|
return [
|
|
203
230
|
`created ${repro}/${study.folder}/${README}`,
|
|
@@ -228,35 +255,69 @@ function runNew(
|
|
|
228
255
|
1,
|
|
229
256
|
`${p.shown}/${REPRO_DIR}/${folder}/ already exists; it is never overwritten. Register it by hand, or remove it and run this again.`,
|
|
230
257
|
)
|
|
231
|
-
: writeStudy(p, added, deps
|
|
258
|
+
: writeStudy(p, added, deps);
|
|
232
259
|
}
|
|
233
260
|
|
|
234
261
|
/** The paper folder the command line names, or the refusal. */
|
|
235
|
-
function paperOf(a: StudiesArgs, deps: StudiesDeps): Refusal |
|
|
262
|
+
function paperOf(a: StudiesArgs, deps: StudiesDeps): Refusal | Chosen {
|
|
236
263
|
if (a.sub === undefined)
|
|
237
264
|
return refuse(2, `studies needs a subcommand: ${SUBS}`);
|
|
238
|
-
|
|
265
|
+
const sub = COMMANDS.get(a.sub);
|
|
266
|
+
if (sub === undefined)
|
|
239
267
|
return refuse(2, `unknown subcommand \`studies ${a.sub}\`: ${SUBS}`);
|
|
240
268
|
if (a.paperDir === undefined)
|
|
241
|
-
return refuse(
|
|
242
|
-
2,
|
|
243
|
-
`studies ${a.sub} needs the paper's folder: ${a.sub === "new" ? NEW_USAGE : INDEX_USAGE}`,
|
|
244
|
-
);
|
|
269
|
+
return refuse(2, `studies ${a.sub} needs the paper's folder: ${sub.usage}`);
|
|
245
270
|
const dir = resolve(deps.cwd, a.paperDir);
|
|
246
271
|
const shown = relative(deps.cwd, dir) || ".";
|
|
247
272
|
return deps.files.isFile(callerPath(join(dir, STATUS_FILE)))
|
|
248
|
-
? { dir, shown }
|
|
273
|
+
? { dir, shown, run: sub.run }
|
|
249
274
|
: refuse(2, `${shown} is not a paper folder: it has no ${STATUS_FILE}`);
|
|
250
275
|
}
|
|
251
276
|
|
|
277
|
+
/** `gate`: the study's id, one argument, then the gate. */
|
|
278
|
+
const gateRun = (
|
|
279
|
+
p: Paper,
|
|
280
|
+
a: StudiesArgs,
|
|
281
|
+
deps: StudiesDeps,
|
|
282
|
+
): Refusal | readonly string[] => {
|
|
283
|
+
if (a.title === undefined)
|
|
284
|
+
return refuse(2, `studies gate needs the study's id: ${GATE_USAGE}`);
|
|
285
|
+
const [surplus] = a.extra;
|
|
286
|
+
if (surplus !== undefined)
|
|
287
|
+
return refuse(
|
|
288
|
+
2,
|
|
289
|
+
`studies gate takes the paper's folder and one id — \`${surplus}\` is one argument too many`,
|
|
290
|
+
);
|
|
291
|
+
const out = runGate(p.dir, p.shown, a.title, deps);
|
|
292
|
+
return out.ok ? out.lines : out.refusal;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/** A run of a subcommand over a paper folder. */
|
|
296
|
+
type Run = (
|
|
297
|
+
p: Paper,
|
|
298
|
+
a: StudiesArgs,
|
|
299
|
+
d: StudiesDeps,
|
|
300
|
+
) => Refusal | readonly string[];
|
|
301
|
+
|
|
302
|
+
/** The paper folder, and the run the subcommand chose. */
|
|
303
|
+
interface Chosen extends Paper {
|
|
304
|
+
readonly run: Run;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Each subcommand: its usage line and its run. */
|
|
308
|
+
const COMMANDS: ReadonlyMap<
|
|
309
|
+
string,
|
|
310
|
+
{ readonly usage: string; readonly run: Run }
|
|
311
|
+
> = new Map([
|
|
312
|
+
["new", { usage: NEW_USAGE, run: runNew }],
|
|
313
|
+
["index", { usage: INDEX_USAGE, run: runIndex }],
|
|
314
|
+
["gate", { usage: GATE_USAGE, run: gateRun }],
|
|
315
|
+
]);
|
|
316
|
+
|
|
252
317
|
/** The run: 0 done, 1 refused by the paper's state, 2 refused by the command line. */
|
|
253
318
|
export function runStudies(a: StudiesArgs, deps: StudiesDeps): number {
|
|
254
319
|
const p = paperOf(a, deps);
|
|
255
|
-
const out = isRefusal(p)
|
|
256
|
-
? p
|
|
257
|
-
: a.sub === "new"
|
|
258
|
-
? runNew(p, a, deps)
|
|
259
|
-
: runIndex(p, a, deps);
|
|
320
|
+
const out = isRefusal(p) ? p : p.run(p, a, deps);
|
|
260
321
|
if (isRefusal(out)) {
|
|
261
322
|
deps.err(out.line);
|
|
262
323
|
return out.code;
|
|
@@ -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
|
+
});
|