paperlint 10.4.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/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/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/node/list-entries.io.d.ts.map +1 -1
- package/dist/adapters/node/list-entries.io.js +3 -1
- package/dist/adapters/node/list-entries.io.js.map +1 -1
- package/dist/domain/decide-first.d.ts +11 -1
- package/dist/domain/decide-first.d.ts.map +1 -1
- package/dist/domain/decide-first.js +6 -0
- package/dist/domain/decide-first.js.map +1 -1
- package/dist/domain/index-columns.d.ts.map +1 -1
- package/dist/domain/index-columns.js +2 -0
- package/dist/domain/index-columns.js.map +1 -1
- package/dist/domain/study-registry.d.ts +65 -8
- package/dist/domain/study-registry.d.ts.map +1 -1
- package/dist/domain/study-registry.js +63 -19
- package/dist/domain/study-registry.js.map +1 -1
- package/dist/outputs-before-decision-rule.d.ts +1 -1
- package/dist/outputs-before-decision-rule.d.ts.map +1 -1
- package/dist/outputs-before-decision-rule.js +21 -17
- package/dist/outputs-before-decision-rule.js.map +1 -1
- package/dist/ports/list-entries.d.ts +7 -4
- package/dist/ports/list-entries.d.ts.map +1 -1
- 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/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.map +1 -1
- package/dist/studies.js +10 -7
- 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 +1 -1
- package/dist/study-gate.d.ts.map +1 -1
- package/dist/study-gate.js +65 -28
- package/dist/study-gate.js.map +1 -1
- package/dist/study-index-input.d.ts +1 -1
- package/dist/study-index-input.d.ts.map +1 -1
- package/dist/study-index-input.js +8 -3
- package/dist/study-index-input.js.map +1 -1
- package/dist/study-modes.d.ts +38 -27
- package/dist/study-modes.d.ts.map +1 -1
- package/dist/study-modes.js +42 -53
- package/dist/study-modes.js.map +1 -1
- package/dist/study-registry-rule.d.ts +2 -6
- package/dist/study-registry-rule.d.ts.map +1 -1
- package/dist/study-registry-rule.js +32 -41
- package/dist/study-registry-rule.js.map +1 -1
- package/docs/design/study-modes.md +46 -28
- package/docs/rules/repro/outputs-before-decision.md +3 -0
- package/docs/rules/repro/study-registered.md +3 -2
- package/docs/rules/tex/heading-case.md +1 -1
- package/eslint.config.mjs +36 -24
- package/package.json +1 -1
- 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/src/CLAUDE.md +7 -4
- package/src/CLAUDE.md.spec.ts +6 -3
- 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/node/list-entries.io.ts +2 -1
- package/src/adapters/node/list-entries.test.ts +18 -0
- package/src/domain/decide-first.test.ts +33 -0
- package/src/domain/decide-first.ts +16 -1
- package/src/domain/index-columns.test.ts +49 -4
- package/src/domain/index-columns.ts +2 -0
- package/src/domain/study-measurements.test.ts +7 -3
- package/src/domain/study-registry.test.ts +34 -6
- package/src/domain/study-registry.ts +123 -31
- package/src/outputs-before-decision-rule.test.ts +47 -2
- package/src/outputs-before-decision-rule.ts +31 -22
- package/src/ports/list-entries.ts +7 -4
- package/src/ports/study-deps.ts +14 -0
- package/src/ports/study-folder.ts +38 -0
- package/src/presets.ts +1 -2
- package/src/studies-index-modes.test.ts +2 -2
- package/src/studies.ts +19 -10
- package/src/study-folder.test.ts +237 -0
- package/src/study-folder.ts +176 -0
- package/src/study-gate.test.ts +158 -7
- package/src/study-gate.ts +127 -54
- package/src/study-index-input.ts +12 -4
- package/src/study-modes.ts +67 -80
- package/src/study-registry-rule.test.ts +87 -0
- package/src/study-registry-rule.ts +58 -66
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** The captions of a document: the text a reader sees under a figure or a table. */
|
|
2
|
+
import { isDefined } from "remeda";
|
|
3
|
+
import { isDefinition, isUnrendered } from "./hidden.ts";
|
|
4
|
+
import {
|
|
5
|
+
collapse,
|
|
6
|
+
isNode,
|
|
7
|
+
mandatory,
|
|
8
|
+
textOf,
|
|
9
|
+
visited,
|
|
10
|
+
type Macro,
|
|
11
|
+
type Node,
|
|
12
|
+
} from "./nodes.ts";
|
|
13
|
+
import type { ParsedTex } from "./parse.ts";
|
|
14
|
+
|
|
15
|
+
const isCaption = (n: Node): n is Macro =>
|
|
16
|
+
n.type === "macro" && n.content === "caption";
|
|
17
|
+
|
|
18
|
+
/** Math a reader sees as symbols: it counts as one word, and says nothing else. */
|
|
19
|
+
const MATH_NODES: ReadonlySet<Node["type"]> = new Set<Node["type"]>([
|
|
20
|
+
"inlinemath",
|
|
21
|
+
"displaymath",
|
|
22
|
+
"mathenv",
|
|
23
|
+
]);
|
|
24
|
+
const MATH_WORD = "math";
|
|
25
|
+
|
|
26
|
+
/** The escapes of a character LaTeX sets for itself: `\%` is a percent sign the reader sees. */
|
|
27
|
+
const ESCAPED: ReadonlySet<string> = new Set(["%", "&", "#", "$", "_"]);
|
|
28
|
+
|
|
29
|
+
/** What a reader sees of a caption's nodes: its words, its escapes, and one word for each formula. */
|
|
30
|
+
const captionText = (nodes: readonly Node[]): string =>
|
|
31
|
+
nodes
|
|
32
|
+
.map((n) => {
|
|
33
|
+
if (MATH_NODES.has(n.type)) return MATH_WORD;
|
|
34
|
+
if (n.type === "macro" && ESCAPED.has(n.content)) return n.content;
|
|
35
|
+
return n.type === "group" ? captionText(n.content) : textOf([n]);
|
|
36
|
+
})
|
|
37
|
+
.join("");
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The text of every `\caption` in document order: its last mandatory argument, whatever optional
|
|
41
|
+
* argument (`\caption[Short]{…}`), star or whitespace comes before it. What a reader never sees is
|
|
42
|
+
* pruned (`isUnrendered`: comments, code), and so is a macro definition's body, where the caption
|
|
43
|
+
* is written but not set. A caption with no word in it is left out.
|
|
44
|
+
*/
|
|
45
|
+
export function captionsOf(t: ParsedTex): readonly string[] {
|
|
46
|
+
return visited(
|
|
47
|
+
t.root,
|
|
48
|
+
(v, parent) => isUnrendered(v, parent) || isDefinition(v),
|
|
49
|
+
)
|
|
50
|
+
.filter(isNode)
|
|
51
|
+
.filter(isCaption)
|
|
52
|
+
.map((m) => mandatory(m).at(-1))
|
|
53
|
+
.filter(isDefined)
|
|
54
|
+
.map((a) => collapse(captionText(a.content)).trim())
|
|
55
|
+
.filter((text) => text !== "");
|
|
56
|
+
}
|
|
@@ -50,6 +50,7 @@ export {
|
|
|
50
50
|
textOf,
|
|
51
51
|
} from "./nodes.ts";
|
|
52
52
|
export { documentBodyOf, includesOf } from "./includes.ts";
|
|
53
|
+
export { captionsOf } from "./captions.ts";
|
|
53
54
|
export { headingsOf } from "./headings.ts";
|
|
54
55
|
export { outlineOf } from "./outline.ts";
|
|
55
56
|
export { parseLatex, type ParsedTex } from "./parse.ts";
|
|
@@ -92,6 +92,12 @@ const DECLARATION_SIGNATURES = Object.fromEntries([
|
|
|
92
92
|
...signed(["linespread", "geometry", "newgeometry"], "m"),
|
|
93
93
|
]);
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* `\caption` takes a star, an optional short caption and the text (`\caption*[Short]{Text}`). unified-latex
|
|
97
|
+
* signs it `o m`, so a starred caption came back with its star as the text and the text as a group beside it.
|
|
98
|
+
*/
|
|
99
|
+
const CAPTION_SIGNATURES = Object.fromEntries(signed(["caption"], "s o m"));
|
|
100
|
+
|
|
95
101
|
/**
|
|
96
102
|
* Macros that define other macros or environments. A heading in a body is no section where it is
|
|
97
103
|
* written, so the outline skips these; the rendered text reads a body as written, since nothing here
|
|
@@ -133,6 +139,7 @@ const parser = once(() =>
|
|
|
133
139
|
...LINK_SIGNATURES,
|
|
134
140
|
...DECLARATION_SIGNATURES,
|
|
135
141
|
...DEF_SIGNATURES,
|
|
142
|
+
...CAPTION_SIGNATURES,
|
|
136
143
|
},
|
|
137
144
|
}),
|
|
138
145
|
);
|
|
@@ -7,7 +7,8 @@ import type { DirEntry, ListEntries } from "../../ports/list-entries.ts";
|
|
|
7
7
|
/** What a directory entry is, its link never followed. */
|
|
8
8
|
const kindOf = (d: Readonly<Dirent>): DirEntry["kind"] => {
|
|
9
9
|
if (d.isSymbolicLink()) return "symlink";
|
|
10
|
-
|
|
10
|
+
if (d.isDirectory()) return "dir";
|
|
11
|
+
return d.isFile() ? "file" : "other";
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
export const nodeListEntries: ListEntries = (
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* `nodeListEntries`: a directory's entries with their kinds, a symbolic link reported as one and never
|
|
3
3
|
* followed; none for a missing directory or a file; other errors thrown.
|
|
4
4
|
*/
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
5
6
|
import { mkdirSync, symlinkSync, writeFileSync } from "node:fs";
|
|
6
7
|
import { join } from "node:path";
|
|
7
8
|
import { describe, expect, it } from "vitest";
|
|
@@ -29,6 +30,23 @@ describe("nodeListEntries", () => {
|
|
|
29
30
|
]);
|
|
30
31
|
});
|
|
31
32
|
|
|
33
|
+
// A FIFO needs `mkfifo`, which Windows does not have; the reason is said, not hidden.
|
|
34
|
+
it.skipIf(process.platform === "win32")(
|
|
35
|
+
"names a FIFO as neither a file nor a directory: other",
|
|
36
|
+
() => {
|
|
37
|
+
const d = join(root, "fifo");
|
|
38
|
+
mkdirSync(d, { recursive: true });
|
|
39
|
+
const made = spawnSync("mkfifo", [join(d, "pipe")]);
|
|
40
|
+
expect(made.status).toBe(0);
|
|
41
|
+
writeFileSync(join(d, "a.md"), "");
|
|
42
|
+
// Guards: it was reported as a file, so a reader would open it and block.
|
|
43
|
+
expect(sorted(d)).toEqual([
|
|
44
|
+
{ name: "a.md", kind: "file" },
|
|
45
|
+
{ name: "pipe", kind: "other" },
|
|
46
|
+
]);
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
|
|
32
50
|
it("is empty for a directory that does not exist, and for a file", () => {
|
|
33
51
|
writeFileSync(join(root, "f"), "");
|
|
34
52
|
expect([
|
|
@@ -180,6 +180,23 @@ it("a decision that names a directory is reported, and no history is judged", ()
|
|
|
180
180
|
]);
|
|
181
181
|
});
|
|
182
182
|
|
|
183
|
+
it("a decision behind a linked directory is reported with the link, and no history is judged", () => {
|
|
184
|
+
// Guards: git records a path through a link as what lies behind the link.
|
|
185
|
+
expect(
|
|
186
|
+
judgeOrder({
|
|
187
|
+
...STUDY,
|
|
188
|
+
facts: { kind: "linked", link: "repro/P1-S01-x/sub" },
|
|
189
|
+
}),
|
|
190
|
+
).toEqual([
|
|
191
|
+
{
|
|
192
|
+
problem: "decision-linked",
|
|
193
|
+
id: "P1-S01",
|
|
194
|
+
file: "repro/P1-S01-x/decision.md",
|
|
195
|
+
link: "repro/P1-S01-x/sub",
|
|
196
|
+
},
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
|
|
183
200
|
const PAGE =
|
|
184
201
|
"\nRule repro/outputs-before-decision: https://github.com/zernie/paperlint/blob/main/docs/rules/repro/outputs-before-decision.md";
|
|
185
202
|
|
|
@@ -192,6 +209,12 @@ const ALL: readonly OrderFinding[] = [
|
|
|
192
209
|
glob: "**/*.md",
|
|
193
210
|
},
|
|
194
211
|
{ problem: "decision-deleted", id: "P1-S01", file: "repro/d.md" },
|
|
212
|
+
{
|
|
213
|
+
problem: "decision-not-file",
|
|
214
|
+
id: "P1-S01",
|
|
215
|
+
file: "repro/p",
|
|
216
|
+
what: "special file",
|
|
217
|
+
},
|
|
195
218
|
{
|
|
196
219
|
problem: "decision-not-file",
|
|
197
220
|
id: "P1-S01",
|
|
@@ -204,6 +227,12 @@ const ALL: readonly OrderFinding[] = [
|
|
|
204
227
|
file: "repro/l.md",
|
|
205
228
|
what: "symbolic link",
|
|
206
229
|
},
|
|
230
|
+
{
|
|
231
|
+
problem: "decision-linked",
|
|
232
|
+
id: "P1-S01",
|
|
233
|
+
file: "repro/sub/d.md",
|
|
234
|
+
link: "repro/sub",
|
|
235
|
+
},
|
|
207
236
|
{ problem: "no-history", id: "P1-S01", why: "git is not installed" },
|
|
208
237
|
{ problem: "shallow", id: "P1-S01" },
|
|
209
238
|
{
|
|
@@ -240,10 +269,14 @@ it("each finding's message: what is wrong, what to do, the rule's page", () => {
|
|
|
240
269
|
PAGE,
|
|
241
270
|
"The decision of `P1-S01`, `repro/d.md`, was committed and then deleted: it is not in HEAD, so the study has no decision its outputs could follow. What to do: restore the decision file as it was decided and commit it — never write a new one after the outputs; if the decision no longer holds, declare the study `exploratory` with the reason." +
|
|
242
271
|
PAGE,
|
|
272
|
+
"The decision of `P1-S01`, `repro/p`, is a special file, not a file: git tracks only regular files, so no order can be read from it. What to do: name the decision file itself in `mode.decision.file`." +
|
|
273
|
+
PAGE,
|
|
243
274
|
"The decision of `P1-S01`, `repro/d`, is a directory, not a file: git answers for a directory with the commits of anything under it, so no order can be read from it. What to do: name the decision file itself in `mode.decision.file`." +
|
|
244
275
|
PAGE,
|
|
245
276
|
"The decision of `P1-S01`, `repro/l.md`, is a symbolic link, not a file: git records the link, not the file it points to, so no order can be read from it. What to do: name the decision file itself in `mode.decision.file`." +
|
|
246
277
|
PAGE,
|
|
278
|
+
"The decision of `P1-S01`, `repro/sub/d.md`, passes through the symbolic link `repro/sub`: git records the link, not the files behind it, so no order can be read from it. What to do: name a decision file that lies in real directories in `mode.decision.file`." +
|
|
279
|
+
PAGE,
|
|
247
280
|
"`P1-S01` is decided before its run, and git's history is the proof of that order, but git could not answer: git is not installed. What to do: lint the paper inside its git repository, with git installed." +
|
|
248
281
|
PAGE,
|
|
249
282
|
"`P1-S01` is decided before its run, and this clone is shallow: every file's first commit is the clone's first, so the order of decision and outputs cannot be read. What to do: fetch the whole history (`git fetch --unshallow`; in GitHub Actions, `actions/checkout` with `fetch-depth: 0`)." +
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
} from "./study-mode.ts";
|
|
24
24
|
|
|
25
25
|
/** What a decision path names on disk when it is no file git can vouch for. */
|
|
26
|
-
export type NotAFile = "directory" | "symbolic link";
|
|
26
|
+
export type NotAFile = "directory" | "symbolic link" | "special file";
|
|
27
27
|
|
|
28
28
|
/** A commit, and its committer date (ISO 8601 with its offset). */
|
|
29
29
|
export interface CommitAt {
|
|
@@ -54,6 +54,8 @@ export interface FirstCommit extends CommitAt {
|
|
|
54
54
|
*/
|
|
55
55
|
export type OrderFacts =
|
|
56
56
|
| { readonly kind: "not-a-file"; readonly what: NotAFile }
|
|
57
|
+
/** The decision's path passes through a symbolic link above the file (`link`: the first, relative to the paper). */
|
|
58
|
+
| { readonly kind: "linked"; readonly link: string }
|
|
57
59
|
| { readonly kind: "unknown"; readonly why: string }
|
|
58
60
|
/** A shallow clone: every file's first commit is the clone's first, so no order can be read. */
|
|
59
61
|
| { readonly kind: "shallow" }
|
|
@@ -97,6 +99,12 @@ export type OrderFinding =
|
|
|
97
99
|
readonly file: string;
|
|
98
100
|
readonly what: NotAFile;
|
|
99
101
|
}
|
|
102
|
+
| {
|
|
103
|
+
readonly problem: "decision-linked";
|
|
104
|
+
readonly id: string;
|
|
105
|
+
readonly file: string;
|
|
106
|
+
readonly link: string;
|
|
107
|
+
}
|
|
100
108
|
| {
|
|
101
109
|
readonly problem: "no-history";
|
|
102
110
|
readonly id: string;
|
|
@@ -188,6 +196,10 @@ const historyFindings = (s: DecideFirstStudy): readonly OrderFinding[] => {
|
|
|
188
196
|
return [
|
|
189
197
|
{ problem: "decision-not-file", id: s.id, file, what: s.facts.what },
|
|
190
198
|
];
|
|
199
|
+
case "linked":
|
|
200
|
+
return [
|
|
201
|
+
{ problem: "decision-linked", id: s.id, file, link: s.facts.link },
|
|
202
|
+
];
|
|
191
203
|
case "unknown":
|
|
192
204
|
return [{ problem: "no-history", id: s.id, why: s.facts.why }];
|
|
193
205
|
case "shallow":
|
|
@@ -212,6 +224,7 @@ const NOT_A_FILE: Readonly<Record<NotAFile, string>> = {
|
|
|
212
224
|
directory:
|
|
213
225
|
"git answers for a directory with the commits of anything under it",
|
|
214
226
|
"symbolic link": "git records the link, not the file it points to",
|
|
227
|
+
"special file": "git tracks only regular files",
|
|
215
228
|
};
|
|
216
229
|
|
|
217
230
|
const short = (c: CommitAt): string => `${c.commit.slice(0, 12)} (${c.date})`;
|
|
@@ -225,6 +238,8 @@ const HEADS: {
|
|
|
225
238
|
`The decision of \`${f.id}\`, \`${f.file}\`, was committed and then deleted: it is not in HEAD, so the study has no decision its outputs could follow. What to do: restore the decision file as it was decided and commit it — never write a new one after the outputs; if the decision no longer holds, declare the study \`exploratory\` with the reason.`,
|
|
226
239
|
"decision-not-file": (f) =>
|
|
227
240
|
`The decision of \`${f.id}\`, \`${f.file}\`, is a ${f.what}, not a file: ${NOT_A_FILE[f.what]}, so no order can be read from it. What to do: name the decision file itself in \`mode.decision.file\`.`,
|
|
241
|
+
"decision-linked": (f) =>
|
|
242
|
+
`The decision of \`${f.id}\`, \`${f.file}\`, passes through the symbolic link \`${f.link}\`: git records the link, not the files behind it, so no order can be read from it. What to do: name a decision file that lies in real directories in \`mode.decision.file\`.`,
|
|
228
243
|
"no-history": (f) =>
|
|
229
244
|
`\`${f.id}\` is decided before its run, and git's history is the proof of that order, but git could not answer: ${f.why}. What to do: lint the paper inside its git repository, with git installed.`,
|
|
230
245
|
shallow: (f) =>
|
|
@@ -82,11 +82,11 @@ const NINE_AT: IndexInput = {
|
|
|
82
82
|
]),
|
|
83
83
|
history: new Map([
|
|
84
84
|
[
|
|
85
|
-
"P1-S01",
|
|
85
|
+
"P1-S01-hint/plan.json",
|
|
86
86
|
{ kind: "read", decision: D, decisionDeleted: false, firstOutput: A },
|
|
87
87
|
],
|
|
88
88
|
[
|
|
89
|
-
"P1-S02",
|
|
89
|
+
"P1-S02-s/plan.json",
|
|
90
90
|
{
|
|
91
91
|
kind: "read",
|
|
92
92
|
decision: null,
|
|
@@ -94,7 +94,7 @@ const NINE_AT: IndexInput = {
|
|
|
94
94
|
firstOutput: null,
|
|
95
95
|
},
|
|
96
96
|
],
|
|
97
|
-
["P1-S04", { kind: "unread", why: "shallow clone" }],
|
|
97
|
+
["P1-S04-s/plan.json", { kind: "unread", why: "shallow clone" }],
|
|
98
98
|
]),
|
|
99
99
|
};
|
|
100
100
|
|
|
@@ -124,7 +124,7 @@ describe("the mode columns of the index", () => {
|
|
|
124
124
|
plans,
|
|
125
125
|
history: new Map([
|
|
126
126
|
[
|
|
127
|
-
"P1-S01",
|
|
127
|
+
"P1-S01-s/plan.json",
|
|
128
128
|
{
|
|
129
129
|
kind: "read",
|
|
130
130
|
decision: D,
|
|
@@ -151,6 +151,49 @@ describe("the mode columns of the index", () => {
|
|
|
151
151
|
});
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
it("two rows of one id each show their own history, keyed by folder and plan", () => {
|
|
155
|
+
const r: StudyRegistry = {
|
|
156
|
+
prefix: "P1",
|
|
157
|
+
studies: [row(1, { folder: "P1-S01-a" }), row(1, { folder: "P1-S01-b" })],
|
|
158
|
+
};
|
|
159
|
+
const at: IndexInput = {
|
|
160
|
+
plans: new Map([
|
|
161
|
+
["P1-S01-a/plan.json", plan(DECIDED)],
|
|
162
|
+
["P1-S01-b/plan.json", plan(DECIDED)],
|
|
163
|
+
]),
|
|
164
|
+
history: new Map([
|
|
165
|
+
[
|
|
166
|
+
"P1-S01-a/plan.json",
|
|
167
|
+
{ kind: "read", decision: D, decisionDeleted: false, firstOutput: A },
|
|
168
|
+
],
|
|
169
|
+
[
|
|
170
|
+
"P1-S01-b/plan.json",
|
|
171
|
+
{
|
|
172
|
+
kind: "read",
|
|
173
|
+
decision: null,
|
|
174
|
+
decisionDeleted: false,
|
|
175
|
+
firstOutput: null,
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
]),
|
|
179
|
+
};
|
|
180
|
+
// Guards: keyed by id, the second row's history was shown on both rows (the later one wins).
|
|
181
|
+
expect(
|
|
182
|
+
studyIndex(r, at)
|
|
183
|
+
.split("\n")
|
|
184
|
+
.slice(2)
|
|
185
|
+
.map((l) =>
|
|
186
|
+
l
|
|
187
|
+
.split("|")
|
|
188
|
+
.slice(6, 9)
|
|
189
|
+
.map((c) => c.trim()),
|
|
190
|
+
),
|
|
191
|
+
).toEqual([
|
|
192
|
+
["decide_before_run", "dddddddddddd 2026-10-14", "aaaaaaaaaaaa 2026-10-16"],
|
|
193
|
+
["decide_before_run", "not committed", "none"],
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
|
|
154
197
|
it("a decision committed and since deleted shows its commit, and that it is gone", () => {
|
|
155
198
|
// Guards: the Methods sentence must not cite a decision that no longer stands.
|
|
156
199
|
expect(
|
|
@@ -181,6 +224,7 @@ describe("indexHistory", () => {
|
|
|
181
224
|
indexHistory(study({ kind: "unknown", why: "not a repository" })),
|
|
182
225
|
indexHistory(study({ kind: "shallow" })),
|
|
183
226
|
indexHistory(study({ kind: "not-a-file", what: "directory" })),
|
|
227
|
+
indexHistory(study({ kind: "linked", link: "repro/sub" })),
|
|
184
228
|
indexHistory(
|
|
185
229
|
study({
|
|
186
230
|
kind: "read",
|
|
@@ -204,6 +248,7 @@ describe("indexHistory", () => {
|
|
|
204
248
|
{ kind: "unread", why: "git could not answer" },
|
|
205
249
|
{ kind: "unread", why: "shallow clone" },
|
|
206
250
|
{ kind: "unread", why: "the decision is a directory" },
|
|
251
|
+
{ kind: "unread", why: "the decision is behind a symbolic link" },
|
|
207
252
|
{ kind: "read", decision: D, decisionDeleted: false, firstOutput: A },
|
|
208
253
|
{
|
|
209
254
|
kind: "read",
|
|
@@ -85,6 +85,8 @@ export const indexHistory = (s: DecideFirstStudy): IndexHistory => {
|
|
|
85
85
|
switch (s.facts.kind) {
|
|
86
86
|
case "not-a-file":
|
|
87
87
|
return { kind: "unread", why: `the decision is a ${s.facts.what}` };
|
|
88
|
+
case "linked":
|
|
89
|
+
return { kind: "unread", why: "the decision is behind a symbolic link" };
|
|
88
90
|
case "unknown":
|
|
89
91
|
return { kind: "unread", why: "git could not answer" };
|
|
90
92
|
case "shallow":
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type StudyPlan,
|
|
23
23
|
type StudyRegistry,
|
|
24
24
|
type IndexInput,
|
|
25
|
+
folderKey,
|
|
25
26
|
} from "./study-registry.ts";
|
|
26
27
|
|
|
27
28
|
/** No plan and no history: the index with every mode column `—`. */
|
|
@@ -110,10 +111,10 @@ const input = (
|
|
|
110
111
|
folders: r.studies.map((s) => s.folder),
|
|
111
112
|
registry: { kind: "read", registry: r },
|
|
112
113
|
readme: withIndex("# Reproduction index\n", r, NO_FACTS),
|
|
113
|
-
|
|
114
|
+
results: new Map(),
|
|
114
115
|
plans: new Map(
|
|
115
116
|
r.studies.flatMap((s) =>
|
|
116
|
-
s.plan === undefined ? [] : [[
|
|
117
|
+
s.plan === undefined ? [] : [[folderKey(s.folder, s.plan), read(PLAN)]],
|
|
117
118
|
),
|
|
118
119
|
),
|
|
119
120
|
...over,
|
|
@@ -385,7 +386,10 @@ describe("plans — the parser decides ready or draft", () => {
|
|
|
385
386
|
});
|
|
386
387
|
const plans = new Map([[`P1-S01-census/${PLAN_FILE}`, read(PLAN)]]);
|
|
387
388
|
expect(
|
|
388
|
-
findings(r, {
|
|
389
|
+
findings(r, {
|
|
390
|
+
plans,
|
|
391
|
+
results: new Map([["P1-S01-census/r.tsv", { kind: "file" as const }]]),
|
|
392
|
+
}),
|
|
389
393
|
).toEqual([]);
|
|
390
394
|
});
|
|
391
395
|
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
studyIndex,
|
|
20
20
|
studyMessage,
|
|
21
21
|
studyReadme,
|
|
22
|
+
studyById,
|
|
22
23
|
studySlug,
|
|
23
24
|
withIndex,
|
|
24
25
|
type RegistryState,
|
|
@@ -61,7 +62,7 @@ const input = (
|
|
|
61
62
|
folders: r.studies.map((s) => s.folder),
|
|
62
63
|
registry: read(r),
|
|
63
64
|
readme: withIndex("# Reproduction index\n", r, NO_FACTS),
|
|
64
|
-
|
|
65
|
+
results: new Map(),
|
|
65
66
|
plans: new Map(),
|
|
66
67
|
...over,
|
|
67
68
|
});
|
|
@@ -88,7 +89,9 @@ describe("judgeStudies — silent", () => {
|
|
|
88
89
|
expect(
|
|
89
90
|
judgeStudies(
|
|
90
91
|
input(r, {
|
|
91
|
-
|
|
92
|
+
results: new Map([
|
|
93
|
+
["P1-S02-ablation/out/results.tsv", { kind: "file" as const }],
|
|
94
|
+
]),
|
|
92
95
|
}),
|
|
93
96
|
),
|
|
94
97
|
).toEqual([]);
|
|
@@ -100,7 +103,7 @@ describe("judgeStudies — silent", () => {
|
|
|
100
103
|
folders: [],
|
|
101
104
|
registry: { kind: "absent" },
|
|
102
105
|
readme: null,
|
|
103
|
-
|
|
106
|
+
results: new Map(),
|
|
104
107
|
plans: new Map(),
|
|
105
108
|
}),
|
|
106
109
|
).toEqual([]);
|
|
@@ -129,7 +132,7 @@ describe("judgeStudies — the registry itself", () => {
|
|
|
129
132
|
folders: ["a-2026-10-01", "b-2026-10-02"],
|
|
130
133
|
registry: { kind: "absent" },
|
|
131
134
|
readme: null,
|
|
132
|
-
|
|
135
|
+
results: new Map(),
|
|
133
136
|
plans: new Map(),
|
|
134
137
|
}),
|
|
135
138
|
).toEqual([
|
|
@@ -143,7 +146,7 @@ describe("judgeStudies — the registry itself", () => {
|
|
|
143
146
|
folders: ["a-2026-10-01"],
|
|
144
147
|
registry: { kind: "unreadable", why: "prefix: required" },
|
|
145
148
|
readme: null,
|
|
146
|
-
|
|
149
|
+
results: new Map(),
|
|
147
150
|
plans: new Map(),
|
|
148
151
|
}),
|
|
149
152
|
).toEqual([{ problem: "unreadable", why: "prefix: required" }]);
|
|
@@ -259,7 +262,11 @@ describe("judgeStudies — status", () => {
|
|
|
259
262
|
const r = reg(study({ status: "running", result_file: "r.tsv" }));
|
|
260
263
|
expect(
|
|
261
264
|
judgeStudies(
|
|
262
|
-
input(r, {
|
|
265
|
+
input(r, {
|
|
266
|
+
results: new Map([
|
|
267
|
+
["P1-S01-baseline/r.tsv", { kind: "file" as const }],
|
|
268
|
+
]),
|
|
269
|
+
}),
|
|
263
270
|
),
|
|
264
271
|
).toEqual([]);
|
|
265
272
|
});
|
|
@@ -671,3 +678,24 @@ describe("studyReadme", () => {
|
|
|
671
678
|
);
|
|
672
679
|
});
|
|
673
680
|
});
|
|
681
|
+
|
|
682
|
+
describe("studyById — the one way to address a study by its id", () => {
|
|
683
|
+
const a = study({ id: "P1-S01", folder: "P1-S01-a" });
|
|
684
|
+
const b = study({ id: "P1-S01", folder: "P1-S01-b" });
|
|
685
|
+
const c = study({ id: "P1-S02", folder: "P1-S02-c" });
|
|
686
|
+
|
|
687
|
+
it("exactly one row: that row; no row: none; two or more: all of them, in registry order", () => {
|
|
688
|
+
// Guards: a caller can take a row only from `one`, never "the first" of an ambiguous id.
|
|
689
|
+
expect([
|
|
690
|
+
studyById([a, c], "P1-S01"),
|
|
691
|
+
studyById([a, c], "P1-S09"),
|
|
692
|
+
studyById([], "P1-S01"),
|
|
693
|
+
studyById([a, c, b], "P1-S01"),
|
|
694
|
+
]).toEqual([
|
|
695
|
+
{ kind: "one", row: a },
|
|
696
|
+
{ kind: "none" },
|
|
697
|
+
{ kind: "none" },
|
|
698
|
+
{ kind: "ambiguous", rows: [a, b] },
|
|
699
|
+
]);
|
|
700
|
+
});
|
|
701
|
+
});
|