paperlint 10.3.0 → 10.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +15 -4
- package/README.md +1 -0
- package/dist/CLAUDE.md.spec.d.ts.map +1 -1
- package/dist/CLAUDE.md.spec.js +6 -3
- package/dist/CLAUDE.md.spec.js.map +1 -1
- package/dist/adapters/git/index.d.ts +10 -0
- package/dist/adapters/git/index.d.ts.map +1 -0
- package/dist/adapters/git/index.js +151 -0
- package/dist/adapters/git/index.js.map +1 -0
- package/dist/adapters/latex/captions.d.ts +9 -0
- package/dist/adapters/latex/captions.d.ts.map +1 -0
- package/dist/adapters/latex/captions.js +40 -0
- package/dist/adapters/latex/captions.js.map +1 -0
- package/dist/adapters/latex/index.d.ts +1 -0
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +1 -0
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/parse.d.ts.map +1 -1
- package/dist/adapters/latex/parse.js +6 -0
- package/dist/adapters/latex/parse.js.map +1 -1
- package/dist/adapters/memory/index.d.ts +6 -0
- package/dist/adapters/memory/index.d.ts.map +1 -1
- package/dist/adapters/memory/index.js +8 -0
- package/dist/adapters/memory/index.js.map +1 -1
- package/dist/adapters/node/index.d.ts +1 -0
- package/dist/adapters/node/index.d.ts.map +1 -1
- package/dist/adapters/node/index.js +1 -0
- package/dist/adapters/node/index.js.map +1 -1
- package/dist/adapters/node/list-entries.io.d.ts +3 -0
- package/dist/adapters/node/list-entries.io.d.ts.map +1 -0
- package/dist/adapters/node/list-entries.io.js +26 -0
- package/dist/adapters/node/list-entries.io.js.map +1 -0
- package/dist/adapters/study-registry/index.d.ts +70 -10
- package/dist/adapters/study-registry/index.d.ts.map +1 -1
- package/dist/adapters/study-registry/index.js +84 -10
- package/dist/adapters/study-registry/index.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +33 -3
- package/dist/cli.js.map +1 -1
- package/dist/domain/decide-first.d.ts +110 -0
- package/dist/domain/decide-first.d.ts.map +1 -0
- package/dist/domain/decide-first.js +108 -0
- package/dist/domain/decide-first.js.map +1 -0
- package/dist/domain/index-columns.d.ts +45 -0
- package/dist/domain/index-columns.d.ts.map +1 -0
- package/dist/domain/index-columns.js +64 -0
- package/dist/domain/index-columns.js.map +1 -0
- package/dist/domain/study-mode.d.ts +37 -0
- package/dist/domain/study-mode.d.ts.map +1 -0
- package/dist/domain/study-mode.js +45 -0
- package/dist/domain/study-mode.js.map +1 -0
- package/dist/domain/study-registry.d.ts +116 -12
- package/dist/domain/study-registry.d.ts.map +1 -1
- package/dist/domain/study-registry.js +164 -44
- package/dist/domain/study-registry.js.map +1 -1
- package/dist/outputs-before-decision-rule.d.ts +17 -0
- package/dist/outputs-before-decision-rule.d.ts.map +1 -0
- package/dist/outputs-before-decision-rule.js +94 -0
- package/dist/outputs-before-decision-rule.js.map +1 -0
- package/dist/ports/git-history.d.ts +44 -0
- package/dist/ports/git-history.d.ts.map +1 -0
- package/dist/ports/git-history.js +2 -0
- package/dist/ports/git-history.js.map +1 -0
- package/dist/ports/list-entries.d.ts +20 -0
- package/dist/ports/list-entries.d.ts.map +1 -0
- package/dist/ports/list-entries.js +2 -0
- package/dist/ports/list-entries.js.map +1 -0
- package/dist/ports/study-deps.d.ts +14 -0
- package/dist/ports/study-deps.d.ts.map +1 -0
- package/dist/ports/study-deps.js +2 -0
- package/dist/ports/study-deps.js.map +1 -0
- package/dist/ports/study-folder.d.ts +43 -0
- package/dist/ports/study-folder.d.ts.map +1 -0
- package/dist/ports/study-folder.js +2 -0
- package/dist/ports/study-folder.js.map +1 -0
- package/dist/ports/study-registry-codec.d.ts +6 -3
- package/dist/ports/study-registry-codec.d.ts.map +1 -1
- package/dist/presets.d.ts +1 -2
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +1 -2
- package/dist/presets.js.map +1 -1
- package/dist/studies.d.ts +4 -0
- package/dist/studies.d.ts.map +1 -1
- package/dist/studies.js +52 -21
- package/dist/studies.js.map +1 -1
- package/dist/study-folder.d.ts +14 -0
- package/dist/study-folder.d.ts.map +1 -0
- package/dist/study-folder.js +123 -0
- package/dist/study-folder.js.map +1 -0
- package/dist/study-gate.d.ts +14 -0
- package/dist/study-gate.d.ts.map +1 -0
- package/dist/study-gate.js +132 -0
- package/dist/study-gate.js.map +1 -0
- package/dist/study-index-input.d.ts +5 -0
- package/dist/study-index-input.d.ts.map +1 -0
- package/dist/study-index-input.js +18 -0
- package/dist/study-index-input.js.map +1 -0
- package/dist/study-modes.d.ts +56 -0
- package/dist/study-modes.d.ts.map +1 -0
- package/dist/study-modes.js +59 -0
- package/dist/study-modes.js.map +1 -0
- package/dist/study-registry-rule.d.ts +13 -1
- package/dist/study-registry-rule.d.ts.map +1 -1
- package/dist/study-registry-rule.js +33 -21
- package/dist/study-registry-rule.js.map +1 -1
- package/dist/study-run.d.ts +19 -0
- package/dist/study-run.d.ts.map +1 -0
- package/dist/study-run.js +9 -0
- package/dist/study-run.js.map +1 -0
- package/docs/design/study-modes.md +265 -0
- package/docs/design/study-registry.md +6 -0
- package/docs/e2e.md +8 -4
- package/docs/layout.md +22 -19
- package/docs/rules/repro/outputs-before-decision.md +122 -0
- package/docs/rules/repro/study-registered.md +49 -15
- package/docs/rules/tex/heading-case.md +1 -1
- package/docs/rules.md +54 -53
- package/docs/testing.md +1 -0
- package/eslint.config.mjs +36 -24
- package/package.json +2 -1
- package/scripts/check.ts +6 -0
- package/skills/camera-ready/SKILL.md +6 -1
- package/skills/camera-ready/SKILL.md.spec.ts +5 -0
- package/skills/draft-paper/SKILL.md +11 -3
- package/skills/draft-paper/SKILL.md.spec.ts +10 -2
- package/skills/grade-paper-writing/SKILL.md +4 -2
- package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -1
- package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +16 -3
- package/skills/grade-paper-writing/prose-lint.mjs +127 -95
- package/skills/grade-paper-writing/prose-lint.test.ts +119 -10
- package/skills/harden-paper/SKILL.md +12 -2
- package/skills/harden-paper/SKILL.md.spec.ts +11 -1
- package/skills/osf-artifact-upload/SKILL.md +7 -1
- package/skills/osf-artifact-upload/SKILL.md.spec.ts +6 -0
- package/skills/pc-panel-review/SKILL.md +10 -3
- package/skills/pc-panel-review/SKILL.md.spec.ts +9 -2
- package/skills/tighten-paper/SKILL.md +5 -1
- package/skills/tighten-paper/SKILL.md.spec.ts +4 -0
- package/src/CLAUDE.md +7 -4
- package/src/CLAUDE.md.spec.ts +6 -3
- package/src/adapters/git/history.test.ts +121 -0
- package/src/adapters/git/index.test.ts +268 -0
- package/src/adapters/git/index.ts +227 -0
- package/src/adapters/latex/captions.test.ts +57 -0
- package/src/adapters/latex/captions.ts +56 -0
- package/src/adapters/latex/index.ts +1 -0
- package/src/adapters/latex/parse.ts +7 -0
- package/src/adapters/memory/index.test.ts +15 -1
- package/src/adapters/memory/index.ts +13 -0
- package/src/adapters/node/index.ts +1 -0
- package/src/adapters/node/list-entries.io.ts +27 -0
- package/src/adapters/node/list-entries.test.ts +61 -0
- package/src/adapters/study-registry/index.test.ts +12 -9
- package/src/adapters/study-registry/index.ts +130 -24
- package/src/adapters/study-registry/mode.test.ts +162 -0
- package/src/cli.ts +47 -3
- package/src/domain/decide-first.test.ts +291 -0
- package/src/domain/decide-first.ts +258 -0
- package/src/domain/index-columns.test.ts +261 -0
- package/src/domain/index-columns.ts +107 -0
- package/src/domain/study-measurements.test.ts +16 -8
- package/src/domain/study-mode.test.ts +55 -0
- package/src/domain/study-mode.ts +78 -0
- package/src/domain/study-registry.test.ts +72 -37
- package/src/domain/study-registry.ts +281 -50
- package/src/new-paper.test.ts +5 -1
- package/src/outputs-before-decision-rule.test.ts +251 -0
- package/src/outputs-before-decision-rule.ts +133 -0
- package/src/ports/git-history.ts +44 -0
- package/src/ports/list-entries.ts +21 -0
- package/src/ports/study-deps.ts +14 -0
- package/src/ports/study-folder.ts +38 -0
- package/src/ports/study-registry-codec.ts +10 -3
- package/src/presets.ts +1 -2
- package/src/rule-cases.test.ts +78 -3
- package/src/studies-index-modes.test.ts +129 -0
- package/src/studies.test.ts +35 -11
- package/src/studies.ts +96 -26
- package/src/study-folder.test.ts +237 -0
- package/src/study-folder.ts +176 -0
- package/src/study-gate.test.ts +467 -0
- package/src/study-gate.ts +234 -0
- package/src/study-index-input.ts +31 -0
- package/src/study-modes.ts +107 -0
- package/src/study-registry-rule.test.ts +285 -11
- package/src/study-registry-rule.ts +64 -35
- package/src/study-run.ts +21 -0
|
@@ -0,0 +1,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 papers/example/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 a symbolic link: nothing is read or written through it. Give the row a real folder inside papers/example/repro/, then run this again.",
|
|
126
|
+
],
|
|
127
|
+
false,
|
|
128
|
+
]);
|
|
129
|
+
});
|
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
|
|
@@ -14,6 +16,7 @@
|
|
|
14
16
|
* registry is refused with the file to write rather than given a guessed one.
|
|
15
17
|
*/
|
|
16
18
|
import { join, relative, resolve } from "node:path";
|
|
19
|
+
import { find, map, pipe } from "remeda";
|
|
17
20
|
import { callerPath } from "./caller-path.ts";
|
|
18
21
|
import type { AbsolutePath } from "./domain/paths.ts";
|
|
19
22
|
import { README, REPRO_DIR } from "./domain/repro-layout.ts";
|
|
@@ -23,7 +26,7 @@ import {
|
|
|
23
26
|
STUDIES_FILE,
|
|
24
27
|
STUDY_KINDS,
|
|
25
28
|
addStudy,
|
|
26
|
-
|
|
29
|
+
indexCurrent,
|
|
27
30
|
isStudyKind,
|
|
28
31
|
planTemplate,
|
|
29
32
|
planText,
|
|
@@ -35,9 +38,15 @@ import {
|
|
|
35
38
|
type StudyRegistry,
|
|
36
39
|
} from "./domain/study-registry.ts";
|
|
37
40
|
import type { Files } from "./ports/files.ts";
|
|
41
|
+
import type { FolderRefusal } from "./ports/study-folder.ts";
|
|
38
42
|
import type { StudyRegistryCodec } from "./ports/study-registry-codec.ts";
|
|
43
|
+
import type { ListEntries } from "./ports/list-entries.ts";
|
|
39
44
|
import type { ListDir } from "./ports/talk-media.ts";
|
|
40
45
|
import { registryOf } from "./study-registry-rule.ts";
|
|
46
|
+
import { folderRefusalText, studyFolder } from "./study-folder.ts";
|
|
47
|
+
import { runGate } from "./study-gate.ts";
|
|
48
|
+
import { indexInputOf } from "./study-index-input.ts";
|
|
49
|
+
import type { GitHistory, GitOrder } from "./ports/git-history.ts";
|
|
41
50
|
|
|
42
51
|
/** What one `studies` run was asked, after the command line. */
|
|
43
52
|
export interface StudiesArgs {
|
|
@@ -52,8 +61,10 @@ export interface StudiesArgs {
|
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
export interface StudiesDeps {
|
|
64
|
+
readonly git: GitHistory & GitOrder;
|
|
55
65
|
readonly files: Files;
|
|
56
66
|
readonly listDir: ListDir;
|
|
67
|
+
readonly entries: ListEntries;
|
|
57
68
|
readonly codec: StudyRegistryCodec;
|
|
58
69
|
readonly cwd: string;
|
|
59
70
|
/** Today, `YYYY-MM-DD`: the root reads the clock. */
|
|
@@ -65,7 +76,8 @@ export interface StudiesDeps {
|
|
|
65
76
|
const NEW_USAGE =
|
|
66
77
|
'npx paperlint studies new <paper-dir> "<title>" [--kind <kind>]';
|
|
67
78
|
const INDEX_USAGE = "npx paperlint studies index <paper-dir>";
|
|
68
|
-
const
|
|
79
|
+
const GATE_USAGE = "npx paperlint studies gate <paper-dir> <id>";
|
|
80
|
+
const SUBS = `new, index or gate — ${[NEW_USAGE, INDEX_USAGE, GATE_USAGE].join(" · ")}`;
|
|
69
81
|
|
|
70
82
|
const STATUS_FILE = "PIPELINE-STATUS.md";
|
|
71
83
|
|
|
@@ -93,10 +105,32 @@ function registryIn(p: Paper, deps: StudiesDeps): StudyRegistry | Refusal {
|
|
|
93
105
|
case "unreadable":
|
|
94
106
|
return refuse(1, `${file} is not a study registry: ${r.why}`);
|
|
95
107
|
case "read":
|
|
96
|
-
return r.registry;
|
|
108
|
+
return escaping(p, r.registry, deps) ?? r.registry;
|
|
97
109
|
}
|
|
98
110
|
}
|
|
99
111
|
|
|
112
|
+
/** The refusal when a row names a folder that may not be entered; null when none does. */
|
|
113
|
+
function escaping(
|
|
114
|
+
p: Paper,
|
|
115
|
+
r: StudyRegistry,
|
|
116
|
+
deps: StudiesDeps,
|
|
117
|
+
): Refusal | null {
|
|
118
|
+
const folder = studyFolder(p.dir, deps);
|
|
119
|
+
const found = pipe(
|
|
120
|
+
r.studies,
|
|
121
|
+
map((row) => ({ row, why: folder.refusal(row.folder) })),
|
|
122
|
+
find(
|
|
123
|
+
(x): x is typeof x & { readonly why: FolderRefusal } => x.why !== null,
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
if (found === undefined) return null;
|
|
127
|
+
const { reason, fix } = folderRefusalText(found.why, p.shown);
|
|
128
|
+
return refuse(
|
|
129
|
+
1,
|
|
130
|
+
`${found.row.id} in ${p.shown}/${REPRO_DIR}/${STUDIES_FILE} names the folder \`${found.row.folder}\`, which ${reason}: nothing is read or written through it. ${fix}, then run this again.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
100
134
|
const isRefusal = (v: unknown): v is Refusal =>
|
|
101
135
|
typeof v === "object" && v !== null && "code" in v && "line" in v;
|
|
102
136
|
|
|
@@ -115,12 +149,13 @@ const studiesCount = (r: StudyRegistry): string =>
|
|
|
115
149
|
* Write the index of `r` into the README; whether it wrote. A block already in sync is left as it
|
|
116
150
|
* is, so the padding a formatter gave the table is never undone.
|
|
117
151
|
*/
|
|
118
|
-
function writeIndex(dir: string, r: StudyRegistry,
|
|
119
|
-
const before = readmeOf(dir, files);
|
|
120
|
-
|
|
121
|
-
|
|
152
|
+
function writeIndex(dir: string, r: StudyRegistry, deps: StudiesDeps): boolean {
|
|
153
|
+
const before = readmeOf(dir, deps.files);
|
|
154
|
+
const at = indexInputOf(dir, { kind: "read", registry: r }, deps);
|
|
155
|
+
if (indexCurrent(before, r, at)) return false;
|
|
156
|
+
deps.files.writeAtomic(
|
|
122
157
|
callerPath(join(dir, REPRO_DIR, README)),
|
|
123
|
-
text(withIndex(before, r)),
|
|
158
|
+
text(withIndex(before, r, at)),
|
|
124
159
|
);
|
|
125
160
|
return true;
|
|
126
161
|
}
|
|
@@ -155,7 +190,7 @@ function runIndex(
|
|
|
155
190
|
if (blocked !== null) return blocked;
|
|
156
191
|
const readme = `${p.shown}/${REPRO_DIR}/${README}`;
|
|
157
192
|
return [
|
|
158
|
-
writeIndex(p.dir, r, deps
|
|
193
|
+
writeIndex(p.dir, r, deps)
|
|
159
194
|
? `wrote the index of ${studiesCount(r)} in ${readme}`
|
|
160
195
|
: `the index in ${readme} is in sync with ${studiesCount(r)}`,
|
|
161
196
|
];
|
|
@@ -186,18 +221,19 @@ function newArgs(
|
|
|
186
221
|
function writeStudy(
|
|
187
222
|
p: Paper,
|
|
188
223
|
added: { readonly registry: StudyRegistry; readonly study: Study },
|
|
189
|
-
|
|
224
|
+
deps: StudiesDeps,
|
|
190
225
|
): readonly string[] {
|
|
191
226
|
const { study, registry } = added;
|
|
227
|
+
const { files } = deps;
|
|
192
228
|
const at = (...names: readonly string[]): AbsolutePath =>
|
|
193
229
|
callerPath(join(p.dir, REPRO_DIR, ...names));
|
|
194
230
|
files.writeAtomic(at(study.folder, README), text(studyReadme(study)));
|
|
195
231
|
files.writeAtomic(
|
|
196
232
|
at(study.folder, PLAN_FILE),
|
|
197
|
-
text(planText(planTemplate())),
|
|
233
|
+
text(planText(planTemplate(study.kind, study.folder))),
|
|
198
234
|
);
|
|
199
235
|
files.writeAtomic(at(STUDIES_FILE), text(registryText(registry)));
|
|
200
|
-
writeIndex(p.dir, registry,
|
|
236
|
+
writeIndex(p.dir, registry, deps);
|
|
201
237
|
const repro = `${p.shown}/${REPRO_DIR}`;
|
|
202
238
|
return [
|
|
203
239
|
`created ${repro}/${study.folder}/${README}`,
|
|
@@ -228,35 +264,69 @@ function runNew(
|
|
|
228
264
|
1,
|
|
229
265
|
`${p.shown}/${REPRO_DIR}/${folder}/ already exists; it is never overwritten. Register it by hand, or remove it and run this again.`,
|
|
230
266
|
)
|
|
231
|
-
: writeStudy(p, added, deps
|
|
267
|
+
: writeStudy(p, added, deps);
|
|
232
268
|
}
|
|
233
269
|
|
|
234
270
|
/** The paper folder the command line names, or the refusal. */
|
|
235
|
-
function paperOf(a: StudiesArgs, deps: StudiesDeps): Refusal |
|
|
271
|
+
function paperOf(a: StudiesArgs, deps: StudiesDeps): Refusal | Chosen {
|
|
236
272
|
if (a.sub === undefined)
|
|
237
273
|
return refuse(2, `studies needs a subcommand: ${SUBS}`);
|
|
238
|
-
|
|
274
|
+
const sub = COMMANDS.get(a.sub);
|
|
275
|
+
if (sub === undefined)
|
|
239
276
|
return refuse(2, `unknown subcommand \`studies ${a.sub}\`: ${SUBS}`);
|
|
240
277
|
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
|
-
);
|
|
278
|
+
return refuse(2, `studies ${a.sub} needs the paper's folder: ${sub.usage}`);
|
|
245
279
|
const dir = resolve(deps.cwd, a.paperDir);
|
|
246
280
|
const shown = relative(deps.cwd, dir) || ".";
|
|
247
281
|
return deps.files.isFile(callerPath(join(dir, STATUS_FILE)))
|
|
248
|
-
? { dir, shown }
|
|
282
|
+
? { dir, shown, run: sub.run }
|
|
249
283
|
: refuse(2, `${shown} is not a paper folder: it has no ${STATUS_FILE}`);
|
|
250
284
|
}
|
|
251
285
|
|
|
286
|
+
/** `gate`: the study's id, one argument, then the gate. */
|
|
287
|
+
const gateRun = (
|
|
288
|
+
p: Paper,
|
|
289
|
+
a: StudiesArgs,
|
|
290
|
+
deps: StudiesDeps,
|
|
291
|
+
): Refusal | readonly string[] => {
|
|
292
|
+
if (a.title === undefined)
|
|
293
|
+
return refuse(2, `studies gate needs the study's id: ${GATE_USAGE}`);
|
|
294
|
+
const [surplus] = a.extra;
|
|
295
|
+
if (surplus !== undefined)
|
|
296
|
+
return refuse(
|
|
297
|
+
2,
|
|
298
|
+
`studies gate takes the paper's folder and one id — \`${surplus}\` is one argument too many`,
|
|
299
|
+
);
|
|
300
|
+
const out = runGate(p.dir, p.shown, a.title, deps);
|
|
301
|
+
return out.ok ? out.lines : out.refusal;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/** A run of a subcommand over a paper folder. */
|
|
305
|
+
type Run = (
|
|
306
|
+
p: Paper,
|
|
307
|
+
a: StudiesArgs,
|
|
308
|
+
d: StudiesDeps,
|
|
309
|
+
) => Refusal | readonly string[];
|
|
310
|
+
|
|
311
|
+
/** The paper folder, and the run the subcommand chose. */
|
|
312
|
+
interface Chosen extends Paper {
|
|
313
|
+
readonly run: Run;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Each subcommand: its usage line and its run. */
|
|
317
|
+
const COMMANDS: ReadonlyMap<
|
|
318
|
+
string,
|
|
319
|
+
{ readonly usage: string; readonly run: Run }
|
|
320
|
+
> = new Map([
|
|
321
|
+
["new", { usage: NEW_USAGE, run: runNew }],
|
|
322
|
+
["index", { usage: INDEX_USAGE, run: runIndex }],
|
|
323
|
+
["gate", { usage: GATE_USAGE, run: gateRun }],
|
|
324
|
+
]);
|
|
325
|
+
|
|
252
326
|
/** The run: 0 done, 1 refused by the paper's state, 2 refused by the command line. */
|
|
253
327
|
export function runStudies(a: StudiesArgs, deps: StudiesDeps): number {
|
|
254
328
|
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);
|
|
329
|
+
const out = isRefusal(p) ? p : p.run(p, a, deps);
|
|
260
330
|
if (isRefusal(out)) {
|
|
261
331
|
deps.err(out.line);
|
|
262
332
|
return out.code;
|