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,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `paperlint studies index` and `studies new` generate the index from beyond the registry: each
|
|
3
|
+
* row's plan, and each decide-first study's history as git gives it now (src/domain/index-columns.ts).
|
|
4
|
+
*/
|
|
5
|
+
import { indexHistory } from "./domain/index-columns.ts";
|
|
6
|
+
import {
|
|
7
|
+
folderKey,
|
|
8
|
+
type IndexInput,
|
|
9
|
+
type RegistryState,
|
|
10
|
+
} from "./domain/study-registry.ts";
|
|
11
|
+
import {
|
|
12
|
+
decideFirstStudies,
|
|
13
|
+
type OrderDeps,
|
|
14
|
+
} from "./outputs-before-decision-rule.ts";
|
|
15
|
+
import { studyFolder } from "./study-folder.ts";
|
|
16
|
+
import { plansOf } from "./study-registry-rule.ts";
|
|
17
|
+
|
|
18
|
+
/** The plans and the histories of the paper folder `paper`, whose registry is `r`. */
|
|
19
|
+
export const indexInputOf = (
|
|
20
|
+
paper: string,
|
|
21
|
+
r: RegistryState,
|
|
22
|
+
deps: OrderDeps,
|
|
23
|
+
): IndexInput => ({
|
|
24
|
+
plans: plansOf(r, studyFolder(paper, deps), deps.codec),
|
|
25
|
+
history: new Map(
|
|
26
|
+
decideFirstStudies(paper, deps).map((s) => [
|
|
27
|
+
folderKey(s.folder, s.plan),
|
|
28
|
+
indexHistory(s),
|
|
29
|
+
]),
|
|
30
|
+
),
|
|
31
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a paper's studies with their modes: each registered study whose `plan.json` parses with a
|
|
3
|
+
* `mode`, and how a decision's path stands. What `repro/outputs-before-decision`, `studies gate`
|
|
4
|
+
* and the index's mode columns read with (docs/design/study-modes.md). A row whose folder is not one
|
|
5
|
+
* folder inside `repro/` is never read through.
|
|
6
|
+
*/
|
|
7
|
+
import { posix } from "node:path";
|
|
8
|
+
import { filter, map, pipe } from "remeda";
|
|
9
|
+
import type { NotAFile } from "./domain/decide-first.ts";
|
|
10
|
+
import type { StudyMode } from "./domain/study-mode.ts";
|
|
11
|
+
import {
|
|
12
|
+
studiesOf,
|
|
13
|
+
type PlanTextField,
|
|
14
|
+
type RegistryState,
|
|
15
|
+
type Study,
|
|
16
|
+
} from "./domain/study-registry.ts";
|
|
17
|
+
import type { StudyRegistryCodec } from "./ports/study-registry-codec.ts";
|
|
18
|
+
import type { StudyFolder } from "./ports/study-folder.ts";
|
|
19
|
+
|
|
20
|
+
/** Whether a decision path is one git can vouch for: a file or nothing yet; else why not. */
|
|
21
|
+
export type DecisionEntry =
|
|
22
|
+
| { readonly kind: "ok" }
|
|
23
|
+
| { readonly kind: "not-a-file"; readonly what: NotAFile }
|
|
24
|
+
| { readonly kind: "linked"; readonly link: string };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What the decision file `file` (relative to the paper) is, read through `folder` without following
|
|
28
|
+
* a link: a decision must be a regular file in real directories — git answers for a directory with
|
|
29
|
+
* the commits of anything under it, for a link with the link and not the file it points to, and for a
|
|
30
|
+
* path through a linked directory with what lies behind the link.
|
|
31
|
+
*/
|
|
32
|
+
export const decisionEntry = (
|
|
33
|
+
folder: StudyFolder,
|
|
34
|
+
file: string,
|
|
35
|
+
): DecisionEntry => {
|
|
36
|
+
const at = folder.entryAt(file);
|
|
37
|
+
if (at.kind === "dir") return { kind: "not-a-file", what: "directory" };
|
|
38
|
+
if (at.kind === "other") return { kind: "not-a-file", what: "special file" };
|
|
39
|
+
if (at.kind !== "linked") return { kind: "ok" };
|
|
40
|
+
return at.link === posix.normalize(file)
|
|
41
|
+
? { kind: "not-a-file", what: "symbolic link" }
|
|
42
|
+
: at;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A study whose plan parsed with a mode: its row, the plan's path in its folder, the mode, and the
|
|
47
|
+
* plan's empty texts — none when it is ready. Only a ready plan's mode decides anything: a draft may
|
|
48
|
+
* name no outputs, or an empty decision file.
|
|
49
|
+
*/
|
|
50
|
+
export interface ModeStudy {
|
|
51
|
+
readonly row: Study;
|
|
52
|
+
readonly plan: string;
|
|
53
|
+
readonly mode: StudyMode;
|
|
54
|
+
readonly empty: readonly PlanTextField[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What reading the plan of one row gave: its mode study, nothing, or a link on the way to it. */
|
|
58
|
+
export type ModeRead =
|
|
59
|
+
| { readonly kind: "mode"; readonly study: ModeStudy }
|
|
60
|
+
| { readonly kind: "none" }
|
|
61
|
+
| { readonly kind: "linked"; readonly link: string };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The plan of the row `s`, read through `folder` (`src/study-folder.ts`, which follows no link and
|
|
65
|
+
* enters no folder that is not one inside `repro/`): its mode study when the plan parsed with a
|
|
66
|
+
* mode; a link on the way to it; else nothing.
|
|
67
|
+
*/
|
|
68
|
+
export const readModeStudy = (
|
|
69
|
+
s: Study,
|
|
70
|
+
folder: StudyFolder,
|
|
71
|
+
codec: StudyRegistryCodec,
|
|
72
|
+
): ModeRead => {
|
|
73
|
+
const plan = s.plan;
|
|
74
|
+
if (plan === undefined) return { kind: "none" };
|
|
75
|
+
const read = folder.read(s.folder, plan);
|
|
76
|
+
if (read.kind === "linked") return read;
|
|
77
|
+
if (read.kind === "absent") return { kind: "none" };
|
|
78
|
+
const parsed = codec.parsePlan(read.text, s.kind);
|
|
79
|
+
if (parsed.kind === "unreadable" || parsed.plan.mode === undefined)
|
|
80
|
+
return { kind: "none" };
|
|
81
|
+
const empty = parsed.kind === "draft" ? parsed.empty : [];
|
|
82
|
+
return {
|
|
83
|
+
kind: "mode",
|
|
84
|
+
study: { row: s, plan, mode: parsed.plan.mode, empty },
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Every study of the registry `r` whose plan declares a mode, in registry order. A plan that does
|
|
90
|
+
* not parse, or is reached through a link, is `repro/study-registered`'s to report; here it declares
|
|
91
|
+
* nothing.
|
|
92
|
+
*/
|
|
93
|
+
export const modeStudies = (
|
|
94
|
+
r: RegistryState,
|
|
95
|
+
folder: StudyFolder,
|
|
96
|
+
codec: StudyRegistryCodec,
|
|
97
|
+
): readonly ModeStudy[] =>
|
|
98
|
+
pipe(
|
|
99
|
+
studiesOf(r),
|
|
100
|
+
map((s) => readModeStudy(s, folder, codec)),
|
|
101
|
+
filter(isMode),
|
|
102
|
+
map((m) => m.study),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
/** A plan read that declared a mode. */
|
|
106
|
+
const isMode = (m: ModeRead): m is Extract<ModeRead, { kind: "mode" }> =>
|
|
107
|
+
m.kind === "mode";
|
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
* on the first line of `PIPELINE-STATUS.md`, with the domain's text.
|
|
7
7
|
*/
|
|
8
8
|
import { describe, expect, it } from "vitest";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
memoryFiles,
|
|
11
|
+
memoryListDir,
|
|
12
|
+
memoryListEntries,
|
|
13
|
+
} from "./adapters/memory/index.ts";
|
|
10
14
|
import { studyRegistryCodec } from "./adapters/study-registry/index.ts";
|
|
11
15
|
import {
|
|
12
16
|
planTemplate,
|
|
@@ -15,8 +19,11 @@ import {
|
|
|
15
19
|
studyMessage,
|
|
16
20
|
withIndex,
|
|
17
21
|
type Study,
|
|
22
|
+
type StudyPlan,
|
|
18
23
|
type StudyRegistry,
|
|
24
|
+
type IndexInput,
|
|
19
25
|
} from "./domain/study-registry.ts";
|
|
26
|
+
import { planOf } from "../test/study-tree.ts";
|
|
20
27
|
import {
|
|
21
28
|
registryOf,
|
|
22
29
|
STUDY_REGISTRY_RULE_LEVELS,
|
|
@@ -24,6 +31,9 @@ import {
|
|
|
24
31
|
studyRegistryRules,
|
|
25
32
|
} from "./study-registry-rule.ts";
|
|
26
33
|
|
|
34
|
+
/** No plan and no history: the index with every mode column `—`. */
|
|
35
|
+
const NO_FACTS: IndexInput = { plans: new Map(), history: new Map() };
|
|
36
|
+
|
|
27
37
|
const DIR = "/work/papers/example";
|
|
28
38
|
const SCAFFOLD = {
|
|
29
39
|
[`${DIR}/PIPELINE-STATUS.md`]: "# status\n",
|
|
@@ -45,7 +55,7 @@ const study = (over: Partial<Study> = {}): Study => ({
|
|
|
45
55
|
/** A registered `repro/`: the registry, its index, and a README.md in each row's folder. */
|
|
46
56
|
const registered = (
|
|
47
57
|
r: StudyRegistry,
|
|
48
|
-
readme: string | null = withIndex("# Reproduction index\n", r),
|
|
58
|
+
readme: string | null = withIndex("# Reproduction index\n", r, NO_FACTS),
|
|
49
59
|
): Record<string, string> => ({
|
|
50
60
|
...SCAFFOLD,
|
|
51
61
|
[`${DIR}/repro/studies.json`]: registryText(r),
|
|
@@ -73,6 +83,7 @@ function lint(
|
|
|
73
83
|
const rule = studyRegistryRules({
|
|
74
84
|
files,
|
|
75
85
|
listDir: memoryListDir(files),
|
|
86
|
+
entries: memoryListEntries(files),
|
|
76
87
|
codec: studyRegistryCodec,
|
|
77
88
|
})[name];
|
|
78
89
|
const out: Reported[] = [];
|
|
@@ -222,9 +233,9 @@ describe("repro/study-registered — reports a row's status and the index", () =
|
|
|
222
233
|
|
|
223
234
|
it("a stale index", () => {
|
|
224
235
|
const r = P1(study());
|
|
225
|
-
expect(messages(registered(r, withIndex("# x\n", P1())))).toEqual(
|
|
226
|
-
studyMessage({ problem: "stale-index", why: "differs" }),
|
|
227
|
-
|
|
236
|
+
expect(messages(registered(r, withIndex("# x\n", P1(), NO_FACTS)))).toEqual(
|
|
237
|
+
[studyMessage({ problem: "stale-index", why: "differs" })],
|
|
238
|
+
);
|
|
228
239
|
});
|
|
229
240
|
});
|
|
230
241
|
|
|
@@ -300,7 +311,7 @@ describe("repro/study-registered — a result_file the disk cannot hold", () =>
|
|
|
300
311
|
const files = memoryFiles({
|
|
301
312
|
...SCAFFOLD,
|
|
302
313
|
[`${DIR}/repro/studies.json`]: registryText(r),
|
|
303
|
-
[`${DIR}/repro/README.md`]: withIndex("# index\n", r),
|
|
314
|
+
[`${DIR}/repro/README.md`]: withIndex("# index\n", r, NO_FACTS),
|
|
304
315
|
[`${DIR}/repro/P1-S01-baseline/README.md`]: "# s\n",
|
|
305
316
|
});
|
|
306
317
|
const probe = {
|
|
@@ -314,14 +325,187 @@ describe("repro/study-registered — a result_file the disk cannot hold", () =>
|
|
|
314
325
|
studyFindings(DIR, {
|
|
315
326
|
files: probe,
|
|
316
327
|
listDir: memoryListDir(files),
|
|
328
|
+
entries: memoryListEntries(files),
|
|
317
329
|
codec: studyRegistryCodec,
|
|
318
330
|
}).map((f) => f.problem),
|
|
319
331
|
).toEqual(["result-file"]);
|
|
320
332
|
});
|
|
321
333
|
});
|
|
322
334
|
|
|
335
|
+
describe("repro/study-registered — a study folder that is a symbolic link", () => {
|
|
336
|
+
it("never reads a plan or a result through it", () => {
|
|
337
|
+
const r = P1(
|
|
338
|
+
study({
|
|
339
|
+
status: "done",
|
|
340
|
+
result: "41 of 60",
|
|
341
|
+
result_file: "r.txt",
|
|
342
|
+
plan: "plan.json",
|
|
343
|
+
}),
|
|
344
|
+
);
|
|
345
|
+
const files = memoryFiles({
|
|
346
|
+
...registered(r),
|
|
347
|
+
[`${DIR}/repro/P1-S01-baseline/plan.json`]: "not JSON",
|
|
348
|
+
[`${DIR}/repro/P1-S01-baseline/r.txt`]: "41 of 60\n",
|
|
349
|
+
});
|
|
350
|
+
const found = studyFindings(DIR, {
|
|
351
|
+
files,
|
|
352
|
+
listDir: memoryListDir(files),
|
|
353
|
+
entries: (dir) =>
|
|
354
|
+
memoryListEntries(files)(dir).map((e) =>
|
|
355
|
+
e.name === "P1-S01-baseline" ? { ...e, kind: "symlink" as const } : e,
|
|
356
|
+
),
|
|
357
|
+
codec: studyRegistryCodec,
|
|
358
|
+
});
|
|
359
|
+
// Guards: the files behind the link are not read — the plan and the result read as missing.
|
|
360
|
+
expect(
|
|
361
|
+
found.map((f) =>
|
|
362
|
+
f.problem === "bad-plan" || f.problem === "result-file"
|
|
363
|
+
? `${f.problem}:${f.why}`
|
|
364
|
+
: f.problem,
|
|
365
|
+
),
|
|
366
|
+
).toEqual(["result-file:missing", "bad-plan:missing"]);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
describe("repro/study-registered — a plan or a result file reached through a link inside a study folder", () => {
|
|
371
|
+
const r = P1(
|
|
372
|
+
study({
|
|
373
|
+
kind: "spike",
|
|
374
|
+
status: "done",
|
|
375
|
+
result: "41 of 60",
|
|
376
|
+
result_file: "out/r.txt",
|
|
377
|
+
plan: "sub/plan.json",
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
const S = `${DIR}/repro/P1-S01-baseline`;
|
|
381
|
+
const found = (links: readonly string[]) => {
|
|
382
|
+
const files = memoryFiles({
|
|
383
|
+
...registered(r),
|
|
384
|
+
[`${S}/sub/plan.json`]: planText(planOf(undefined)),
|
|
385
|
+
[`${S}/out/r.txt`]: "41 of 60\n",
|
|
386
|
+
});
|
|
387
|
+
return studyFindings(DIR, {
|
|
388
|
+
files,
|
|
389
|
+
listDir: memoryListDir(files),
|
|
390
|
+
entries: (dir) =>
|
|
391
|
+
memoryListEntries(files)(dir).map((e) =>
|
|
392
|
+
links.includes(`${dir}/${e.name}`)
|
|
393
|
+
? { ...e, kind: "symlink" as const }
|
|
394
|
+
: e,
|
|
395
|
+
),
|
|
396
|
+
codec: studyRegistryCodec,
|
|
397
|
+
});
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
it("silent while no link is on the way; each link is reported with its name, and the file behind it is not read", () => {
|
|
401
|
+
const linked = found([`${S}/sub`, `${S}/out`]).map((f) =>
|
|
402
|
+
f.problem === "bad-plan" || f.problem === "result-file"
|
|
403
|
+
? studyMessage(f).split("\n")[0]
|
|
404
|
+
: f.problem,
|
|
405
|
+
);
|
|
406
|
+
// Guards: both files exist and parse, so only the link can make a finding. The text names the
|
|
407
|
+
// file, the link and the fix.
|
|
408
|
+
expect([found([]).map((f) => f.problem), linked]).toEqual([
|
|
409
|
+
[],
|
|
410
|
+
[
|
|
411
|
+
"`repro/studies.json`: the `result_file` of `P1-S01`, `out/r.txt`, passes through the symbolic link `out` in its folder; nothing is read through a link. What to do: name a regular file inside the study's folder, or replace the link with the file itself.",
|
|
412
|
+
"`repro/studies.json`: the `plan` of `P1-S01`, `sub/plan.json`, passes through the symbolic link `sub` in its folder; nothing is read through a link. What to do: replace the link with the plan file itself.",
|
|
413
|
+
],
|
|
414
|
+
]);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
describe("repro/study-registered — a result file or a plan that is a FIFO", () => {
|
|
419
|
+
it("is not a file in its folder: reported as missing, never opened", () => {
|
|
420
|
+
const r = P1(
|
|
421
|
+
study({
|
|
422
|
+
status: "done",
|
|
423
|
+
result: "41 of 60",
|
|
424
|
+
result_file: "r.txt",
|
|
425
|
+
plan: "plan.json",
|
|
426
|
+
}),
|
|
427
|
+
);
|
|
428
|
+
const S = `${DIR}/repro/P1-S01-baseline`;
|
|
429
|
+
const files = memoryFiles({
|
|
430
|
+
...registered(r),
|
|
431
|
+
[`${S}/plan.json`]: "{}",
|
|
432
|
+
[`${S}/r.txt`]: "41 of 60\n",
|
|
433
|
+
});
|
|
434
|
+
const found = studyFindings(DIR, {
|
|
435
|
+
files,
|
|
436
|
+
listDir: memoryListDir(files),
|
|
437
|
+
entries: (dir) =>
|
|
438
|
+
memoryListEntries(files)(dir).map((e) =>
|
|
439
|
+
[`${S}/plan.json`, `${S}/r.txt`].includes(`${dir}/${e.name}`)
|
|
440
|
+
? { ...e, kind: "other" as const }
|
|
441
|
+
: e,
|
|
442
|
+
),
|
|
443
|
+
codec: studyRegistryCodec,
|
|
444
|
+
});
|
|
445
|
+
// Guards: `existingResults` used `Files.isFile`; the listing said "file" for a FIFO and the result passed.
|
|
446
|
+
expect(
|
|
447
|
+
found.map((f) =>
|
|
448
|
+
f.problem === "bad-plan" || f.problem === "result-file"
|
|
449
|
+
? `${f.problem}:${f.why}`
|
|
450
|
+
: f.problem,
|
|
451
|
+
),
|
|
452
|
+
).toEqual(["result-file:missing", "bad-plan:missing"]);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
describe("repro/study-registered — a row whose folder leads outside repro/", () => {
|
|
457
|
+
it("is reported by name, and nothing is read through it: its plan and result read as missing", () => {
|
|
458
|
+
const r = P1(
|
|
459
|
+
study({
|
|
460
|
+
folder: "../outside",
|
|
461
|
+
status: "done",
|
|
462
|
+
result: "41 of 60",
|
|
463
|
+
result_file: "r.txt",
|
|
464
|
+
plan: "plan.json",
|
|
465
|
+
}),
|
|
466
|
+
);
|
|
467
|
+
const files = memoryFiles({
|
|
468
|
+
...SCAFFOLD,
|
|
469
|
+
[`${DIR}/repro/studies.json`]: registryText(r),
|
|
470
|
+
[`${DIR}/repro/README.md`]: withIndex("# index\n", r, NO_FACTS),
|
|
471
|
+
[`${DIR}/outside/plan.json`]: "not JSON",
|
|
472
|
+
[`${DIR}/outside/r.txt`]: "41 of 60\n",
|
|
473
|
+
});
|
|
474
|
+
const read: string[] = [];
|
|
475
|
+
const spy = {
|
|
476
|
+
...files,
|
|
477
|
+
readBytes: (p: Parameters<typeof files.readBytes>[0]) => {
|
|
478
|
+
read.push(p);
|
|
479
|
+
return files.readBytes(p);
|
|
480
|
+
},
|
|
481
|
+
isFile: (p: Parameters<typeof files.isFile>[0]): boolean => {
|
|
482
|
+
read.push(p);
|
|
483
|
+
return files.isFile(p);
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
const found = studyFindings(DIR, {
|
|
487
|
+
files: spy,
|
|
488
|
+
listDir: memoryListDir(files),
|
|
489
|
+
entries: memoryListEntries(files),
|
|
490
|
+
codec: studyRegistryCodec,
|
|
491
|
+
});
|
|
492
|
+
// Guards: a row naming `../outside` never has a file outside `repro/` read for it.
|
|
493
|
+
expect([
|
|
494
|
+
found.map((f) =>
|
|
495
|
+
f.problem === "bad-plan" || f.problem === "result-file"
|
|
496
|
+
? `${f.problem}:${f.why}`
|
|
497
|
+
: f.problem,
|
|
498
|
+
),
|
|
499
|
+
read.filter((p) => p.includes("/outside/")),
|
|
500
|
+
]).toEqual([
|
|
501
|
+
["folder-name", "no-folder", "result-file:missing", "bad-plan:missing"],
|
|
502
|
+
[],
|
|
503
|
+
]);
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
323
507
|
/** A plan as a study would hold it once the author has checked its drawn units. */
|
|
324
|
-
const
|
|
508
|
+
const FILLED_PLAN: StudyPlan = {
|
|
325
509
|
question: "How many rules can a linter check?",
|
|
326
510
|
population: {
|
|
327
511
|
source: "the crawl of 2026-10-01",
|
|
@@ -330,7 +514,28 @@ const FILLED = planText({
|
|
|
330
514
|
arms: [],
|
|
331
515
|
primary_outcome: "the share of rules a linter can check",
|
|
332
516
|
author_check: { by: "A. Author", date: "2026-10-14", quote: "Go." },
|
|
333
|
-
|
|
517
|
+
mode: {
|
|
518
|
+
kind: "decide_before_run",
|
|
519
|
+
outputs: ["results.tsv"],
|
|
520
|
+
decision: {
|
|
521
|
+
file: "repro/P1-S01-baseline/decision.md",
|
|
522
|
+
note: "the sample approved",
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
const FILLED = planText(FILLED_PLAN);
|
|
527
|
+
|
|
528
|
+
/** The index of `r` as `studies index` writes it, FILLED_PLAN as its study's plan and `history` read. */
|
|
529
|
+
const FILLED_INDEX = (
|
|
530
|
+
r: StudyRegistry,
|
|
531
|
+
history: IndexInput["history"] = new Map(),
|
|
532
|
+
): string =>
|
|
533
|
+
withIndex("# Reproduction index\n", r, {
|
|
534
|
+
plans: new Map([
|
|
535
|
+
["P1-S01-baseline/plan.json", { kind: "ready", plan: FILLED_PLAN }],
|
|
536
|
+
]),
|
|
537
|
+
history,
|
|
538
|
+
});
|
|
334
539
|
|
|
335
540
|
/** A registry with a headline measurement of one running study whose row names `plan`. */
|
|
336
541
|
const HEADLINE = (plan: string): StudyRegistry => ({
|
|
@@ -348,10 +553,11 @@ const HEADLINE = (plan: string): StudyRegistry => ({
|
|
|
348
553
|
});
|
|
349
554
|
|
|
350
555
|
describe("repro/study-registered — the plan a row names, read from its folder", () => {
|
|
351
|
-
it("is silent on a plan.json the author checked", () => {
|
|
556
|
+
it("is silent on a plan.json the author checked, its mode in the index", () => {
|
|
557
|
+
const r = HEADLINE("plan.json");
|
|
352
558
|
expect(
|
|
353
559
|
lint({
|
|
354
|
-
...registered(
|
|
560
|
+
...registered(r, FILLED_INDEX(r)),
|
|
355
561
|
[`${DIR}/repro/P1-S01-baseline/plan.json`]: FILLED,
|
|
356
562
|
}),
|
|
357
563
|
).toEqual([]);
|
|
@@ -389,7 +595,9 @@ describe("repro/study-registered — the plan a row names, read from its folder"
|
|
|
389
595
|
expect(
|
|
390
596
|
lint({
|
|
391
597
|
...registered(HEADLINE("plan.json")),
|
|
392
|
-
[`${DIR}/repro/P1-S01-baseline/plan.json`]: planText(
|
|
598
|
+
[`${DIR}/repro/P1-S01-baseline/plan.json`]: planText(
|
|
599
|
+
planTemplate("spike", "x"),
|
|
600
|
+
),
|
|
393
601
|
}).map((r) => [r.messageId, r.line]),
|
|
394
602
|
).toEqual([
|
|
395
603
|
["bad-plan", 1],
|
|
@@ -413,6 +621,7 @@ describe("repro/study-registered — a plan the disk cannot hold", () => {
|
|
|
413
621
|
studyFindings(DIR, {
|
|
414
622
|
files: probe,
|
|
415
623
|
listDir: memoryListDir(files),
|
|
624
|
+
entries: memoryListEntries(files),
|
|
416
625
|
codec: studyRegistryCodec,
|
|
417
626
|
}).map((f) => f.problem),
|
|
418
627
|
).toEqual(["bad-plan"]);
|
|
@@ -454,3 +663,68 @@ describe("repro/study-plan-name — a plan under its old name", () => {
|
|
|
454
663
|
]).toEqual([[], []]);
|
|
455
664
|
});
|
|
456
665
|
});
|
|
666
|
+
|
|
667
|
+
/** A plan whose texts are filled and which declares no mode; checked by the author when `check`. */
|
|
668
|
+
const noMode = (check: boolean): string =>
|
|
669
|
+
planText({
|
|
670
|
+
question: "q",
|
|
671
|
+
population: { source: "s", draw: { kind: "all", method: "census" } },
|
|
672
|
+
arms: [],
|
|
673
|
+
primary_outcome: "o",
|
|
674
|
+
author_check: check
|
|
675
|
+
? { by: "A. Author", date: "2026-10-14", quote: "Go." }
|
|
676
|
+
: null,
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
/** The first line of each finding over the headline study with `plan` as its plan.json. */
|
|
680
|
+
const firstLines = (plan: string): readonly string[] =>
|
|
681
|
+
lint({
|
|
682
|
+
...registered(HEADLINE("plan.json")),
|
|
683
|
+
[`${DIR}/repro/P1-S01-baseline/plan.json`]: plan,
|
|
684
|
+
}).map((r) => r.message.split("\n")[0] ?? "");
|
|
685
|
+
|
|
686
|
+
it("repro/study-registered — a measurement's plan that has run without a mode is reported; approved without one, it is no plan", () => {
|
|
687
|
+
// Guards: decide-first is the default — a measurement that ran with no mode is reported, never silent.
|
|
688
|
+
expect([firstLines(noMode(false)), firstLines(noMode(true))]).toEqual([
|
|
689
|
+
[
|
|
690
|
+
"The plan of `P1-S01`, `plan.json` in its folder, leaves `mode` empty, and the study is `running`: a plan is written before the runs. What to do: fill them in, or set the study back to `designing`.",
|
|
691
|
+
"`P1-S01` is `running` in the headline measurement `M1`, and its plan has no `author_check`. What to do: show the author the drawn units, then record their answer in the plan's `author_check` — `by`, `date`, and their words in `quote`.",
|
|
692
|
+
],
|
|
693
|
+
[
|
|
694
|
+
"The plan of `P1-S01`, `plan.json` in its folder, is not a study plan: author_check: is set, and the plan leaves `mode` empty — the author approves a complete plan: fill them, or set author_check to null. What to do: give it the fields on the rule's page — `question`, `population`, `arms`, `primary_outcome`, and for a measurement its `mode`.",
|
|
695
|
+
],
|
|
696
|
+
]);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
describe("repro/study-registered — the index's mode columns", () => {
|
|
700
|
+
it("reports an index whose mode column is not the plan's, and never compares the two read from git", () => {
|
|
701
|
+
const r = HEADLINE("plan.json");
|
|
702
|
+
const at = { commit: "a".repeat(40), date: "2026-10-14T09:00:00+05:00" };
|
|
703
|
+
const lintWith = (readme: string): string[] =>
|
|
704
|
+
lint({
|
|
705
|
+
...registered(r, readme),
|
|
706
|
+
[`${DIR}/repro/P1-S01-baseline/plan.json`]: FILLED,
|
|
707
|
+
}).map((x) => x.messageId);
|
|
708
|
+
// Guards: the mode is in the tree, so a stale one is reported; the commits are what git said when
|
|
709
|
+
// `studies index` ran — a squash merge renames them — so whatever they say is in sync.
|
|
710
|
+
expect([
|
|
711
|
+
lintWith(withIndex("# Reproduction index\n", r, NO_FACTS)),
|
|
712
|
+
lintWith(
|
|
713
|
+
FILLED_INDEX(
|
|
714
|
+
r,
|
|
715
|
+
new Map([
|
|
716
|
+
[
|
|
717
|
+
"P1-S01",
|
|
718
|
+
{
|
|
719
|
+
kind: "read",
|
|
720
|
+
decision: at,
|
|
721
|
+
decisionDeleted: false,
|
|
722
|
+
firstOutput: at,
|
|
723
|
+
},
|
|
724
|
+
],
|
|
725
|
+
]),
|
|
726
|
+
),
|
|
727
|
+
),
|
|
728
|
+
]).toEqual([["stale-index"], []]);
|
|
729
|
+
});
|
|
730
|
+
});
|