paperlint 10.3.0 → 10.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +15 -4
- package/README.md +1 -0
- package/dist/adapters/git/index.d.ts +10 -0
- package/dist/adapters/git/index.d.ts.map +1 -0
- package/dist/adapters/git/index.js +151 -0
- package/dist/adapters/git/index.js.map +1 -0
- package/dist/adapters/memory/index.d.ts +6 -0
- package/dist/adapters/memory/index.d.ts.map +1 -1
- package/dist/adapters/memory/index.js +8 -0
- package/dist/adapters/memory/index.js.map +1 -1
- package/dist/adapters/node/index.d.ts +1 -0
- package/dist/adapters/node/index.d.ts.map +1 -1
- package/dist/adapters/node/index.js +1 -0
- package/dist/adapters/node/index.js.map +1 -1
- package/dist/adapters/node/list-entries.io.d.ts +3 -0
- package/dist/adapters/node/list-entries.io.d.ts.map +1 -0
- package/dist/adapters/node/list-entries.io.js +24 -0
- package/dist/adapters/node/list-entries.io.js.map +1 -0
- package/dist/adapters/study-registry/index.d.ts +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 +100 -0
- package/dist/domain/decide-first.d.ts.map +1 -0
- package/dist/domain/decide-first.js +102 -0
- package/dist/domain/decide-first.js.map +1 -0
- package/dist/domain/index-columns.d.ts +45 -0
- package/dist/domain/index-columns.d.ts.map +1 -0
- package/dist/domain/index-columns.js +62 -0
- package/dist/domain/index-columns.js.map +1 -0
- package/dist/domain/study-mode.d.ts +37 -0
- package/dist/domain/study-mode.d.ts.map +1 -0
- package/dist/domain/study-mode.js +45 -0
- package/dist/domain/study-mode.js.map +1 -0
- package/dist/domain/study-registry.d.ts +57 -10
- package/dist/domain/study-registry.d.ts.map +1 -1
- package/dist/domain/study-registry.js +106 -30
- package/dist/domain/study-registry.js.map +1 -1
- package/dist/outputs-before-decision-rule.d.ts +17 -0
- package/dist/outputs-before-decision-rule.d.ts.map +1 -0
- package/dist/outputs-before-decision-rule.js +90 -0
- package/dist/outputs-before-decision-rule.js.map +1 -0
- package/dist/ports/git-history.d.ts +44 -0
- package/dist/ports/git-history.d.ts.map +1 -0
- package/dist/ports/git-history.js +2 -0
- package/dist/ports/git-history.js.map +1 -0
- package/dist/ports/list-entries.d.ts +17 -0
- package/dist/ports/list-entries.d.ts.map +1 -0
- package/dist/ports/list-entries.js +2 -0
- package/dist/ports/list-entries.js.map +1 -0
- package/dist/ports/study-registry-codec.d.ts +6 -3
- package/dist/ports/study-registry-codec.d.ts.map +1 -1
- package/dist/studies.d.ts +4 -0
- package/dist/studies.d.ts.map +1 -1
- package/dist/studies.js +50 -22
- package/dist/studies.js.map +1 -1
- package/dist/study-gate.d.ts +14 -0
- package/dist/study-gate.d.ts.map +1 -0
- package/dist/study-gate.js +95 -0
- package/dist/study-gate.js.map +1 -0
- package/dist/study-index-input.d.ts +5 -0
- package/dist/study-index-input.d.ts.map +1 -0
- package/dist/study-index-input.js +13 -0
- package/dist/study-index-input.js.map +1 -0
- package/dist/study-modes.d.ts +45 -0
- package/dist/study-modes.d.ts.map +1 -0
- package/dist/study-modes.js +70 -0
- package/dist/study-modes.js.map +1 -0
- package/dist/study-registry-rule.d.ts +17 -1
- package/dist/study-registry-rule.d.ts.map +1 -1
- package/dist/study-registry-rule.js +34 -13
- package/dist/study-registry-rule.js.map +1 -1
- package/dist/study-run.d.ts +19 -0
- package/dist/study-run.d.ts.map +1 -0
- package/dist/study-run.js +9 -0
- package/dist/study-run.js.map +1 -0
- package/docs/design/study-modes.md +247 -0
- package/docs/design/study-registry.md +6 -0
- package/docs/e2e.md +8 -4
- package/docs/layout.md +22 -19
- package/docs/rules/repro/outputs-before-decision.md +119 -0
- package/docs/rules/repro/study-registered.md +47 -14
- package/docs/rules.md +54 -53
- package/docs/testing.md +1 -0
- 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/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/adapters/git/history.test.ts +121 -0
- package/src/adapters/git/index.test.ts +268 -0
- package/src/adapters/git/index.ts +227 -0
- package/src/adapters/memory/index.test.ts +15 -1
- package/src/adapters/memory/index.ts +13 -0
- package/src/adapters/node/index.ts +1 -0
- package/src/adapters/node/list-entries.io.ts +26 -0
- package/src/adapters/node/list-entries.test.ts +43 -0
- package/src/adapters/study-registry/index.test.ts +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 +258 -0
- package/src/domain/decide-first.ts +243 -0
- package/src/domain/index-columns.test.ts +216 -0
- package/src/domain/index-columns.ts +105 -0
- package/src/domain/study-measurements.test.ts +9 -5
- package/src/domain/study-mode.test.ts +55 -0
- package/src/domain/study-mode.ts +78 -0
- package/src/domain/study-registry.test.ts +38 -31
- package/src/domain/study-registry.ts +170 -31
- package/src/new-paper.test.ts +5 -1
- package/src/outputs-before-decision-rule.test.ts +206 -0
- package/src/outputs-before-decision-rule.ts +124 -0
- package/src/ports/git-history.ts +44 -0
- package/src/ports/list-entries.ts +18 -0
- package/src/ports/study-registry-codec.ts +10 -3
- 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 +88 -27
- package/src/study-gate.test.ts +316 -0
- package/src/study-gate.ts +161 -0
- package/src/study-index-input.ts +23 -0
- package/src/study-modes.ts +120 -0
- package/src/study-registry-rule.test.ts +198 -11
- package/src/study-registry-rule.ts +51 -14
- package/src/study-run.ts +21 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The schema of a study's mode (`mode` in its plan). Both halves: what `studies new` writes parses back
|
|
3
|
+
* to itself; a measurement's plan is ready only with exactly one mode; every contradiction is refused
|
|
4
|
+
* with its reason.
|
|
5
|
+
*/
|
|
6
|
+
import { expect, it } from "vitest";
|
|
7
|
+
import type { StudyMode } from "../../domain/study-mode.ts";
|
|
8
|
+
import {
|
|
9
|
+
planTemplate,
|
|
10
|
+
planText,
|
|
11
|
+
type StudyKind,
|
|
12
|
+
type StudyPlan,
|
|
13
|
+
} from "../../domain/study-registry.ts";
|
|
14
|
+
import { studyRegistryCodec } from "./index.ts";
|
|
15
|
+
|
|
16
|
+
const DECIDED: StudyMode = {
|
|
17
|
+
kind: "decide_before_run",
|
|
18
|
+
outputs: ["data/outcome/**", "RESULTS.md"],
|
|
19
|
+
decision: {
|
|
20
|
+
file: "repro/P1-S01-x/decision.md",
|
|
21
|
+
note: "exclusions and the sample approved",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const EXPLORATORY: StudyMode = {
|
|
25
|
+
kind: "exploratory",
|
|
26
|
+
reason: "a first look at the logs, to find the question",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const PLAN: StudyPlan = {
|
|
30
|
+
question: "Does the hint help?",
|
|
31
|
+
population: {
|
|
32
|
+
source: "tasks, 2026-10-01",
|
|
33
|
+
draw: { kind: "all", method: "census" },
|
|
34
|
+
},
|
|
35
|
+
arms: [{ name: "hint" }, { name: "none" }],
|
|
36
|
+
primary_outcome: "pass rate",
|
|
37
|
+
author_check: null,
|
|
38
|
+
mode: DECIDED,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const parse = (text: string, kind: StudyKind = "measurement") =>
|
|
42
|
+
studyRegistryCodec.parsePlan(text, kind);
|
|
43
|
+
const withMode = (mode: unknown): string => JSON.stringify({ ...PLAN, mode });
|
|
44
|
+
const why = (text: string): string => {
|
|
45
|
+
const r = parse(text);
|
|
46
|
+
return r.kind === "unreadable" ? r.why : r.kind;
|
|
47
|
+
};
|
|
48
|
+
const empty = (text: string, kind: StudyKind = "measurement") => {
|
|
49
|
+
const r = parse(text, kind);
|
|
50
|
+
return r.kind === "draft" ? r.empty : r.kind;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
it("each mode parses to itself, ready", () => {
|
|
54
|
+
// Guards: the three modes round-trip through the text planText writes.
|
|
55
|
+
expect(
|
|
56
|
+
[DECIDED, EXPLORATORY].map((mode) => parse(planText({ ...PLAN, mode }))),
|
|
57
|
+
).toEqual(
|
|
58
|
+
[DECIDED, EXPLORATORY].map((mode) => ({
|
|
59
|
+
kind: "ready",
|
|
60
|
+
plan: { ...PLAN, mode },
|
|
61
|
+
})),
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("a measurement's plan without a mode is a draft naming `mode`; a spike's is ready", () => {
|
|
66
|
+
const none: StudyPlan = { ...PLAN, mode: undefined };
|
|
67
|
+
// Guards: "none" is not a ready state for a measurement — and a spike may omit it.
|
|
68
|
+
expect(empty(planText(none))).toEqual(["mode"]);
|
|
69
|
+
expect(empty(planText(none), "spike")).toBe("ready");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("the template of a measurement is a draft decided before its run, its decision file named", () => {
|
|
73
|
+
const t = planTemplate("measurement", "P1-S02-x");
|
|
74
|
+
// Guards: `studies new` scaffolds the default mode; what is left to fill is named.
|
|
75
|
+
expect(t.mode).toEqual({
|
|
76
|
+
kind: "decide_before_run",
|
|
77
|
+
outputs: [],
|
|
78
|
+
decision: { file: "repro/P1-S02-x/decision.md", note: "" },
|
|
79
|
+
});
|
|
80
|
+
expect(empty(planText(t))).toEqual([
|
|
81
|
+
"question",
|
|
82
|
+
"population.source",
|
|
83
|
+
"population.draw.method",
|
|
84
|
+
"primary_outcome",
|
|
85
|
+
"mode.outputs",
|
|
86
|
+
"mode.decision.note",
|
|
87
|
+
]);
|
|
88
|
+
expect(planTemplate("spike", "P1-S03-y").mode).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("each mode's empty texts are named: the decision's, an exploratory reason", () => {
|
|
92
|
+
// Guards: a mode left half-written is a draft, so a study that has run on it is reported.
|
|
93
|
+
expect(
|
|
94
|
+
empty(
|
|
95
|
+
withMode({ ...DECIDED, outputs: [], decision: { file: " ", note: "" } }),
|
|
96
|
+
),
|
|
97
|
+
).toEqual(["mode.outputs", "mode.decision.file", "mode.decision.note"]);
|
|
98
|
+
expect(empty(withMode({ ...EXPLORATORY, reason: "" }))).toEqual([
|
|
99
|
+
"mode.reason",
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("two modes at once, or a mode that is neither, cannot be written", () => {
|
|
104
|
+
// Guards: exactly one — a second mode's key is an unknown key, an unknown kind is refused.
|
|
105
|
+
expect(why(withMode({ ...DECIDED, reason: "r" }))).toBe(
|
|
106
|
+
'mode: Unrecognized key: "reason"',
|
|
107
|
+
);
|
|
108
|
+
expect(why(withMode({ kind: "preregistered" }))).toBe(
|
|
109
|
+
"mode.kind: Invalid discriminator value. Expected 'decide_before_run' | 'exploratory'",
|
|
110
|
+
);
|
|
111
|
+
expect(why(withMode({ kind: "blind" }))).toBe(
|
|
112
|
+
"mode.kind: Invalid discriminator value. Expected 'decide_before_run' | 'exploratory'",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("refuses an output outside the folder or naming the study's README, and a decision outside the paper", () => {
|
|
117
|
+
const outside =
|
|
118
|
+
"is not a path inside the study's folder, or names its README.md";
|
|
119
|
+
// Guards: a glob cannot reach past the study's folder; a decision cannot leave the paper.
|
|
120
|
+
expect(
|
|
121
|
+
["../x", "/abs", "a\\b", "README.md"].map((o) =>
|
|
122
|
+
why(withMode({ ...DECIDED, outputs: [o] })),
|
|
123
|
+
),
|
|
124
|
+
).toEqual(Array.from({ length: 4 }, () => `mode.outputs.0: ${outside}`));
|
|
125
|
+
expect(why(withMode({ ...DECIDED, outputs: ["a", "a"] }))).toBe(
|
|
126
|
+
"mode.outputs: the output «a» is listed more than once",
|
|
127
|
+
);
|
|
128
|
+
expect(
|
|
129
|
+
["../d.md", "/d.md", "a\\d.md"].map((file) =>
|
|
130
|
+
why(withMode({ ...DECIDED, decision: { file, note: "n" } })),
|
|
131
|
+
),
|
|
132
|
+
).toEqual(
|
|
133
|
+
Array.from(
|
|
134
|
+
{ length: 3 },
|
|
135
|
+
() => "mode.decision.file: is not a path inside the paper's folder",
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("refuses a decision path that names the paper's folder or a directory, and keeps an empty one a draft", () => {
|
|
141
|
+
const notFile =
|
|
142
|
+
"mode.decision.file: is not a path to a file inside the paper's folder: one or more names joined by /, none empty, . or ..";
|
|
143
|
+
// Guards: a decision of `.` would make the gate ask git about the whole paper, and the order rule
|
|
144
|
+
// read the paper's first commit as the decision; such a path cannot be written down.
|
|
145
|
+
expect(
|
|
146
|
+
[".", "./", "repro/", "a//d.md", "a/./d.md", "./d.md"].map((file) =>
|
|
147
|
+
why(withMode({ ...DECIDED, decision: { file, note: "n" } })),
|
|
148
|
+
),
|
|
149
|
+
).toEqual(Array(6).fill(notFile));
|
|
150
|
+
expect(
|
|
151
|
+
empty(withMode({ ...DECIDED, decision: { file: "", note: "n" } })),
|
|
152
|
+
).toEqual(["mode.decision.file"]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("mode is a field of the plan, so extra cannot carry it", () => {
|
|
156
|
+
// Guards: one place says how a study is gated.
|
|
157
|
+
expect(
|
|
158
|
+
why(JSON.stringify({ ...PLAN, mode: undefined, extra: { mode: {} } })),
|
|
159
|
+
).toBe(
|
|
160
|
+
"extra.mode: is a field of the plan itself — set it there, not in extra",
|
|
161
|
+
);
|
|
162
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -190,6 +190,12 @@ import {
|
|
|
190
190
|
studyRegistryRules,
|
|
191
191
|
} from "./study-registry-rule.ts";
|
|
192
192
|
import { studyRegistryCodec } from "./adapters/study-registry/index.ts";
|
|
193
|
+
import { nodeListEntries, spawnProcess } from "./adapters/node/index.ts";
|
|
194
|
+
import { gitHistory } from "./adapters/git/index.ts";
|
|
195
|
+
import {
|
|
196
|
+
ORDER_RULE_LEVELS,
|
|
197
|
+
outputsBeforeDecisionRules,
|
|
198
|
+
} from "./outputs-before-decision-rule.ts";
|
|
193
199
|
import { runStudies } from "./studies.ts";
|
|
194
200
|
import typography from "#eslint-rules/paper-typography";
|
|
195
201
|
import texBuild from "#eslint-rules/tex-build";
|
|
@@ -243,6 +249,11 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
|
|
|
243
249
|
index in repro/README.md regenerated
|
|
244
250
|
npx paperlint studies index <paper-dir>
|
|
245
251
|
regenerate the index in repro/README.md from repro/studies.json
|
|
252
|
+
npx paperlint studies gate <paper-dir> <id>
|
|
253
|
+
may the study's run start? For a study decided before its run
|
|
254
|
+
(the default mode), exit 0 only once its decision file is
|
|
255
|
+
committed as it stands, printing the commit and date; else exit
|
|
256
|
+
1 saying why. A runner calls it first: gate … && ./run.sh
|
|
246
257
|
npx paperlint hook <name> run an editor hook (.claude/settings.json calls this)
|
|
247
258
|
npx paperlint --help
|
|
248
259
|
|
|
@@ -267,7 +278,10 @@ studies:
|
|
|
267
278
|
--kind <kind> new: measurement (the default), spike, tooling, data, artifact or salvage.
|
|
268
279
|
A study's status, result and in_paper are edited in repro/studies.json, its
|
|
269
280
|
plan in its plan.json; the rule repro/study-registered checks them
|
|
270
|
-
(docs/rules/repro/study-registered.md)
|
|
281
|
+
(docs/rules/repro/study-registered.md). A measurement's plan declares its
|
|
282
|
+
mode: decide_before_run (written by new) or exploratory;
|
|
283
|
+
repro/outputs-before-decision checks the order in git
|
|
284
|
+
(docs/design/study-modes.md)
|
|
271
285
|
|
|
272
286
|
submission:
|
|
273
287
|
The venue preset declares the portal — "portal": { "kind": "hotcrp", "url": "<site>" } — and the
|
|
@@ -383,20 +397,43 @@ const FRAGMENT_RULES = {
|
|
|
383
397
|
"heading-case": REGISTER_AND_CASE_RULES["heading-case"],
|
|
384
398
|
};
|
|
385
399
|
|
|
400
|
+
/** `names` of this process's environment, and nothing else of it. */
|
|
401
|
+
const envOf =
|
|
402
|
+
(names: readonly string[]) =>
|
|
403
|
+
(env: Readonly<NodeJS.ProcessEnv>): Readonly<Record<string, string>> =>
|
|
404
|
+
Object.fromEntries(
|
|
405
|
+
Object.entries(env).filter(
|
|
406
|
+
(e): e is [string, string] =>
|
|
407
|
+
names.includes(e[0]) && e[1] !== undefined,
|
|
408
|
+
),
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
/** The environment git runs in: PATH, and HOME for its configuration. */
|
|
412
|
+
const gitEnv = envOf(["PATH", "HOME"]);
|
|
413
|
+
|
|
414
|
+
/** git's history, as the decide-first rule and the gate read it. */
|
|
415
|
+
const GIT = gitHistory(spawnProcess(), gitEnv(process.env));
|
|
416
|
+
|
|
386
417
|
/** What the two layout rules read with: the disk and its directory listings. */
|
|
387
418
|
const LAYOUT_RULE_DEPS = { files: nodeFiles, listDir: nodeListDir };
|
|
388
419
|
|
|
389
420
|
/** What `repro/study-registered` and `paperlint studies` read with: the layout's, and the registry's parser. */
|
|
390
|
-
const STUDY_RULE_DEPS = {
|
|
421
|
+
const STUDY_RULE_DEPS = {
|
|
422
|
+
...LAYOUT_RULE_DEPS,
|
|
423
|
+
entries: nodeListEntries,
|
|
424
|
+
codec: studyRegistryCodec,
|
|
425
|
+
};
|
|
391
426
|
|
|
392
427
|
/** The `repro` plugin's rules, on every `PIPELINE-STATUS.md`: the layout of `repro/`, and its study registry. */
|
|
393
428
|
const REPRO_RULES = {
|
|
394
429
|
...reproLayoutRules(LAYOUT_RULE_DEPS),
|
|
395
430
|
...studyRegistryRules(STUDY_RULE_DEPS),
|
|
431
|
+
...outputsBeforeDecisionRules({ ...STUDY_RULE_DEPS, git: GIT }),
|
|
396
432
|
};
|
|
397
433
|
const REPRO_RULE_LEVELS = {
|
|
398
434
|
...REPRO_LAYOUT_RULE_LEVELS,
|
|
399
435
|
...STUDY_REGISTRY_RULE_LEVELS,
|
|
436
|
+
...ORDER_RULE_LEVELS,
|
|
400
437
|
};
|
|
401
438
|
|
|
402
439
|
/** Today in this machine's time zone, `YYYY-MM-DD`: the date `paperlint studies new` registers a study on. */
|
|
@@ -1675,7 +1712,14 @@ const SIMPLE: Readonly<
|
|
|
1675
1712
|
extra: a.paths.slice(3),
|
|
1676
1713
|
kind: a.kind,
|
|
1677
1714
|
},
|
|
1678
|
-
{
|
|
1715
|
+
{
|
|
1716
|
+
...STUDY_RULE_DEPS,
|
|
1717
|
+
git: GIT,
|
|
1718
|
+
cwd,
|
|
1719
|
+
today: today(),
|
|
1720
|
+
log,
|
|
1721
|
+
err,
|
|
1722
|
+
},
|
|
1679
1723
|
),
|
|
1680
1724
|
submission: (a, { log, err, cwd }) =>
|
|
1681
1725
|
runSubmission(
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `repro/outputs-before-decision` decides from git's facts (`src/domain/decide-first.ts`): silent
|
|
3
|
+
* on outputs that came after the decision, loud on each output that did not, on outputs with no
|
|
4
|
+
* decision committed, on a decision the outputs match, and on a history it cannot read.
|
|
5
|
+
*/
|
|
6
|
+
import { expect, it } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
judgeOrder,
|
|
9
|
+
orderMessage,
|
|
10
|
+
type DecideFirstStudy,
|
|
11
|
+
type OrderFinding,
|
|
12
|
+
} from "./decide-first.ts";
|
|
13
|
+
|
|
14
|
+
const D = { commit: "d".repeat(40), date: "2026-10-16T10:00:00+05:00" };
|
|
15
|
+
const A = { commit: "a".repeat(40), date: "2026-10-16T11:00:00+05:00" };
|
|
16
|
+
|
|
17
|
+
const STUDY: DecideFirstStudy = {
|
|
18
|
+
id: "P1-S01",
|
|
19
|
+
folder: "P1-S01-x",
|
|
20
|
+
plan: "plan.json",
|
|
21
|
+
mode: {
|
|
22
|
+
kind: "decide_before_run",
|
|
23
|
+
outputs: ["out/**", "RESULTS.md"],
|
|
24
|
+
decision: { file: "repro/P1-S01-x/decision.md", note: "exclusions" },
|
|
25
|
+
},
|
|
26
|
+
tree: ["plan.json", "README.md", "decision.md", "RESULTS.md", "notes.txt"],
|
|
27
|
+
facts: {
|
|
28
|
+
kind: "read",
|
|
29
|
+
decision: D,
|
|
30
|
+
decisionDeleted: false,
|
|
31
|
+
files: [
|
|
32
|
+
{ path: "decision.md", ...D, order: "same" },
|
|
33
|
+
{ path: "plan.json", ...D, order: "same" },
|
|
34
|
+
{ path: "RESULTS.md", ...A, order: "after" },
|
|
35
|
+
{ path: "notes.txt", ...D, order: "same" },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
it("silent when every output entered history after the decision; the study's own files never count", () => {
|
|
41
|
+
// Guards: the clean history — and the decision and the plan, committed together, are no outputs.
|
|
42
|
+
expect(judgeOrder(STUDY)).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("an output in the decision's commit, or not after it, deleted or not, is one finding each", () => {
|
|
46
|
+
const facts = {
|
|
47
|
+
kind: "read",
|
|
48
|
+
decision: D,
|
|
49
|
+
decisionDeleted: false,
|
|
50
|
+
files: [
|
|
51
|
+
{ path: "RESULTS.md", ...D, order: "same" },
|
|
52
|
+
{ path: "out/gone.csv", ...A, order: "not-after" },
|
|
53
|
+
],
|
|
54
|
+
} as const;
|
|
55
|
+
// Guards: loud on each defect, the deleted early output included.
|
|
56
|
+
expect(judgeOrder({ ...STUDY, facts })).toEqual([
|
|
57
|
+
{
|
|
58
|
+
problem: "output-before-decision",
|
|
59
|
+
id: "P1-S01",
|
|
60
|
+
folder: "P1-S01-x",
|
|
61
|
+
path: "RESULTS.md",
|
|
62
|
+
output: D,
|
|
63
|
+
decision: D,
|
|
64
|
+
same: true,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
problem: "output-before-decision",
|
|
68
|
+
id: "P1-S01",
|
|
69
|
+
folder: "P1-S01-x",
|
|
70
|
+
path: "out/gone.csv",
|
|
71
|
+
output: A,
|
|
72
|
+
decision: D,
|
|
73
|
+
same: false,
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("an output in the tree while the decision was never committed", () => {
|
|
79
|
+
// Guards: the run started before any decision existed in history.
|
|
80
|
+
expect(
|
|
81
|
+
judgeOrder({
|
|
82
|
+
...STUDY,
|
|
83
|
+
facts: {
|
|
84
|
+
kind: "read",
|
|
85
|
+
decision: null,
|
|
86
|
+
decisionDeleted: false,
|
|
87
|
+
files: [],
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
).toEqual([
|
|
91
|
+
{
|
|
92
|
+
problem: "undecided-output",
|
|
93
|
+
id: "P1-S01",
|
|
94
|
+
folder: "P1-S01-x",
|
|
95
|
+
path: "RESULTS.md",
|
|
96
|
+
decision: "repro/P1-S01-x/decision.md",
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("an output committed and since deleted, while the decision was never committed, still counts", () => {
|
|
102
|
+
// Guards: history is the evidence — an output that existed before any decision is not erased by
|
|
103
|
+
// deleting it; each path is said once, whether it is in the tree, in history, or both.
|
|
104
|
+
expect(
|
|
105
|
+
judgeOrder({
|
|
106
|
+
...STUDY,
|
|
107
|
+
tree: ["plan.json", "README.md", "RESULTS.md"],
|
|
108
|
+
facts: {
|
|
109
|
+
kind: "read",
|
|
110
|
+
decision: null,
|
|
111
|
+
decisionDeleted: false,
|
|
112
|
+
files: [
|
|
113
|
+
{ path: "plan.json", ...D, order: "not-after" },
|
|
114
|
+
{ path: "out/gone.csv", ...A, order: "not-after" },
|
|
115
|
+
{ path: "RESULTS.md", ...A, order: "not-after" },
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
}).map((f) => (f.problem === "undecided-output" ? f.path : f.problem)),
|
|
119
|
+
).toEqual(["RESULTS.md", "out/gone.csv"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("a history it cannot read, and a decision the outputs match, are said", () => {
|
|
123
|
+
const clash = {
|
|
124
|
+
...STUDY,
|
|
125
|
+
mode: { ...STUDY.mode, outputs: ["**/*.md"] },
|
|
126
|
+
};
|
|
127
|
+
// Guards: no git and a shallow clone are findings, never a pass; the clash comes first.
|
|
128
|
+
expect([
|
|
129
|
+
judgeOrder({
|
|
130
|
+
...STUDY,
|
|
131
|
+
facts: { kind: "unknown", why: "git is not installed" },
|
|
132
|
+
}),
|
|
133
|
+
judgeOrder({ ...clash, facts: { kind: "shallow" } }),
|
|
134
|
+
]).toEqual([
|
|
135
|
+
[{ problem: "no-history", id: "P1-S01", why: "git is not installed" }],
|
|
136
|
+
[
|
|
137
|
+
{
|
|
138
|
+
problem: "decision-is-output",
|
|
139
|
+
id: "P1-S01",
|
|
140
|
+
file: "repro/P1-S01-x/decision.md",
|
|
141
|
+
glob: "**/*.md",
|
|
142
|
+
},
|
|
143
|
+
{ problem: "shallow", id: "P1-S01" },
|
|
144
|
+
],
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("a decision committed and since deleted is reported, and no order is judged against it", () => {
|
|
149
|
+
// Guards: outputs that came after a decision that no longer stands are not passed on it.
|
|
150
|
+
expect(
|
|
151
|
+
judgeOrder({
|
|
152
|
+
...STUDY,
|
|
153
|
+
facts: {
|
|
154
|
+
kind: "read",
|
|
155
|
+
decision: D,
|
|
156
|
+
decisionDeleted: true,
|
|
157
|
+
files: [{ path: "RESULTS.md", ...A, order: "after" }],
|
|
158
|
+
},
|
|
159
|
+
}),
|
|
160
|
+
).toEqual([
|
|
161
|
+
{
|
|
162
|
+
problem: "decision-deleted",
|
|
163
|
+
id: "P1-S01",
|
|
164
|
+
file: "repro/P1-S01-x/decision.md",
|
|
165
|
+
},
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("a decision that names a directory is reported, and no history is judged", () => {
|
|
170
|
+
// Guards: the outputs of such a study are never passed on a directory's commits.
|
|
171
|
+
expect(
|
|
172
|
+
judgeOrder({ ...STUDY, facts: { kind: "not-a-file", what: "directory" } }),
|
|
173
|
+
).toEqual([
|
|
174
|
+
{
|
|
175
|
+
problem: "decision-not-file",
|
|
176
|
+
id: "P1-S01",
|
|
177
|
+
file: "repro/P1-S01-x/decision.md",
|
|
178
|
+
what: "directory",
|
|
179
|
+
},
|
|
180
|
+
]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const PAGE =
|
|
184
|
+
"\nRule repro/outputs-before-decision: https://github.com/zernie/paperlint/blob/main/docs/rules/repro/outputs-before-decision.md";
|
|
185
|
+
|
|
186
|
+
/** One finding of every problem, the decision's first. */
|
|
187
|
+
const ALL: readonly OrderFinding[] = [
|
|
188
|
+
{
|
|
189
|
+
problem: "decision-is-output",
|
|
190
|
+
id: "P1-S01",
|
|
191
|
+
file: "repro/P1-S01-x/decision.md",
|
|
192
|
+
glob: "**/*.md",
|
|
193
|
+
},
|
|
194
|
+
{ problem: "decision-deleted", id: "P1-S01", file: "repro/d.md" },
|
|
195
|
+
{
|
|
196
|
+
problem: "decision-not-file",
|
|
197
|
+
id: "P1-S01",
|
|
198
|
+
file: "repro/d",
|
|
199
|
+
what: "directory",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
problem: "decision-not-file",
|
|
203
|
+
id: "P1-S01",
|
|
204
|
+
file: "repro/l.md",
|
|
205
|
+
what: "symbolic link",
|
|
206
|
+
},
|
|
207
|
+
{ problem: "no-history", id: "P1-S01", why: "git is not installed" },
|
|
208
|
+
{ problem: "shallow", id: "P1-S01" },
|
|
209
|
+
{
|
|
210
|
+
problem: "undecided-output",
|
|
211
|
+
id: "P1-S01",
|
|
212
|
+
folder: "P1-S01-x",
|
|
213
|
+
path: "RESULTS.md",
|
|
214
|
+
decision: "repro/P1-S01-x/decision.md",
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
problem: "output-before-decision",
|
|
218
|
+
id: "P1-S01",
|
|
219
|
+
folder: "P1-S01-x",
|
|
220
|
+
path: "RESULTS.md",
|
|
221
|
+
output: D,
|
|
222
|
+
decision: D,
|
|
223
|
+
same: true,
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
problem: "output-before-decision",
|
|
227
|
+
id: "P1-S01",
|
|
228
|
+
folder: "P1-S01-x",
|
|
229
|
+
path: "RESULTS.md",
|
|
230
|
+
output: A,
|
|
231
|
+
decision: D,
|
|
232
|
+
same: false,
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
it("each finding's message: what is wrong, what to do, the rule's page", () => {
|
|
237
|
+
// Guards: the whole text a person reads, for every problem.
|
|
238
|
+
expect(ALL.map(orderMessage)).toEqual([
|
|
239
|
+
"The decision of `P1-S01`, `repro/P1-S01-x/decision.md`, is matched by its output `**/*.md`: the decision would be one of the files its own run produces. What to do: keep the decision out of the outputs — a file the globs do not match, or one elsewhere in the paper — and set `mode.decision.file`." +
|
|
240
|
+
PAGE,
|
|
241
|
+
"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
|
+
PAGE,
|
|
243
|
+
"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
|
+
PAGE,
|
|
245
|
+
"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
|
+
PAGE,
|
|
247
|
+
"`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
|
+
PAGE,
|
|
249
|
+
"`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`)." +
|
|
250
|
+
PAGE,
|
|
251
|
+
"`repro/P1-S01-x/RESULTS.md` is an output of `P1-S01` — in the tree, or in its history — and the study is decided before its run, but its decision `repro/P1-S01-x/decision.md` was never committed. What to do: write and commit the decision, check it with `npx paperlint studies gate <paper-dir> P1-S01`, then run the study again; if the output was made before any decision, declare the study `exploratory` with the reason. Deleting the output does not undo it: history keeps it." +
|
|
252
|
+
PAGE,
|
|
253
|
+
`\`repro/P1-S01-x/RESULTS.md\`, an output of \`P1-S01\`, entered history in ${"d".repeat(12)} (${D.date}), the very commit that first recorded its decision, ${"d".repeat(12)} (${D.date}). The study is declared decided before its run, and its history does not show that order. What to do: if the decision really came first, nothing in this history can prove it — declare the study \`exploratory\` with the reason, or run it again after the decision; never rewrite the history to make it pass.` +
|
|
254
|
+
PAGE,
|
|
255
|
+
`\`repro/P1-S01-x/RESULTS.md\`, an output of \`P1-S01\`, entered history in ${"a".repeat(12)} (${A.date}), not after the commit that first recorded its decision, ${"d".repeat(12)} (${D.date}). The study is declared decided before its run, and its history does not show that order. What to do: if the decision really came first, nothing in this history can prove it — declare the study \`exploratory\` with the reason, or run it again after the decision; never rewrite the history to make it pass.` +
|
|
256
|
+
PAGE,
|
|
257
|
+
]);
|
|
258
|
+
});
|