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,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `paperlint studies index` and `studies new` (`src/studies.ts`) writing the index's mode columns from
|
|
3
|
+
* the plans and git's history (a stand-in), and refusing a registry whose row names a folder outside
|
|
4
|
+
* `repro/` before reading or writing through it.
|
|
5
|
+
*/
|
|
6
|
+
import { expect, it } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
DECIDED,
|
|
9
|
+
DIR,
|
|
10
|
+
ROW,
|
|
11
|
+
planOf,
|
|
12
|
+
studies,
|
|
13
|
+
tree,
|
|
14
|
+
} from "../test/study-tree.ts";
|
|
15
|
+
|
|
16
|
+
const D = { commit: "d".repeat(40), date: "2026-10-14T09:12:00+05:00" };
|
|
17
|
+
const A = { commit: "a".repeat(40), date: "2026-10-16T11:00:00+05:00" };
|
|
18
|
+
const README = `${DIR}/repro/README.md`;
|
|
19
|
+
|
|
20
|
+
/** The row of P1-S01 in the README `studies index` wrote, its cells trimmed. */
|
|
21
|
+
const rowOf = (after: Readonly<Record<string, string>>): string[] =>
|
|
22
|
+
(after[README] ?? "")
|
|
23
|
+
.split("\n")
|
|
24
|
+
.filter((l) => l.startsWith("| P1-S01"))
|
|
25
|
+
.flatMap((l) => l.split("|").map((c) => c.trim()))
|
|
26
|
+
.filter((c) => c !== "");
|
|
27
|
+
|
|
28
|
+
it("writes the decision's first commit and the first output's, read from git", () => {
|
|
29
|
+
const r = studies(
|
|
30
|
+
"index",
|
|
31
|
+
tree({ [`${DIR}/repro/P1-S01-outcome/RESULTS.md`]: "41 of 60\n" }),
|
|
32
|
+
{ title: undefined },
|
|
33
|
+
{
|
|
34
|
+
order: {
|
|
35
|
+
kind: "read",
|
|
36
|
+
decision: D,
|
|
37
|
+
decisionDeleted: false,
|
|
38
|
+
files: [
|
|
39
|
+
{ path: "decision.md", ...D, order: "same" },
|
|
40
|
+
{ path: "RESULTS.md", ...A, order: "after" },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
// Guards: the cells the review skills read the Methods sentence's facts from.
|
|
46
|
+
expect([r.code, r.log, rowOf(r.after)]).toEqual([
|
|
47
|
+
0,
|
|
48
|
+
["wrote the index of 1 study in papers/example/repro/README.md"],
|
|
49
|
+
[
|
|
50
|
+
"P1-S01",
|
|
51
|
+
"Outcome",
|
|
52
|
+
"running",
|
|
53
|
+
"—",
|
|
54
|
+
"no",
|
|
55
|
+
"decide_before_run",
|
|
56
|
+
"dddddddddddd 2026-10-14",
|
|
57
|
+
"aaaaaaaaaaaa 2026-10-16",
|
|
58
|
+
"[P1-S01-outcome/](P1-S01-outcome/)",
|
|
59
|
+
],
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("rewrites the index when only git's columns moved, and leaves it when nothing did", () => {
|
|
64
|
+
const before = studies("index", tree(), { title: undefined }).after;
|
|
65
|
+
const order = {
|
|
66
|
+
kind: "read",
|
|
67
|
+
decision: D,
|
|
68
|
+
decisionDeleted: false,
|
|
69
|
+
files: [],
|
|
70
|
+
} as const;
|
|
71
|
+
const moved = studies("index", before, { title: undefined }, { order });
|
|
72
|
+
const again = studies("index", moved.after, { title: undefined }, { order });
|
|
73
|
+
// Guards: the lint ignores those two columns, so the command is what keeps them current.
|
|
74
|
+
expect([rowOf(before)[6], moved.log, again.log]).toEqual([
|
|
75
|
+
"unknown (git could not answer)",
|
|
76
|
+
["wrote the index of 1 study in papers/example/repro/README.md"],
|
|
77
|
+
["the index in papers/example/repro/README.md is in sync with 1 study"],
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("an exploratory study's mode carries its reason, and no commit", () => {
|
|
82
|
+
const r = studies(
|
|
83
|
+
"index",
|
|
84
|
+
tree({}, planOf({ kind: "exploratory", reason: "a first look" })),
|
|
85
|
+
{ title: undefined },
|
|
86
|
+
);
|
|
87
|
+
expect(rowOf(r.after).slice(5, 8)).toEqual([
|
|
88
|
+
"exploratory: a first look",
|
|
89
|
+
"—",
|
|
90
|
+
"—",
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("refuses a registry whose row names a folder outside repro/, for index and for new", () => {
|
|
95
|
+
const escaping = tree(
|
|
96
|
+
{ [`${DIR}/x/plan.json`]: JSON.stringify(planOf(DECIDED)) },
|
|
97
|
+
planOf(DECIDED),
|
|
98
|
+
{ ...ROW, folder: "../x" },
|
|
99
|
+
);
|
|
100
|
+
const line =
|
|
101
|
+
"P1-S01 in papers/example/repro/studies.json names the folder `../x`, which is not one folder inside repro/: nothing is read or written through it. Give the row its own folder's name, then run this again.";
|
|
102
|
+
const index = studies("index", escaping, { title: undefined });
|
|
103
|
+
const added = studies("new", escaping, { title: "Second" });
|
|
104
|
+
// Guards: nothing is written — no README, no new folder — when a row could lead outside the paper.
|
|
105
|
+
expect([
|
|
106
|
+
[index.code, index.err, README in index.after],
|
|
107
|
+
[added.code, added.err, Object.keys(added.after).length],
|
|
108
|
+
]).toEqual([
|
|
109
|
+
[1, [line], false],
|
|
110
|
+
[1, [line], Object.keys(escaping).length],
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("refuses a registry whose study folder is a symbolic link, for index", () => {
|
|
115
|
+
const r = studies(
|
|
116
|
+
"index",
|
|
117
|
+
tree(),
|
|
118
|
+
{ title: undefined },
|
|
119
|
+
{ links: [`${DIR}/repro/P1-S01-outcome`] },
|
|
120
|
+
);
|
|
121
|
+
// Guards: a linked folder leads the reads out of `repro/`, like a row naming `../x`.
|
|
122
|
+
expect([r.code, r.err, README in r.after]).toEqual([
|
|
123
|
+
1,
|
|
124
|
+
[
|
|
125
|
+
"P1-S01 in papers/example/repro/studies.json names the folder `P1-S01-outcome`, which is not one folder inside repro/: nothing is read or written through it. Give the row its own folder's name, then run this again.",
|
|
126
|
+
],
|
|
127
|
+
false,
|
|
128
|
+
]);
|
|
129
|
+
});
|
package/src/studies.test.ts
CHANGED
|
@@ -5,18 +5,28 @@
|
|
|
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
|
+
planTemplate,
|
|
17
|
+
planText,
|
|
12
18
|
registryText,
|
|
13
19
|
studyReadme,
|
|
14
20
|
withIndex,
|
|
15
21
|
type Study,
|
|
16
22
|
type StudyRegistry,
|
|
23
|
+
type IndexInput,
|
|
17
24
|
} from "./domain/study-registry.ts";
|
|
18
25
|
import { runStudies, type StudiesArgs } from "./studies.ts";
|
|
19
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
|
+
|
|
20
30
|
const CWD = "/work";
|
|
21
31
|
const DIR = "/work/papers/example";
|
|
22
32
|
const REPRO = `${DIR}/repro`;
|
|
@@ -66,9 +76,24 @@ function studies(
|
|
|
66
76
|
{
|
|
67
77
|
files,
|
|
68
78
|
listDir: memoryListDir(files),
|
|
79
|
+
entries: memoryListEntries(files),
|
|
69
80
|
codec: studyRegistryCodec,
|
|
70
81
|
cwd,
|
|
71
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
|
+
},
|
|
72
97
|
log: (l) => log.push(l),
|
|
73
98
|
err: (l) => err.push(l),
|
|
74
99
|
},
|
|
@@ -101,17 +126,22 @@ describe("studies new", () => {
|
|
|
101
126
|
status: "designing",
|
|
102
127
|
date: "2026-10-15",
|
|
103
128
|
in_paper: "no",
|
|
129
|
+
plan: "plan.json",
|
|
104
130
|
};
|
|
105
131
|
const r = P1(study(), added);
|
|
106
132
|
expect({ code, err }).toEqual({ code: 0, err: [] });
|
|
107
133
|
expect(after).toEqual({
|
|
108
134
|
...before,
|
|
109
135
|
[`${REPRO}/P1-S02-ablation-of-the-guard/README.md`]: studyReadme(added),
|
|
136
|
+
[`${REPRO}/P1-S02-ablation-of-the-guard/plan.json`]: planText(
|
|
137
|
+
planTemplate("spike", "x"),
|
|
138
|
+
),
|
|
110
139
|
[`${REPRO}/studies.json`]: registryText(r),
|
|
111
|
-
[`${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`,
|
|
112
141
|
});
|
|
113
142
|
expect(log).toEqual([
|
|
114
143
|
"created papers/example/repro/P1-S02-ablation-of-the-guard/README.md",
|
|
144
|
+
"created papers/example/repro/P1-S02-ablation-of-the-guard/plan.json, the study's plan: its fields are there to fill in before the first run",
|
|
115
145
|
"added P1-S02 to papers/example/repro/studies.json: kind spike, status designing, 2026-10-15",
|
|
116
146
|
"wrote the index of 2 studies in papers/example/repro/README.md",
|
|
117
147
|
"Next: write the study's one-sentence question in its row (`question`) and in its README.md. When it ends, set `status`, `result`, `result_file` and `in_paper` in its row, then run `npx paperlint studies index papers/example`.",
|
|
@@ -215,7 +245,7 @@ describe("studies index", () => {
|
|
|
215
245
|
it("regenerates the block in place", () => {
|
|
216
246
|
const tree = paper({
|
|
217
247
|
[`${REPRO}/studies.json`]: registryText(r),
|
|
218
|
-
[`${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`,
|
|
219
249
|
});
|
|
220
250
|
const { code, log, err, after } = studies(
|
|
221
251
|
{ sub: "index", paperDir: "papers/example" },
|
|
@@ -228,14 +258,14 @@ describe("studies index", () => {
|
|
|
228
258
|
});
|
|
229
259
|
expect(after).toEqual({
|
|
230
260
|
...tree,
|
|
231
|
-
[`${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`,
|
|
232
262
|
});
|
|
233
263
|
});
|
|
234
264
|
|
|
235
265
|
it("says so and writes nothing when the index is in sync", () => {
|
|
236
266
|
const tree = paper({
|
|
237
267
|
[`${REPRO}/studies.json`]: registryText(r),
|
|
238
|
-
[`${REPRO}/README.md`]: withIndex("# I\n", r),
|
|
268
|
+
[`${REPRO}/README.md`]: withIndex("# I\n", r, NO_FACTS),
|
|
239
269
|
});
|
|
240
270
|
const out = studies({ sub: "index" }, tree);
|
|
241
271
|
expect([out.code, out.log, out.after]).toEqual([
|
|
@@ -260,7 +290,7 @@ describe("studies index", () => {
|
|
|
260
290
|
describe("studies index — a table a formatter padded", () => {
|
|
261
291
|
it("leaves an index in sync as it is, whatever its padding", () => {
|
|
262
292
|
const r = P1(study({ title: "測定 A" }));
|
|
263
|
-
const generated = withIndex("# I\n", r);
|
|
293
|
+
const generated = withIndex("# I\n", r, NO_FACTS);
|
|
264
294
|
const padded = generated.replace("測定 A", "測定 A ");
|
|
265
295
|
expect(padded).not.toBe(generated);
|
|
266
296
|
const tree = paper({
|
|
@@ -309,7 +339,7 @@ describe("studies — run from inside the paper folder", () => {
|
|
|
309
339
|
it("names the folder `.`", () => {
|
|
310
340
|
const tree = paper({
|
|
311
341
|
[`${REPRO}/studies.json`]: registryText(P1()),
|
|
312
|
-
[`${REPRO}/README.md`]: withIndex("# I\n", P1()),
|
|
342
|
+
[`${REPRO}/README.md`]: withIndex("# I\n", P1(), NO_FACTS),
|
|
313
343
|
});
|
|
314
344
|
const out = studies({ sub: "index", paperDir: "." }, tree, DIR);
|
|
315
345
|
expect([out.code, out.log]).toEqual([
|
|
@@ -324,12 +354,12 @@ describe("studies — the command line", () => {
|
|
|
324
354
|
[
|
|
325
355
|
"no subcommand",
|
|
326
356
|
{ sub: undefined },
|
|
327
|
-
'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>',
|
|
328
358
|
],
|
|
329
359
|
[
|
|
330
360
|
"an unknown subcommand",
|
|
331
361
|
{ sub: "add" },
|
|
332
|
-
'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>',
|
|
333
363
|
],
|
|
334
364
|
[
|
|
335
365
|
"new with no paper folder",
|
package/src/studies.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
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
|
-
* the next id; `repro/<id>-<slug>/README.md` with the id and title in its frontmatter
|
|
7
|
-
* row, `status: designing`, today's date
|
|
8
|
+
* the next id; `repro/<id>-<slug>/README.md` with the id and title in its frontmatter, and
|
|
9
|
+
* `plan.json`, the plan's template; the row, `status: designing`, today's date, its `plan`;
|
|
10
|
+
* the index regenerated
|
|
8
11
|
* index <paper-dir>
|
|
9
12
|
* the index block of `repro/README.md`, regenerated from the registry
|
|
10
13
|
*
|
|
@@ -18,11 +21,14 @@ import type { AbsolutePath } from "./domain/paths.ts";
|
|
|
18
21
|
import { README, REPRO_DIR } from "./domain/repro-layout.ts";
|
|
19
22
|
import {
|
|
20
23
|
DEFAULT_KIND,
|
|
24
|
+
PLAN_FILE,
|
|
21
25
|
STUDIES_FILE,
|
|
22
26
|
STUDY_KINDS,
|
|
23
27
|
addStudy,
|
|
24
|
-
|
|
28
|
+
indexCurrent,
|
|
25
29
|
isStudyKind,
|
|
30
|
+
planTemplate,
|
|
31
|
+
planText,
|
|
26
32
|
registryText,
|
|
27
33
|
studyReadme,
|
|
28
34
|
withIndex,
|
|
@@ -32,8 +38,12 @@ import {
|
|
|
32
38
|
} from "./domain/study-registry.ts";
|
|
33
39
|
import type { Files } from "./ports/files.ts";
|
|
34
40
|
import type { StudyRegistryCodec } from "./ports/study-registry-codec.ts";
|
|
41
|
+
import type { ListEntries } from "./ports/list-entries.ts";
|
|
35
42
|
import type { ListDir } from "./ports/talk-media.ts";
|
|
36
|
-
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";
|
|
37
47
|
|
|
38
48
|
/** What one `studies` run was asked, after the command line. */
|
|
39
49
|
export interface StudiesArgs {
|
|
@@ -48,8 +58,10 @@ export interface StudiesArgs {
|
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
export interface StudiesDeps {
|
|
61
|
+
readonly git: GitHistory & GitOrder;
|
|
51
62
|
readonly files: Files;
|
|
52
63
|
readonly listDir: ListDir;
|
|
64
|
+
readonly entries: ListEntries;
|
|
53
65
|
readonly codec: StudyRegistryCodec;
|
|
54
66
|
readonly cwd: string;
|
|
55
67
|
/** Today, `YYYY-MM-DD`: the root reads the clock. */
|
|
@@ -61,7 +73,8 @@ export interface StudiesDeps {
|
|
|
61
73
|
const NEW_USAGE =
|
|
62
74
|
'npx paperlint studies new <paper-dir> "<title>" [--kind <kind>]';
|
|
63
75
|
const INDEX_USAGE = "npx paperlint studies index <paper-dir>";
|
|
64
|
-
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(" · ")}`;
|
|
65
78
|
|
|
66
79
|
const STATUS_FILE = "PIPELINE-STATUS.md";
|
|
67
80
|
|
|
@@ -89,10 +102,26 @@ function registryIn(p: Paper, deps: StudiesDeps): StudyRegistry | Refusal {
|
|
|
89
102
|
case "unreadable":
|
|
90
103
|
return refuse(1, `${file} is not a study registry: ${r.why}`);
|
|
91
104
|
case "read":
|
|
92
|
-
return r.registry;
|
|
105
|
+
return escaping(p, r.registry, deps) ?? r.registry;
|
|
93
106
|
}
|
|
94
107
|
}
|
|
95
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
|
+
|
|
96
125
|
const isRefusal = (v: unknown): v is Refusal =>
|
|
97
126
|
typeof v === "object" && v !== null && "code" in v && "line" in v;
|
|
98
127
|
|
|
@@ -111,12 +140,13 @@ const studiesCount = (r: StudyRegistry): string =>
|
|
|
111
140
|
* Write the index of `r` into the README; whether it wrote. A block already in sync is left as it
|
|
112
141
|
* is, so the padding a formatter gave the table is never undone.
|
|
113
142
|
*/
|
|
114
|
-
function writeIndex(dir: string, r: StudyRegistry,
|
|
115
|
-
const before = readmeOf(dir, files);
|
|
116
|
-
|
|
117
|
-
|
|
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(
|
|
118
148
|
callerPath(join(dir, REPRO_DIR, README)),
|
|
119
|
-
text(withIndex(before, r)),
|
|
149
|
+
text(withIndex(before, r, at)),
|
|
120
150
|
);
|
|
121
151
|
return true;
|
|
122
152
|
}
|
|
@@ -151,7 +181,7 @@ function runIndex(
|
|
|
151
181
|
if (blocked !== null) return blocked;
|
|
152
182
|
const readme = `${p.shown}/${REPRO_DIR}/${README}`;
|
|
153
183
|
return [
|
|
154
|
-
writeIndex(p.dir, r, deps
|
|
184
|
+
writeIndex(p.dir, r, deps)
|
|
155
185
|
? `wrote the index of ${studiesCount(r)} in ${readme}`
|
|
156
186
|
: `the index in ${readme} is in sync with ${studiesCount(r)}`,
|
|
157
187
|
];
|
|
@@ -178,21 +208,27 @@ function newArgs(
|
|
|
178
208
|
);
|
|
179
209
|
}
|
|
180
210
|
|
|
181
|
-
/** Write a new study's README.md, the registry, and the index; the lines that say so. */
|
|
211
|
+
/** Write a new study's README.md and plan.json, the registry, and the index; the lines that say so. */
|
|
182
212
|
function writeStudy(
|
|
183
213
|
p: Paper,
|
|
184
214
|
added: { readonly registry: StudyRegistry; readonly study: Study },
|
|
185
|
-
|
|
215
|
+
deps: StudiesDeps,
|
|
186
216
|
): readonly string[] {
|
|
187
217
|
const { study, registry } = added;
|
|
218
|
+
const { files } = deps;
|
|
188
219
|
const at = (...names: readonly string[]): AbsolutePath =>
|
|
189
220
|
callerPath(join(p.dir, REPRO_DIR, ...names));
|
|
190
221
|
files.writeAtomic(at(study.folder, README), text(studyReadme(study)));
|
|
222
|
+
files.writeAtomic(
|
|
223
|
+
at(study.folder, PLAN_FILE),
|
|
224
|
+
text(planText(planTemplate(study.kind, study.folder))),
|
|
225
|
+
);
|
|
191
226
|
files.writeAtomic(at(STUDIES_FILE), text(registryText(registry)));
|
|
192
|
-
writeIndex(p.dir, registry,
|
|
227
|
+
writeIndex(p.dir, registry, deps);
|
|
193
228
|
const repro = `${p.shown}/${REPRO_DIR}`;
|
|
194
229
|
return [
|
|
195
230
|
`created ${repro}/${study.folder}/${README}`,
|
|
231
|
+
`created ${repro}/${study.folder}/${PLAN_FILE}, the study's plan: its fields are there to fill in before the first run`,
|
|
196
232
|
`added ${study.id} to ${repro}/${STUDIES_FILE}: kind ${study.kind}, status ${study.status}, ${study.date}`,
|
|
197
233
|
`wrote the index of ${studiesCount(registry)} in ${repro}/${README}`,
|
|
198
234
|
`Next: write the study's one-sentence question in its row (\`question\`) and in its README.md. When it ends, set \`status\`, \`result\`, \`result_file\` and \`in_paper\` in its row, then run \`npx paperlint studies index ${p.shown}\`.`,
|
|
@@ -219,35 +255,69 @@ function runNew(
|
|
|
219
255
|
1,
|
|
220
256
|
`${p.shown}/${REPRO_DIR}/${folder}/ already exists; it is never overwritten. Register it by hand, or remove it and run this again.`,
|
|
221
257
|
)
|
|
222
|
-
: writeStudy(p, added, deps
|
|
258
|
+
: writeStudy(p, added, deps);
|
|
223
259
|
}
|
|
224
260
|
|
|
225
261
|
/** The paper folder the command line names, or the refusal. */
|
|
226
|
-
function paperOf(a: StudiesArgs, deps: StudiesDeps): Refusal |
|
|
262
|
+
function paperOf(a: StudiesArgs, deps: StudiesDeps): Refusal | Chosen {
|
|
227
263
|
if (a.sub === undefined)
|
|
228
264
|
return refuse(2, `studies needs a subcommand: ${SUBS}`);
|
|
229
|
-
|
|
265
|
+
const sub = COMMANDS.get(a.sub);
|
|
266
|
+
if (sub === undefined)
|
|
230
267
|
return refuse(2, `unknown subcommand \`studies ${a.sub}\`: ${SUBS}`);
|
|
231
268
|
if (a.paperDir === undefined)
|
|
232
|
-
return refuse(
|
|
233
|
-
2,
|
|
234
|
-
`studies ${a.sub} needs the paper's folder: ${a.sub === "new" ? NEW_USAGE : INDEX_USAGE}`,
|
|
235
|
-
);
|
|
269
|
+
return refuse(2, `studies ${a.sub} needs the paper's folder: ${sub.usage}`);
|
|
236
270
|
const dir = resolve(deps.cwd, a.paperDir);
|
|
237
271
|
const shown = relative(deps.cwd, dir) || ".";
|
|
238
272
|
return deps.files.isFile(callerPath(join(dir, STATUS_FILE)))
|
|
239
|
-
? { dir, shown }
|
|
273
|
+
? { dir, shown, run: sub.run }
|
|
240
274
|
: refuse(2, `${shown} is not a paper folder: it has no ${STATUS_FILE}`);
|
|
241
275
|
}
|
|
242
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
|
+
|
|
243
317
|
/** The run: 0 done, 1 refused by the paper's state, 2 refused by the command line. */
|
|
244
318
|
export function runStudies(a: StudiesArgs, deps: StudiesDeps): number {
|
|
245
319
|
const p = paperOf(a, deps);
|
|
246
|
-
const out = isRefusal(p)
|
|
247
|
-
? p
|
|
248
|
-
: a.sub === "new"
|
|
249
|
-
? runNew(p, a, deps)
|
|
250
|
-
: runIndex(p, a, deps);
|
|
320
|
+
const out = isRefusal(p) ? p : p.run(p, a, deps);
|
|
251
321
|
if (isRefusal(out)) {
|
|
252
322
|
deps.err(out.line);
|
|
253
323
|
return out.code;
|