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,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one reader of what lies inside a study's folder (`src/study-folder.ts`), over a disk held in
|
|
3
|
+
* memory whose listing marks chosen paths as symbolic links: a file is read, a missing path is absent,
|
|
4
|
+
* and a path that passes through a link — at the file or at any directory above it — is `linked`,
|
|
5
|
+
* naming the link, with nothing read.
|
|
6
|
+
*/
|
|
7
|
+
import { expect, it } from "vitest";
|
|
8
|
+
import { memoryFiles, memoryListEntries } from "./adapters/memory/index.ts";
|
|
9
|
+
import { folderRefusalText, studyFolder } from "./study-folder.ts";
|
|
10
|
+
|
|
11
|
+
const PAPER = "/work/papers/example";
|
|
12
|
+
const FOLDER = `${PAPER}/repro/P1-S01-outcome`;
|
|
13
|
+
const TREE = {
|
|
14
|
+
[`${FOLDER}/plan.json`]: "{}",
|
|
15
|
+
[`${FOLDER}/sub/plan.json`]: "{ }",
|
|
16
|
+
[`${FOLDER}/sub/deeper/out.tsv`]: "x\n",
|
|
17
|
+
[`${FOLDER}/other/note.md`]: "n\n",
|
|
18
|
+
[`${PAPER}/repro/P1-S01-linked/plan.json`]: "{}",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The reader over `TREE`, `links` and `others` being absolute paths the listing reports as links and as neither file nor directory (a FIFO); plus every path the disk was asked for. */
|
|
22
|
+
const reader = (links: readonly string[], others: readonly string[] = []) => {
|
|
23
|
+
const files = memoryFiles(TREE);
|
|
24
|
+
const asked: string[] = [];
|
|
25
|
+
return {
|
|
26
|
+
asked,
|
|
27
|
+
sf: studyFolder(PAPER, {
|
|
28
|
+
files: {
|
|
29
|
+
...files,
|
|
30
|
+
readBytes: (p) => {
|
|
31
|
+
asked.push(p);
|
|
32
|
+
return files.readBytes(p);
|
|
33
|
+
},
|
|
34
|
+
isFile: (p) => {
|
|
35
|
+
asked.push(p);
|
|
36
|
+
return files.isFile(p);
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
entries: (dir) =>
|
|
40
|
+
memoryListEntries(files)(dir).map((e) => {
|
|
41
|
+
if (links.includes(`${dir}/${e.name}`))
|
|
42
|
+
return { ...e, kind: "symlink" as const };
|
|
43
|
+
return others.includes(`${dir}/${e.name}`)
|
|
44
|
+
? { ...e, kind: "other" as const }
|
|
45
|
+
: e;
|
|
46
|
+
}),
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
it("reads a file inside the folder; a path that is nothing, a directory, or under a file is absent", () => {
|
|
52
|
+
const { sf } = reader([]);
|
|
53
|
+
// Guards: the plain cases, so that `linked` below is the link's doing and not a broken reader.
|
|
54
|
+
expect([
|
|
55
|
+
sf.read("P1-S01-outcome", "plan.json"),
|
|
56
|
+
sf.read("P1-S01-outcome", "./sub/plan.json"),
|
|
57
|
+
sf.read("P1-S01-outcome", "nope.json"),
|
|
58
|
+
sf.read("P1-S01-outcome", "sub"),
|
|
59
|
+
sf.read("P1-S01-outcome", "plan.json/x"),
|
|
60
|
+
sf.read("P1-S01-outcome", "sub/missing/plan.json"),
|
|
61
|
+
]).toEqual([
|
|
62
|
+
{ kind: "text", text: "{}" },
|
|
63
|
+
{ kind: "text", text: "{ }" },
|
|
64
|
+
{ kind: "absent" },
|
|
65
|
+
{ kind: "absent" },
|
|
66
|
+
{ kind: "absent" },
|
|
67
|
+
{ kind: "absent" },
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("a link at the file, or at any directory above it, is `linked` and names the link; nothing is read", () => {
|
|
72
|
+
const { sf, asked } = reader([
|
|
73
|
+
`${FOLDER}/plan.json`,
|
|
74
|
+
`${FOLDER}/sub`,
|
|
75
|
+
`${FOLDER}/other/note.md`,
|
|
76
|
+
]);
|
|
77
|
+
// Guards: the file behind a link is never asked for, wherever on the path the link stands; the
|
|
78
|
+
// link is named relative to the folder, as far down as the walk got.
|
|
79
|
+
expect([
|
|
80
|
+
sf.read("P1-S01-outcome", "plan.json"),
|
|
81
|
+
sf.read("P1-S01-outcome", "sub/plan.json"),
|
|
82
|
+
sf.read("P1-S01-outcome", "sub/deeper/out.tsv"),
|
|
83
|
+
sf.read("P1-S01-outcome", "other/note.md"),
|
|
84
|
+
]).toEqual([
|
|
85
|
+
{ kind: "linked", link: "plan.json" },
|
|
86
|
+
{ kind: "linked", link: "sub" },
|
|
87
|
+
{ kind: "linked", link: "sub" },
|
|
88
|
+
{ kind: "linked", link: "other/note.md" },
|
|
89
|
+
]);
|
|
90
|
+
expect(asked).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("`locate` says what is there without reading it", () => {
|
|
94
|
+
const { sf, asked } = reader([`${FOLDER}/sub`]);
|
|
95
|
+
// Guards: the answer for a result file's existence needs no read of its bytes.
|
|
96
|
+
expect([
|
|
97
|
+
sf.locate("P1-S01-outcome", "plan.json"),
|
|
98
|
+
sf.locate("P1-S01-outcome", "sub/deeper/out.tsv"),
|
|
99
|
+
sf.locate("P1-S01-outcome", "nope"),
|
|
100
|
+
]).toEqual([
|
|
101
|
+
{ kind: "file" },
|
|
102
|
+
{ kind: "linked", link: "sub" },
|
|
103
|
+
{ kind: "absent" },
|
|
104
|
+
]);
|
|
105
|
+
expect(asked).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("reads nothing outside the folder: a NUL, a path up and out, a folder name that is not one folder, a folder that is a link", () => {
|
|
109
|
+
const { sf, asked } = reader([`${PAPER}/repro/P1-S01-outcome`]);
|
|
110
|
+
const open = reader([]).sf;
|
|
111
|
+
// Guards: the reader itself holds the containment the callers rely on — the folder that is a link
|
|
112
|
+
// reads as absent (not `linked`), as the registry rule's page says.
|
|
113
|
+
expect([
|
|
114
|
+
open.read("P1-S01-outcome", "a\0b"),
|
|
115
|
+
open.read("P1-S01-outcome", "../x/plan.json"),
|
|
116
|
+
open.read("../x", "plan.json"),
|
|
117
|
+
open.read("a/b", "plan.json"),
|
|
118
|
+
sf.read("P1-S01-outcome", "plan.json"),
|
|
119
|
+
]).toEqual(Array(5).fill({ kind: "absent" }));
|
|
120
|
+
expect(asked).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("a file that vanishes between the walk and the read is absent", () => {
|
|
124
|
+
const files = memoryFiles(TREE);
|
|
125
|
+
const sf = studyFolder(PAPER, {
|
|
126
|
+
files: { ...files, isFile: () => false },
|
|
127
|
+
entries: memoryListEntries(files),
|
|
128
|
+
});
|
|
129
|
+
// Guards: the reader never throws on a race; it says there is nothing.
|
|
130
|
+
expect(sf.read("P1-S01-outcome", "plan.json")).toEqual({ kind: "absent" });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("a link is named as far down as the walk got, even when its name repeats above it", () => {
|
|
134
|
+
const files = memoryFiles({ [`${FOLDER}/a/a/x.txt`]: "x\n" });
|
|
135
|
+
const sf = studyFolder(PAPER, {
|
|
136
|
+
files,
|
|
137
|
+
entries: (dir) =>
|
|
138
|
+
memoryListEntries(files)(dir).map((e) =>
|
|
139
|
+
`${dir}/${e.name}` === `${FOLDER}/a/a`
|
|
140
|
+
? { ...e, kind: "symlink" as const }
|
|
141
|
+
: e,
|
|
142
|
+
),
|
|
143
|
+
});
|
|
144
|
+
// Guards: the second `a` is the link, not the first.
|
|
145
|
+
expect(sf.read("P1-S01-outcome", "a/a/x.txt")).toEqual({
|
|
146
|
+
kind: "linked",
|
|
147
|
+
link: "a/a",
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("a row's folder may be entered unless it is no one folder inside repro/ or is a link", () => {
|
|
152
|
+
const { sf } = reader([`${PAPER}/repro/P1-S01-linked`]);
|
|
153
|
+
// Guards: the one place the folder check lives; each refusal is its own reason.
|
|
154
|
+
expect([
|
|
155
|
+
sf.refusal("P1-S01-outcome"),
|
|
156
|
+
sf.refusal("../x"),
|
|
157
|
+
sf.refusal("a/b"),
|
|
158
|
+
sf.refusal("P1-S01-linked"),
|
|
159
|
+
]).toEqual([null, "not-a-folder", "not-a-folder", "symbolic-link"]);
|
|
160
|
+
expect([
|
|
161
|
+
folderRefusalText("not-a-folder", "p"),
|
|
162
|
+
folderRefusalText("symbolic-link", "p"),
|
|
163
|
+
]).toEqual([
|
|
164
|
+
{
|
|
165
|
+
reason: "is not one folder inside p/repro/",
|
|
166
|
+
fix: "Give the row its own folder's name",
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
reason: "is a symbolic link",
|
|
170
|
+
fix: "Give the row a real folder inside p/repro/",
|
|
171
|
+
},
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("a path in the paper is a file, a directory, nothing, or a link on the way — the first one, from the paper's root", () => {
|
|
176
|
+
const { sf, asked } = reader([`${FOLDER}/sub`, `${FOLDER}/plan.json`]);
|
|
177
|
+
const at = (p: string) => sf.entryAt(p);
|
|
178
|
+
// Guards: the decision file is judged by every component of its path, not by its last one.
|
|
179
|
+
expect([
|
|
180
|
+
at("repro/P1-S01-outcome/other/note.md"),
|
|
181
|
+
at("repro/P1-S01-outcome/other"),
|
|
182
|
+
at("repro/P1-S01-outcome/nope.md"),
|
|
183
|
+
at("repro/P1-S01-outcome/sub/plan.json"),
|
|
184
|
+
at("repro/P1-S01-outcome/plan.json"),
|
|
185
|
+
at("../outside.md"),
|
|
186
|
+
at("a\0b"),
|
|
187
|
+
]).toEqual([
|
|
188
|
+
{ kind: "file" },
|
|
189
|
+
{ kind: "dir" },
|
|
190
|
+
{ kind: "absent" },
|
|
191
|
+
{ kind: "linked", link: "repro/P1-S01-outcome/sub" },
|
|
192
|
+
{ kind: "linked", link: "repro/P1-S01-outcome/plan.json" },
|
|
193
|
+
{ kind: "absent" },
|
|
194
|
+
{ kind: "absent" },
|
|
195
|
+
]);
|
|
196
|
+
expect(asked).toEqual([]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("lists every file under a study's folder, relative and /-separated; a link is one entry, never descended into", () => {
|
|
200
|
+
const { sf, asked } = reader([`${FOLDER}/sub`, `${FOLDER}/other/note.md`]);
|
|
201
|
+
// Guards: the outputs of a study are listed by the same reader that reads its plan — a linked
|
|
202
|
+
// directory is a file as git stores it, and what lies behind it is not the study's.
|
|
203
|
+
expect(sf.list("P1-S01-outcome")).toEqual([
|
|
204
|
+
"plan.json",
|
|
205
|
+
"sub",
|
|
206
|
+
"other/note.md",
|
|
207
|
+
]);
|
|
208
|
+
expect(asked).toEqual([]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("lists nothing of a folder that may not be entered or is not there", () => {
|
|
212
|
+
const { sf } = reader([`${PAPER}/repro/P1-S01-linked`]);
|
|
213
|
+
// Guards: a linked study folder brings no foreign file into the study, nor does a name outside repro/.
|
|
214
|
+
expect([
|
|
215
|
+
sf.list("P1-S01-linked"),
|
|
216
|
+
sf.list("../x"),
|
|
217
|
+
sf.list("P1-S09-missing"),
|
|
218
|
+
]).toEqual([[], [], []]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("something that is not a regular file — a FIFO, a socket — is absent as a plan or a result, other as a path, and still listed by name", () => {
|
|
222
|
+
const { sf, asked } = reader([], [`${FOLDER}/plan.json`]);
|
|
223
|
+
// Guards: the listing used to call every such entry a file, so the reader would open it (a FIFO
|
|
224
|
+
// blocks) and report it present.
|
|
225
|
+
expect([
|
|
226
|
+
sf.locate("P1-S01-outcome", "plan.json"),
|
|
227
|
+
sf.read("P1-S01-outcome", "plan.json"),
|
|
228
|
+
sf.entryAt("repro/P1-S01-outcome/plan.json"),
|
|
229
|
+
sf.list("P1-S01-outcome"),
|
|
230
|
+
]).toEqual([
|
|
231
|
+
{ kind: "absent" },
|
|
232
|
+
{ kind: "absent" },
|
|
233
|
+
{ kind: "other" },
|
|
234
|
+
["plan.json", "sub/plan.json", "sub/deeper/out.tsv", "other/note.md"],
|
|
235
|
+
]);
|
|
236
|
+
expect(asked).toEqual([]);
|
|
237
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one reader of what lies inside a study's folder, `repro/<folder>/<path>`, and of the decision
|
|
3
|
+
* file that lies elsewhere in the paper. It never follows a symbolic link: a path is walked one
|
|
4
|
+
* component at a time by the entries of each directory (`ListEntries`, which does not follow links),
|
|
5
|
+
* and a path that passes through a link — the file itself, or any directory above it — is `linked`,
|
|
6
|
+
* naming the link, before a single byte is read. Every reader of a study takes a `StudyFolder` and
|
|
7
|
+
* not `Files`, so a read that follows a link is not what those readers are handed.
|
|
8
|
+
*
|
|
9
|
+
* What goes through it, and who builds it (once per run, from the paper's folder):
|
|
10
|
+
* - the plan of a row: `repro/study-registered`, `repro/outputs-before-decision`, `studies gate`
|
|
11
|
+
* and the index's mode columns;
|
|
12
|
+
* - the result file of a row: `repro/study-registered`;
|
|
13
|
+
* - whether a row's folder may be entered at all (`refusal`): `studies gate`, `studies index|new`;
|
|
14
|
+
* - the decision file's path (`entryAt`): the gate and the order rule.
|
|
15
|
+
* The outputs of a study are listed through it too (`list`).
|
|
16
|
+
* A study folder that is itself a link, or a folder name that is not one folder inside `repro/`, is
|
|
17
|
+
* never entered: its files read as `absent`.
|
|
18
|
+
*
|
|
19
|
+
* A link made between the walk and the read is not caught: paperlint guards against accidents, not
|
|
20
|
+
* against an author who races their own tooling (docs/design/study-modes.md, the threat model).
|
|
21
|
+
*/
|
|
22
|
+
import { join, posix } from "node:path";
|
|
23
|
+
import { callerPath } from "./caller-path.ts";
|
|
24
|
+
import { REPRO_DIR } from "./domain/repro-layout.ts";
|
|
25
|
+
import {
|
|
26
|
+
isFolderName,
|
|
27
|
+
isInside,
|
|
28
|
+
type Located,
|
|
29
|
+
} from "./domain/study-registry.ts";
|
|
30
|
+
import type { Files } from "./ports/files.ts";
|
|
31
|
+
import type { DirEntry, ListEntries } from "./ports/list-entries.ts";
|
|
32
|
+
import type {
|
|
33
|
+
FolderRefusal,
|
|
34
|
+
PathEntry,
|
|
35
|
+
StudyFolder,
|
|
36
|
+
} from "./ports/study-folder.ts";
|
|
37
|
+
|
|
38
|
+
/** What a refusal says of the folder a row names: `which <reason>`, and the fix without its full stop. */
|
|
39
|
+
export const folderRefusalText = (
|
|
40
|
+
why: FolderRefusal,
|
|
41
|
+
shown: string,
|
|
42
|
+
): { readonly reason: string; readonly fix: string } =>
|
|
43
|
+
why === "not-a-folder"
|
|
44
|
+
? {
|
|
45
|
+
reason: `is not one folder inside ${shown}/${REPRO_DIR}/`,
|
|
46
|
+
fix: "Give the row its own folder's name",
|
|
47
|
+
}
|
|
48
|
+
: {
|
|
49
|
+
reason: "is a symbolic link",
|
|
50
|
+
fix: `Give the row a real folder inside ${shown}/${REPRO_DIR}/`,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** What the entry `name` of the directory `dir` is; undefined when there is none. */
|
|
54
|
+
const kindAt = (
|
|
55
|
+
entries: ListEntries,
|
|
56
|
+
dir: string,
|
|
57
|
+
name: string,
|
|
58
|
+
): DirEntry["kind"] | undefined =>
|
|
59
|
+
entries(callerPath(dir)).find((e) => e.name === name)?.kind;
|
|
60
|
+
|
|
61
|
+
const ENTRY: Readonly<Record<Exclude<DirEntry["kind"], "symlink">, PathEntry>> =
|
|
62
|
+
{
|
|
63
|
+
file: { kind: "file" },
|
|
64
|
+
dir: { kind: "dir" },
|
|
65
|
+
other: { kind: "other" },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What the entry `name` below `dir`/`above` is: every component on the way is looked up in its
|
|
70
|
+
* directory's entries, and the first that is a symbolic link ends the walk (an entry under a
|
|
71
|
+
* directory that is not there is not listed, so it can be no link).
|
|
72
|
+
*/
|
|
73
|
+
const walk = (
|
|
74
|
+
dir: string,
|
|
75
|
+
above: readonly string[],
|
|
76
|
+
name: string,
|
|
77
|
+
entries: ListEntries,
|
|
78
|
+
): PathEntry => {
|
|
79
|
+
const path = [...above, name];
|
|
80
|
+
const at = path.findIndex(
|
|
81
|
+
(c, i) => kindAt(entries, join(dir, ...path.slice(0, i)), c) === "symlink",
|
|
82
|
+
);
|
|
83
|
+
if (at >= 0) return { kind: "linked", link: path.slice(0, at + 1).join("/") };
|
|
84
|
+
const kind = kindAt(entries, join(dir, ...above), name);
|
|
85
|
+
return kind === undefined || kind === "symlink"
|
|
86
|
+
? { kind: "absent" }
|
|
87
|
+
: ENTRY[kind];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** `path` as the directories above its last component, and that component. */
|
|
91
|
+
const split = (
|
|
92
|
+
path: string,
|
|
93
|
+
): {
|
|
94
|
+
readonly above: readonly string[];
|
|
95
|
+
readonly name: string;
|
|
96
|
+
} => {
|
|
97
|
+
const normal = posix.normalize(path);
|
|
98
|
+
return {
|
|
99
|
+
above: posix
|
|
100
|
+
.dirname(normal)
|
|
101
|
+
.split("/")
|
|
102
|
+
.filter((c) => c !== "."),
|
|
103
|
+
name: posix.basename(normal),
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** What `path` in `folder` is, by the entries of the paper folder `paper`; nothing is read. */
|
|
108
|
+
const locateIn = (
|
|
109
|
+
paper: string,
|
|
110
|
+
entries: ListEntries,
|
|
111
|
+
folder: string,
|
|
112
|
+
path: string,
|
|
113
|
+
): Located => {
|
|
114
|
+
// A NUL is no path the disk can hold, and probing one throws; the rest never leaves the folder.
|
|
115
|
+
if (!isFolderName(folder) || !isInside(path) || path.includes("\0"))
|
|
116
|
+
return { kind: "absent" };
|
|
117
|
+
const { above, name } = split(path);
|
|
118
|
+
// The folder is walked too: one that is a link is never entered, and reads as absent.
|
|
119
|
+
const found = walk(join(paper, REPRO_DIR), [folder, ...above], name, entries);
|
|
120
|
+
// A directory or a special file is no file to read: it reads as nothing.
|
|
121
|
+
if (found.kind === "dir" || found.kind === "other") return { kind: "absent" };
|
|
122
|
+
if (found.kind !== "linked") return found;
|
|
123
|
+
return found.link === folder
|
|
124
|
+
? { kind: "absent" }
|
|
125
|
+
: { kind: "linked", link: found.link.slice(folder.length + 1) };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Why `folder` may not be entered in the paper folder `paper`; null when it may. */
|
|
129
|
+
const refusalOf = (
|
|
130
|
+
paper: string,
|
|
131
|
+
entries: ListEntries,
|
|
132
|
+
folder: string,
|
|
133
|
+
): FolderRefusal | null => {
|
|
134
|
+
if (!isFolderName(folder)) return "not-a-folder";
|
|
135
|
+
return kindAt(entries, join(paper, REPRO_DIR), folder) === "symlink"
|
|
136
|
+
? "symbolic-link"
|
|
137
|
+
: null;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** Every file under `dir`, relative to it; a link is a file, and is not entered. */
|
|
141
|
+
const filesUnder = (
|
|
142
|
+
dir: string,
|
|
143
|
+
entries: ListEntries,
|
|
144
|
+
prefix = "",
|
|
145
|
+
): readonly string[] =>
|
|
146
|
+
entries(callerPath(dir)).flatMap(({ name, kind }) => {
|
|
147
|
+
const rel = prefix === "" ? name : `${prefix}/${name}`;
|
|
148
|
+
return kind === "dir" ? filesUnder(join(dir, name), entries, rel) : [rel];
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/** The reader of the study folders of the paper folder `paper`. */
|
|
152
|
+
export const studyFolder = (
|
|
153
|
+
paper: string,
|
|
154
|
+
deps: { readonly files: Files; readonly entries: ListEntries },
|
|
155
|
+
): StudyFolder => ({
|
|
156
|
+
refusal: (folder) => refusalOf(paper, deps.entries, folder),
|
|
157
|
+
locate: (folder, path) => locateIn(paper, deps.entries, folder, path),
|
|
158
|
+
read: (folder, path) => {
|
|
159
|
+
const at = locateIn(paper, deps.entries, folder, path);
|
|
160
|
+
if (at.kind !== "file") return at;
|
|
161
|
+
const p = callerPath(join(paper, REPRO_DIR, folder, path));
|
|
162
|
+
const bytes = deps.files.isFile(p) ? deps.files.readBytes(p) : null;
|
|
163
|
+
return bytes === null
|
|
164
|
+
? { kind: "absent" }
|
|
165
|
+
: { kind: "text", text: new TextDecoder().decode(bytes) };
|
|
166
|
+
},
|
|
167
|
+
list: (folder) =>
|
|
168
|
+
refusalOf(paper, deps.entries, folder) === null
|
|
169
|
+
? filesUnder(join(paper, REPRO_DIR, folder), deps.entries)
|
|
170
|
+
: [],
|
|
171
|
+
entryAt: (path) => {
|
|
172
|
+
if (!isInside(path) || path.includes("\0")) return { kind: "absent" };
|
|
173
|
+
const { above, name } = split(path);
|
|
174
|
+
return walk(paper, above, name, deps.entries);
|
|
175
|
+
},
|
|
176
|
+
});
|