paperlint 13.0.3 → 13.2.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/CLAUDE.md +9 -1
- package/dist/adapters/git/index.d.ts.map +1 -1
- package/dist/adapters/git/index.js +13 -0
- package/dist/adapters/git/index.js.map +1 -1
- package/dist/adapters/study-registry/index.d.ts +142 -1
- package/dist/adapters/study-registry/index.d.ts.map +1 -1
- package/dist/adapters/study-registry/index.js +160 -1
- package/dist/adapters/study-registry/index.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -0
- package/dist/cli.js.map +1 -1
- package/dist/domain/decide-first.d.ts +43 -0
- package/dist/domain/decide-first.d.ts.map +1 -1
- package/dist/domain/decide-first.js +45 -2
- package/dist/domain/decide-first.js.map +1 -1
- package/dist/domain/study-gates.d.ts +96 -0
- package/dist/domain/study-gates.d.ts.map +1 -0
- package/dist/domain/study-gates.js +56 -0
- package/dist/domain/study-gates.js.map +1 -0
- package/dist/domain/study-mode.d.ts +6 -0
- package/dist/domain/study-mode.d.ts.map +1 -1
- package/dist/domain/study-mode.js.map +1 -1
- package/dist/domain/study-registry.d.ts +24 -4
- package/dist/domain/study-registry.d.ts.map +1 -1
- package/dist/domain/study-registry.js +39 -2
- package/dist/domain/study-registry.js.map +1 -1
- package/dist/domain/study-result.d.ts +151 -0
- package/dist/domain/study-result.d.ts.map +1 -0
- package/dist/domain/study-result.js +175 -0
- package/dist/domain/study-result.js.map +1 -0
- package/dist/outputs-before-decision-rule.d.ts.map +1 -1
- package/dist/outputs-before-decision-rule.js +37 -3
- package/dist/outputs-before-decision-rule.js.map +1 -1
- package/dist/ports/git-history.d.ts +12 -0
- package/dist/ports/git-history.d.ts.map +1 -1
- package/dist/ports/study-registry-codec.d.ts +3 -0
- package/dist/ports/study-registry-codec.d.ts.map +1 -1
- package/dist/skills/build-label-page/scripts/label-page-cli.d.ts +6 -0
- package/dist/skills/build-label-page/scripts/label-page-commands.d.ts +14 -0
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +149 -0
- package/dist/skills/build-label-page/scripts/label-page-html.d.ts +5 -0
- package/dist/skills/build-label-page/scripts/label-page.d.ts +2 -0
- package/dist/study-modes.d.ts +3 -0
- package/dist/study-modes.d.ts.map +1 -1
- package/dist/study-modes.js +8 -1
- package/dist/study-modes.js.map +1 -1
- package/dist/study-registry-rule.d.ts.map +1 -1
- package/dist/study-registry-rule.js +1 -0
- package/dist/study-registry-rule.js.map +1 -1
- package/dist/study-result-rule.d.ts +12 -0
- package/dist/study-result-rule.d.ts.map +1 -0
- package/dist/study-result-rule.js +87 -0
- package/dist/study-result-rule.js.map +1 -0
- package/docs/design/study-gates.md +208 -0
- package/docs/rules/repro/outputs-before-decision.md +8 -0
- package/docs/rules/repro/study-registered.md +38 -15
- package/docs/rules/repro/study-result.md +100 -0
- package/docs/rules.md +1 -0
- package/eslint.config.mjs +1 -1
- package/hooks/hooks.harness.ts +51 -13
- package/hooks/paper-skills-nudge.hook.d.mts +22 -0
- package/hooks/paper-skills-nudge.hook.mjs +85 -23
- package/hooks/paper-skills-nudge.test.ts +82 -4
- package/package.json +2 -1
- package/plugin/hooks/hooks.json +7 -2
- package/schemas/plan.v2.schema.json +341 -0
- package/skills/argument-arc/SKILL.md.spec.ts +1 -1
- package/skills/build-label-page/SKILL.md +85 -54
- package/skills/build-label-page/SKILL.md.spec.ts +85 -53
- package/skills/build-label-page/build-label-page.test.ts +10 -941
- package/skills/build-label-page/references/checklist.md +45 -3
- package/skills/build-label-page/references/closed-choice-generator.md +88 -0
- package/skills/build-label-page/scripts/label-page-cli.js +72 -0
- package/skills/build-label-page/scripts/label-page-cli.ts +85 -0
- package/skills/build-label-page/scripts/label-page-commands.js +14 -0
- package/skills/build-label-page/scripts/label-page-commands.ts +14 -0
- package/skills/build-label-page/scripts/label-page-core.js +850 -0
- package/skills/build-label-page/scripts/label-page-core.test.ts +1164 -0
- package/skills/build-label-page/scripts/label-page-core.ts +1210 -0
- package/skills/build-label-page/scripts/label-page-html.js +33 -0
- package/skills/build-label-page/scripts/label-page-html.test.ts +413 -0
- package/skills/build-label-page/scripts/label-page-html.ts +43 -0
- package/skills/build-label-page/scripts/label-page.js +70 -0
- package/skills/build-label-page/scripts/label-page.test.ts +71 -0
- package/skills/build-label-page/scripts/label-page.ts +71 -0
- package/skills/build-label-page/scripts/page-template.html +513 -0
- package/skills/find-venue/SKILL.md.spec.ts +1 -1
- package/skills/paper-status/SKILL.md.spec.ts +1 -1
- package/skills/render-paper/SKILL.md.spec.ts +1 -1
- package/src/adapters/git/index.test.ts +33 -0
- package/src/adapters/git/index.ts +19 -0
- package/src/adapters/study-registry/gates.test.ts +181 -0
- package/src/adapters/study-registry/index.ts +219 -1
- package/src/adapters/study-registry/mode.test.ts +6 -0
- package/src/cli.ts +3 -0
- package/src/domain/decide-first.test.ts +131 -0
- package/src/domain/decide-first.ts +92 -2
- package/src/domain/study-gates.ts +125 -0
- package/src/domain/study-measurements.test.ts +71 -0
- package/src/domain/study-mode.ts +6 -0
- package/src/domain/study-registry.ts +64 -2
- package/src/domain/study-result.test.ts +300 -0
- package/src/domain/study-result.ts +358 -0
- package/src/outputs-before-decision-rule.test.ts +115 -2
- package/src/outputs-before-decision-rule.ts +50 -2
- package/src/ports/git-history.ts +12 -0
- package/src/ports/study-registry-codec.ts +3 -0
- package/src/rule-cases.test.ts +74 -0
- package/src/studies.test.ts +3 -0
- package/src/study-modes.ts +10 -1
- package/src/study-registry-rule.ts +1 -0
- package/src/study-result-rule.test.ts +281 -0
- package/src/study-result-rule.ts +123 -0
- package/skills/build-label-page/references/example-generator.py +0 -803
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
* order whether the clone is shallow; the decision file's first commit; every file's first
|
|
8
8
|
* commit under the study's folder (`git log --reverse --name-only`); and for each, whether
|
|
9
9
|
* any commit that touched the decision is its strict ancestor (`git rev-list -- <decision>`)
|
|
10
|
+
* fileAt a file under the study's folder as it stood at a commit (`git ls-tree`, then `git show`)
|
|
10
11
|
*/
|
|
11
12
|
import type { AbsolutePath } from "../../domain/paths.ts";
|
|
12
13
|
import type {
|
|
13
14
|
CommitAt,
|
|
14
15
|
Committed,
|
|
16
|
+
FileAt,
|
|
15
17
|
FirstCommit,
|
|
16
18
|
GitHistory,
|
|
17
19
|
GitOrder,
|
|
@@ -110,9 +112,26 @@ export function gitHistory(
|
|
|
110
112
|
: { kind: "changed", commit };
|
|
111
113
|
},
|
|
112
114
|
order: (studyDir, decision) => orderIn(git, studyDir, decision),
|
|
115
|
+
fileAt: (studyDir, commit, path) => fileAtIn(git, studyDir, commit, path),
|
|
113
116
|
};
|
|
114
117
|
}
|
|
115
118
|
|
|
119
|
+
/** `path` (relative to `studyDir`) at `commit`: `ls-tree` says whether it is there, `show` gives its text. */
|
|
120
|
+
const fileAtIn = (
|
|
121
|
+
git: Git,
|
|
122
|
+
studyDir: AbsolutePath,
|
|
123
|
+
commit: string,
|
|
124
|
+
path: string,
|
|
125
|
+
): FileAt => {
|
|
126
|
+
const listed = git(studyDir, ["ls-tree", "--name-only", commit, "--", path]);
|
|
127
|
+
if ("why" in listed) return { kind: "unknown", why: listed.why };
|
|
128
|
+
if (listed.out.trim() === "") return { kind: "absent" };
|
|
129
|
+
const shown = git(studyDir, ["show", `${commit}:./${path}`]);
|
|
130
|
+
return "why" in shown
|
|
131
|
+
? { kind: "unknown", why: shown.why }
|
|
132
|
+
: { kind: "text", text: shown.out };
|
|
133
|
+
};
|
|
134
|
+
|
|
116
135
|
type Git = ReturnType<typeof gitIn>;
|
|
117
136
|
|
|
118
137
|
/** Whether `file` is in HEAD's tree (`git ls-tree`, which takes an absolute path in the worktree). */
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The schema of a plan v2's gates (src/domain/study-gates.ts). Both halves: a plan v2 with every gate
|
|
3
|
+
* parses to itself, ready, and a plan v1 stays as it was; every gate missing, misplaced or malformed
|
|
4
|
+
* is refused with its reason; a gate still null keeps the plan a draft naming it.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { expect, it } from "vitest";
|
|
10
|
+
import { PLAN_V2 } from "../../domain/study-gates.ts";
|
|
11
|
+
import type { DecideFirst } from "../../domain/study-mode.ts";
|
|
12
|
+
import { planText, type StudyPlan } from "../../domain/study-registry.ts";
|
|
13
|
+
import { planV2JsonSchema, studyRegistryCodec } from "./index.ts";
|
|
14
|
+
|
|
15
|
+
const DECIDED: DecideFirst = {
|
|
16
|
+
kind: "decide_before_run",
|
|
17
|
+
outputs: ["result.json"],
|
|
18
|
+
decision: { file: "repro/P1-S01-x/decision.md", note: "the draw" },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const V1: StudyPlan = {
|
|
22
|
+
question: "Do the readings disagree?",
|
|
23
|
+
population: {
|
|
24
|
+
source: "repositories, 2026-10-31",
|
|
25
|
+
draw: { kind: "seeded", method: "one rule per repository", seed: 7 },
|
|
26
|
+
},
|
|
27
|
+
arms: [],
|
|
28
|
+
primary_outcome: "share of rules whose readings disagree",
|
|
29
|
+
author_check: null,
|
|
30
|
+
mode: DECIDED,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const V2: StudyPlan = {
|
|
34
|
+
...V1,
|
|
35
|
+
$schema: PLAN_V2,
|
|
36
|
+
mode: { ...DECIDED, we_lose_if: { result: "share_upper95", below: 0.2 } },
|
|
37
|
+
second_check: [
|
|
38
|
+
{ step: "lint-checkable", min_kappa: 0.6 },
|
|
39
|
+
{ step: "admissible", min_kappa: 0.6 },
|
|
40
|
+
],
|
|
41
|
+
self_test: { made_by: "scripts/selftest.mjs" },
|
|
42
|
+
funnel: ["no linter rule", "clone failed"],
|
|
43
|
+
recall_audit: { n_files: 50, min_recall: 0.6 },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const parse = (p: unknown) =>
|
|
47
|
+
studyRegistryCodec.parsePlan(JSON.stringify(p), "measurement");
|
|
48
|
+
const why = (p: unknown): string => {
|
|
49
|
+
const r = parse(p);
|
|
50
|
+
return r.kind === "unreadable" ? r.why : r.kind;
|
|
51
|
+
};
|
|
52
|
+
const empty = (p: unknown) => {
|
|
53
|
+
const r = parse(p);
|
|
54
|
+
return r.kind === "draft" ? r.empty : r.kind;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
it("a plan v2 with every gate parses to itself, ready; a plan v1 stays ready", () => {
|
|
58
|
+
// Guards: the v2 fields round-trip through the text planText writes, and v1 is unchanged.
|
|
59
|
+
expect(
|
|
60
|
+
[V1, V2].map((p) =>
|
|
61
|
+
studyRegistryCodec.parsePlan(planText(p), "measurement"),
|
|
62
|
+
),
|
|
63
|
+
).toEqual([
|
|
64
|
+
{ kind: "ready", plan: V1 },
|
|
65
|
+
{ kind: "ready", plan: V2 },
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('"none" is a gate decided: a census with nothing labelled, tested or dropped is ready', () => {
|
|
70
|
+
const p = {
|
|
71
|
+
...V2,
|
|
72
|
+
mode: { ...DECIDED, we_lose_if: { result: "rate", above: 0.1 } },
|
|
73
|
+
second_check: "none",
|
|
74
|
+
self_test: "none",
|
|
75
|
+
funnel: "none",
|
|
76
|
+
recall_audit: "none",
|
|
77
|
+
};
|
|
78
|
+
// Guards: "not this study" has one spelling, and it is a decision, not a gap.
|
|
79
|
+
expect(parse(p)).toEqual({ kind: "ready", plan: p });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("every gate still null keeps the plan a draft naming it", () => {
|
|
83
|
+
const p = {
|
|
84
|
+
...V2,
|
|
85
|
+
mode: { ...DECIDED, we_lose_if: null },
|
|
86
|
+
second_check: null,
|
|
87
|
+
self_test: null,
|
|
88
|
+
funnel: null,
|
|
89
|
+
recall_audit: null,
|
|
90
|
+
};
|
|
91
|
+
// Guards: null is "not decided yet" — the study may not run on it.
|
|
92
|
+
expect(empty(p)).toEqual([
|
|
93
|
+
"mode.we_lose_if",
|
|
94
|
+
"second_check",
|
|
95
|
+
"self_test",
|
|
96
|
+
"funnel",
|
|
97
|
+
"recall_audit",
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("a plan v2 that leaves a gate out is no plan, the gate named", () => {
|
|
102
|
+
const noFunnel = { ...V2, funnel: undefined };
|
|
103
|
+
const modeWithout = DECIDED;
|
|
104
|
+
// Guards: a forgotten gate cannot be written — only "none" or null say "not this one".
|
|
105
|
+
expect(why(noFunnel)).toMatch(/^funnel: is missing, and a plan v2/u);
|
|
106
|
+
expect(why({ ...V2, recall_audit: undefined })).toMatch(
|
|
107
|
+
/^recall_audit: is missing, and a plan v2/u,
|
|
108
|
+
);
|
|
109
|
+
expect(why({ ...V2, mode: modeWithout })).toMatch(
|
|
110
|
+
/^mode\.we_lose_if: is missing/u,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("a plan without $schema that carries a gate is refused: nobody would read it", () => {
|
|
115
|
+
// Guards: a v1 plan cannot half-declare the gates.
|
|
116
|
+
expect(why({ ...V1, funnel: "none" })).toBe(
|
|
117
|
+
`funnel: is a field of a plan v2 — add "$schema": "${PLAN_V2}"`,
|
|
118
|
+
);
|
|
119
|
+
expect(why({ ...V1, recall_audit: "none" })).toBe(
|
|
120
|
+
`recall_audit: is a field of a plan v2 — add "$schema": "${PLAN_V2}"`,
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("an exploratory mode cannot carry we_lose_if, and an exploratory plan v2 needs none", () => {
|
|
125
|
+
const exploratory = { kind: "exploratory", reason: "a first look" };
|
|
126
|
+
// Guards: no claim, no falsifier — the shape refuses one.
|
|
127
|
+
expect(why({ ...V2, mode: { ...exploratory, we_lose_if: null } })).toContain(
|
|
128
|
+
"we_lose_if",
|
|
129
|
+
);
|
|
130
|
+
expect(parse({ ...V2, mode: exploratory }).kind).toBe("ready");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("each malformed gate is refused", () => {
|
|
134
|
+
const bad: readonly unknown[] = [
|
|
135
|
+
{ ...V2, $schema: "https://example.com/plan.json" },
|
|
136
|
+
{
|
|
137
|
+
...V2,
|
|
138
|
+
mode: { ...DECIDED, we_lose_if: { result: "x", below: 1, above: 2 } },
|
|
139
|
+
},
|
|
140
|
+
{ ...V2, mode: { ...DECIDED, we_lose_if: { result: "x" } } },
|
|
141
|
+
{ ...V2, second_check: [] },
|
|
142
|
+
{ ...V2, second_check: [{ step: "a", min_kappa: 0 }] },
|
|
143
|
+
{ ...V2, second_check: [{ step: "a", min_kappa: 1.2 }] },
|
|
144
|
+
{
|
|
145
|
+
...V2,
|
|
146
|
+
second_check: [
|
|
147
|
+
{ step: "a", min_kappa: 0.6 },
|
|
148
|
+
{ step: "a", min_kappa: 0.7 },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
{ ...V2, self_test: "n/a" },
|
|
152
|
+
{ ...V2, self_test: { made_by: "../outside.mjs" } },
|
|
153
|
+
{ ...V2, self_test: { must_catch: "bad.js" } },
|
|
154
|
+
{ ...V2, funnel: [] },
|
|
155
|
+
{ ...V2, funnel: ["clone failed", "clone failed"] },
|
|
156
|
+
{ ...V2, recall_audit: "n/a" },
|
|
157
|
+
{ ...V2, recall_audit: { n_files: 0, min_recall: 0.6 } },
|
|
158
|
+
{ ...V2, recall_audit: { n_files: 2.5, min_recall: 0.6 } },
|
|
159
|
+
{ ...V2, recall_audit: { n_files: 50, min_recall: 0 } },
|
|
160
|
+
{ ...V2, recall_audit: { n_files: 50, min_recall: 1.2 } },
|
|
161
|
+
{ ...V2, recall_audit: { n_files: 50 } },
|
|
162
|
+
];
|
|
163
|
+
// Guards: each of these is refused — none parses to a plan.
|
|
164
|
+
expect(bad.map((p) => parse(p).kind)).toEqual(bad.map(() => "unreadable"));
|
|
165
|
+
expect(why(bad[6])).toContain("the step «a» is listed more than once");
|
|
166
|
+
expect(why(bad[11])).toContain("the reason «clone failed»");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("the shipped schemas/plan.v2.schema.json is the one the parser generates", () => {
|
|
170
|
+
const shipped: unknown = JSON.parse(
|
|
171
|
+
readFileSync(
|
|
172
|
+
join(
|
|
173
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
174
|
+
"../../../schemas/plan.v2.schema.json",
|
|
175
|
+
),
|
|
176
|
+
"utf8",
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
// Guards: an editor's suggestions for a plan v2 never drift from what the parser accepts.
|
|
180
|
+
expect(shipped).toEqual(planV2JsonSchema());
|
|
181
|
+
});
|
|
@@ -44,6 +44,21 @@ import {
|
|
|
44
44
|
type Decision,
|
|
45
45
|
type StudyMode,
|
|
46
46
|
} from "../../domain/study-mode.ts";
|
|
47
|
+
import {
|
|
48
|
+
NONE,
|
|
49
|
+
PLAN_V2,
|
|
50
|
+
type Funnel,
|
|
51
|
+
type KappaStep,
|
|
52
|
+
type RecallAudit,
|
|
53
|
+
type SecondCheck,
|
|
54
|
+
type SelfTest,
|
|
55
|
+
type WeLoseIf,
|
|
56
|
+
} from "../../domain/study-gates.ts";
|
|
57
|
+
import {
|
|
58
|
+
CLAIM_LOST,
|
|
59
|
+
type ParsedResult,
|
|
60
|
+
type StudyResult,
|
|
61
|
+
} from "../../domain/study-result.ts";
|
|
47
62
|
import type { StudyRegistryCodec } from "../../ports/study-registry-codec.ts";
|
|
48
63
|
|
|
49
64
|
const study = z.looseObject({
|
|
@@ -190,12 +205,81 @@ const decisionShape = z.strictObject({ file: inPaper, note: z.string() });
|
|
|
190
205
|
/** The output globs: each once; none yet in a draft. */
|
|
191
206
|
const outputs = z.array(inFolder).superRefine(once("the output"));
|
|
192
207
|
|
|
208
|
+
// ── a plan v2's gates (src/domain/study-gates.ts) ───────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/** A threshold: a finite number. */
|
|
211
|
+
const threshold = z.number().refine(Number.isFinite, "is not a finite number");
|
|
212
|
+
|
|
213
|
+
/** When the claim loses: below a threshold or above one, exactly one of the two. */
|
|
214
|
+
const weLoseIf = z.union([
|
|
215
|
+
z.strictObject({ result: words, below: threshold }),
|
|
216
|
+
z.strictObject({ result: words, above: threshold }),
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
/** The literal that says "this study has none of it". */
|
|
220
|
+
const none = z.literal(NONE);
|
|
221
|
+
|
|
222
|
+
const kappaStep = z.strictObject({
|
|
223
|
+
step: words,
|
|
224
|
+
min_kappa: z
|
|
225
|
+
.number()
|
|
226
|
+
.gt(0, "is a kappa above 0")
|
|
227
|
+
.lte(1, "is a kappa of at most 1"),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
/** The steps judged by two raters, each named once; or none. */
|
|
231
|
+
const secondCheck = z.union([
|
|
232
|
+
none,
|
|
233
|
+
z
|
|
234
|
+
.array(kappaStep)
|
|
235
|
+
.min(1, 'lists no step — write "none" when nothing is labelled')
|
|
236
|
+
.superRefine((steps, ctx) => {
|
|
237
|
+
once("the step")(
|
|
238
|
+
steps.map((t) => t.step),
|
|
239
|
+
ctx,
|
|
240
|
+
);
|
|
241
|
+
}),
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
/** A path inside the study's folder: a file or a folder. */
|
|
245
|
+
const inStudyFolder = z
|
|
246
|
+
.string()
|
|
247
|
+
.min(1)
|
|
248
|
+
.refine(staysInside, "is not a path inside the study's folder");
|
|
249
|
+
|
|
250
|
+
const selfTest = z.union([
|
|
251
|
+
none,
|
|
252
|
+
z.strictObject({ made_by: inStudyFolder }),
|
|
253
|
+
z.strictObject({ must_catch: inStudyFolder, must_pass: inStudyFolder }),
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
const funnel = z.union([
|
|
257
|
+
none,
|
|
258
|
+
z
|
|
259
|
+
.array(words)
|
|
260
|
+
.min(1, 'lists no reason — write "none" when no unit is dropped')
|
|
261
|
+
.superRefine(once("the reason")),
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
/** The audit of the extractor: files a person reads in full, and the least recall in (0, 1]. */
|
|
265
|
+
const recallAudit = z.union([
|
|
266
|
+
none,
|
|
267
|
+
z.strictObject({
|
|
268
|
+
n_files: z.number().int().positive("is at least 1 file"),
|
|
269
|
+
min_recall: z
|
|
270
|
+
.number()
|
|
271
|
+
.gt(0, "is a recall above 0")
|
|
272
|
+
.lte(1, "is a recall of at most 1"),
|
|
273
|
+
}),
|
|
274
|
+
]);
|
|
275
|
+
|
|
193
276
|
/** How the outcomes are kept from the decisions: exactly one mode, by its `kind`. */
|
|
194
277
|
const mode = z.discriminatedUnion("kind", [
|
|
195
278
|
z.strictObject({
|
|
196
279
|
kind: z.literal("decide_before_run"),
|
|
197
280
|
outputs,
|
|
198
281
|
decision: decisionShape,
|
|
282
|
+
we_lose_if: weLoseIf.nullable().optional(),
|
|
199
283
|
}),
|
|
200
284
|
z.strictObject({ kind: z.literal("exploratory"), reason: z.string() }),
|
|
201
285
|
]);
|
|
@@ -209,8 +293,68 @@ const planShape = z.strictObject({
|
|
|
209
293
|
author_check: authorCheck.nullable(),
|
|
210
294
|
extra: extra.optional(),
|
|
211
295
|
mode: mode.optional(),
|
|
296
|
+
$schema: z.literal(PLAN_V2).optional(),
|
|
297
|
+
second_check: secondCheck.nullable().optional(),
|
|
298
|
+
self_test: selfTest.nullable().optional(),
|
|
299
|
+
funnel: funnel.nullable().optional(),
|
|
300
|
+
recall_audit: recallAudit.nullable().optional(),
|
|
212
301
|
});
|
|
213
302
|
|
|
303
|
+
/**
|
|
304
|
+
* The JSON Schema of a plan v2, generated from the parser's own shape: what an editor offers for a
|
|
305
|
+
* `plan.json` whose `$schema` names it. Shipped as `schemas/plan.v2.schema.json`; a test holds the
|
|
306
|
+
* file to this. The rules, not the schema, decide: a gate left out of a decide-first mode, or a
|
|
307
|
+
* draft, is theirs to report.
|
|
308
|
+
*/
|
|
309
|
+
export const planV2JsonSchema = (): unknown =>
|
|
310
|
+
z.toJSONSchema(
|
|
311
|
+
planShape.extend({
|
|
312
|
+
$schema: z.literal(PLAN_V2),
|
|
313
|
+
second_check: secondCheck.nullable(),
|
|
314
|
+
self_test: selfTest.nullable(),
|
|
315
|
+
funnel: funnel.nullable(),
|
|
316
|
+
recall_audit: recallAudit.nullable(),
|
|
317
|
+
}),
|
|
318
|
+
{ io: "input", unrepresentable: "any" },
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
/** The gates a plan v2 declares at its top, each present (null while undecided). */
|
|
322
|
+
const V2_GATES = [
|
|
323
|
+
"second_check",
|
|
324
|
+
"self_test",
|
|
325
|
+
"funnel",
|
|
326
|
+
"recall_audit",
|
|
327
|
+
] as const;
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* A plan v2 declares every gate, and a decide-first mode its `we_lose_if`; a plan v1 declares none
|
|
331
|
+
* of them: a gate in a plan that does not say it is v2 would be read by nobody.
|
|
332
|
+
*/
|
|
333
|
+
const gatesOfItsVersion = (
|
|
334
|
+
p: Readonly<z.output<typeof planShape>>,
|
|
335
|
+
ctx: Readonly<z.core.$RefinementCtx>,
|
|
336
|
+
): void => {
|
|
337
|
+
const v2 = p.$schema !== undefined;
|
|
338
|
+
const decided = p.mode?.kind === "decide_before_run" ? p.mode : undefined;
|
|
339
|
+
const gates = [
|
|
340
|
+
...V2_GATES.map((g) => ({ path: [g], value: p[g] })),
|
|
341
|
+
...(decided === undefined
|
|
342
|
+
? []
|
|
343
|
+
: [{ path: ["mode", "we_lose_if"], value: decided.we_lose_if }]),
|
|
344
|
+
];
|
|
345
|
+
gates
|
|
346
|
+
.filter((g) => (g.value === undefined) === v2)
|
|
347
|
+
.forEach((g) => {
|
|
348
|
+
ctx.addIssue({
|
|
349
|
+
code: "custom",
|
|
350
|
+
path: g.path,
|
|
351
|
+
message: v2
|
|
352
|
+
? 'is missing, and a plan v2 declares it — "none" when the study has none of it, null until decided'
|
|
353
|
+
: `is a field of a plan v2 — add "$schema": "${PLAN_V2}"`,
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
|
|
214
358
|
/**
|
|
215
359
|
* A plan under its old name, `prereg.json`, written by hand before the plan had a schema: open at
|
|
216
360
|
* every level, its own top-level keys kept in `extra`. A seed makes the draw seeded, none (or null) a
|
|
@@ -291,6 +435,60 @@ const modeEmpties = (
|
|
|
291
435
|
);
|
|
292
436
|
};
|
|
293
437
|
|
|
438
|
+
/** A count: a whole number, 0 or more. */
|
|
439
|
+
const count = z.number().int().nonnegative();
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* A study's `result.json` (src/domain/study-result.ts): open — the study's own numbers by any name —
|
|
443
|
+
* with the four parts the gates read, each of a fixed shape when present.
|
|
444
|
+
*/
|
|
445
|
+
const studyResult = z.looseObject({
|
|
446
|
+
claim: z.literal(CLAIM_LOST).optional(),
|
|
447
|
+
// Cohen's kappa lies in -1..1: a percentage (75) or a count is refused here, not compared with a bar.
|
|
448
|
+
kappa: z
|
|
449
|
+
.record(
|
|
450
|
+
z.string(),
|
|
451
|
+
z
|
|
452
|
+
.number()
|
|
453
|
+
.gte(-1, "is a kappa of at least -1")
|
|
454
|
+
.lte(1, "is a kappa of at most 1"),
|
|
455
|
+
)
|
|
456
|
+
.optional(),
|
|
457
|
+
self_test: z.strictObject({ passed: count, failed: count }).optional(),
|
|
458
|
+
funnel: z.record(z.string(), count).optional(),
|
|
459
|
+
// The counts of the audit, not a share: the files are checked against the plan, the recall computed.
|
|
460
|
+
recall_audit: z
|
|
461
|
+
.strictObject({
|
|
462
|
+
files: count,
|
|
463
|
+
units_in_files: z
|
|
464
|
+
.number()
|
|
465
|
+
.int()
|
|
466
|
+
.positive(
|
|
467
|
+
"is at least 1 unit — an audit that found none measures no recall",
|
|
468
|
+
),
|
|
469
|
+
extracted: count,
|
|
470
|
+
})
|
|
471
|
+
.refine(
|
|
472
|
+
(c) => c.extracted <= c.units_in_files,
|
|
473
|
+
"counts more units extracted than the person found in the files",
|
|
474
|
+
)
|
|
475
|
+
.optional(),
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
/** A plan v2's gates still null, by their field: decided is anything but null. */
|
|
479
|
+
const gateEmpties = (p: StudyPlan): readonly PlanTextField[] =>
|
|
480
|
+
PLAN_TEXT_FIELDS.filter(
|
|
481
|
+
(f) =>
|
|
482
|
+
(f === "mode.we_lose_if" &&
|
|
483
|
+
p.mode?.kind === "decide_before_run" &&
|
|
484
|
+
p.mode.we_lose_if === null) ||
|
|
485
|
+
((f === "second_check" ||
|
|
486
|
+
f === "self_test" ||
|
|
487
|
+
f === "funnel" ||
|
|
488
|
+
f === "recall_audit") &&
|
|
489
|
+
p[f] === null),
|
|
490
|
+
);
|
|
491
|
+
|
|
294
492
|
/** A plan that parsed: ready, or a draft. */
|
|
295
493
|
type ReadyOrDraft = Exclude<ParsedPlan, { readonly kind: "unreadable" }>;
|
|
296
494
|
|
|
@@ -308,6 +506,7 @@ const readyOrDraft =
|
|
|
308
506
|
const empty = [
|
|
309
507
|
...PLAN_TEXT_FIELDS.filter((f) => paths.includes(f)),
|
|
310
508
|
...modeEmpties(p, kind),
|
|
509
|
+
...gateEmpties(p),
|
|
311
510
|
];
|
|
312
511
|
if (empty.length === 0) return { kind: "ready", plan: p };
|
|
313
512
|
if (p.author_check === null)
|
|
@@ -321,7 +520,8 @@ const readyOrDraft =
|
|
|
321
520
|
};
|
|
322
521
|
|
|
323
522
|
/** A `plan.json` of a study of `kind`: strict, then ready or draft — a measurement's ready only with its mode. */
|
|
324
|
-
const planFor = (kind: StudyKind) =>
|
|
523
|
+
const planFor = (kind: StudyKind) =>
|
|
524
|
+
planShape.superRefine(gatesOfItsVersion).transform(readyOrDraft(kind));
|
|
325
525
|
|
|
326
526
|
/**
|
|
327
527
|
* A `prereg.json`: loose, mapped onto the plan, then ready or draft. Written before modes existed, it
|
|
@@ -368,6 +568,13 @@ export type SchemasAreTheDomain = [
|
|
|
368
568
|
Same<z.output<typeof legacyPlan>, ReadyOrDraft>,
|
|
369
569
|
Same<z.output<typeof mode>, StudyMode>,
|
|
370
570
|
Same<z.output<typeof decisionShape>, Decision>,
|
|
571
|
+
Same<z.output<typeof weLoseIf>, WeLoseIf>,
|
|
572
|
+
Same<z.output<typeof kappaStep>, KappaStep>,
|
|
573
|
+
Same<z.output<typeof secondCheck>, SecondCheck>,
|
|
574
|
+
Same<z.output<typeof selfTest>, SelfTest>,
|
|
575
|
+
Same<z.output<typeof funnel>, Funnel>,
|
|
576
|
+
Same<z.output<typeof recallAudit>, RecallAudit>,
|
|
577
|
+
Same<z.output<typeof studyResult>, StudyResult>,
|
|
371
578
|
] extends true[]
|
|
372
579
|
? true
|
|
373
580
|
: never;
|
|
@@ -427,8 +634,19 @@ export const parseStudyPlan = (text: string, kind: StudyKind): ParsedPlan =>
|
|
|
427
634
|
/** A plan still named `prereg.json`: loose, read only when a row names it. */
|
|
428
635
|
export const parseLegacyPlan = planParser(legacyPlan);
|
|
429
636
|
|
|
637
|
+
/** A study's `result.json` text, parsed once at the boundary: the result, or every reason it is none. */
|
|
638
|
+
export function parseStudyResult(text: string): ParsedResult {
|
|
639
|
+
const json = jsonOf(text);
|
|
640
|
+
if (json === undefined) return { kind: "unreadable", why: "not JSON" };
|
|
641
|
+
const parsed = studyResult.safeParse(json);
|
|
642
|
+
return parsed.success
|
|
643
|
+
? { kind: "read", result: parsed.data }
|
|
644
|
+
: { kind: "unreadable", why: reasons(parsed.error.issues) };
|
|
645
|
+
}
|
|
646
|
+
|
|
430
647
|
export const studyRegistryCodec: StudyRegistryCodec = {
|
|
431
648
|
parse: parseStudyRegistry,
|
|
432
649
|
parsePlan: parseStudyPlan,
|
|
433
650
|
parseLegacyPlan,
|
|
651
|
+
parseResult: parseStudyResult,
|
|
434
652
|
};
|
|
@@ -76,6 +76,7 @@ it("the template of a measurement is a draft decided before its run, its decisio
|
|
|
76
76
|
kind: "decide_before_run",
|
|
77
77
|
outputs: [],
|
|
78
78
|
decision: { file: "repro/P1-S02-x/decision.md", note: "" },
|
|
79
|
+
we_lose_if: null,
|
|
79
80
|
});
|
|
80
81
|
expect(empty(planText(t))).toEqual([
|
|
81
82
|
"question",
|
|
@@ -84,6 +85,11 @@ it("the template of a measurement is a draft decided before its run, its decisio
|
|
|
84
85
|
"primary_outcome",
|
|
85
86
|
"mode.outputs",
|
|
86
87
|
"mode.decision.note",
|
|
88
|
+
"mode.we_lose_if",
|
|
89
|
+
"second_check",
|
|
90
|
+
"self_test",
|
|
91
|
+
"funnel",
|
|
92
|
+
"recall_audit",
|
|
87
93
|
]);
|
|
88
94
|
expect(planTemplate("spike", "P1-S03-y").mode).toBeUndefined();
|
|
89
95
|
});
|
package/src/cli.ts
CHANGED
|
@@ -215,6 +215,7 @@ import {
|
|
|
215
215
|
outputsBeforeDecisionRules,
|
|
216
216
|
} from "./outputs-before-decision-rule.ts";
|
|
217
217
|
import { runStudies } from "./studies.ts";
|
|
218
|
+
import { RESULT_RULE_LEVELS, studyResultRules } from "./study-result-rule.ts";
|
|
218
219
|
import typography from "#eslint-rules/paper-typography";
|
|
219
220
|
import texBuild from "#eslint-rules/tex-build";
|
|
220
221
|
import reviewFrontmatter from "#eslint-rules/review-frontmatter";
|
|
@@ -490,11 +491,13 @@ const REPRO_RULES = {
|
|
|
490
491
|
...reproLayoutRules(LAYOUT_RULE_DEPS),
|
|
491
492
|
...studyRegistryRules(STUDY_RULE_DEPS),
|
|
492
493
|
...outputsBeforeDecisionRules({ ...STUDY_RULE_DEPS, git: GIT }),
|
|
494
|
+
...studyResultRules(STUDY_RULE_DEPS),
|
|
493
495
|
};
|
|
494
496
|
const REPRO_RULE_LEVELS = {
|
|
495
497
|
...REPRO_LAYOUT_RULE_LEVELS,
|
|
496
498
|
...STUDY_REGISTRY_RULE_LEVELS,
|
|
497
499
|
...ORDER_RULE_LEVELS,
|
|
500
|
+
...RESULT_RULE_LEVELS,
|
|
498
501
|
};
|
|
499
502
|
|
|
500
503
|
/** Today in this machine's time zone, `YYYY-MM-DD`: the date `paperlint studies new` registers a study on. */
|
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { expect, it } from "vitest";
|
|
7
7
|
import {
|
|
8
|
+
firstOutputAfter,
|
|
8
9
|
judgeOrder,
|
|
9
10
|
orderMessage,
|
|
10
11
|
type DecideFirstStudy,
|
|
11
12
|
type OrderFinding,
|
|
13
|
+
type PlanThen,
|
|
12
14
|
} from "./decide-first.ts";
|
|
15
|
+
import type { PlanGates } from "./study-gates.ts";
|
|
13
16
|
|
|
14
17
|
const D = { commit: "d".repeat(40), date: "2026-10-16T10:00:00+05:00" };
|
|
15
18
|
const A = { commit: "a".repeat(40), date: "2026-10-16T11:00:00+05:00" };
|
|
@@ -289,3 +292,131 @@ it("each finding's message: what is wrong, what to do, the rule's page", () => {
|
|
|
289
292
|
PAGE,
|
|
290
293
|
]);
|
|
291
294
|
});
|
|
295
|
+
|
|
296
|
+
// ── a plan v2's gates against the plan at the first output ──────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
const GATES: PlanGates = {
|
|
299
|
+
we_lose_if: { result: "share", below: 0.2 },
|
|
300
|
+
second_check: [{ step: "fits", min_kappa: 0.6 }],
|
|
301
|
+
self_test: "none",
|
|
302
|
+
funnel: ["no linter rule"],
|
|
303
|
+
recall_audit: "none",
|
|
304
|
+
};
|
|
305
|
+
const OUT = { path: "RESULTS.md", ...A, order: "after" as const };
|
|
306
|
+
const withGates = (
|
|
307
|
+
then: PlanThen,
|
|
308
|
+
now: PlanGates = GATES,
|
|
309
|
+
): DecideFirstStudy => ({
|
|
310
|
+
...STUDY,
|
|
311
|
+
gates: { now, output: OUT, then },
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("the first output after the decision is the one the gates are held to; none while none followed it", () => {
|
|
315
|
+
// Guards: the study's own files and outputs not after the decision are not it.
|
|
316
|
+
expect([
|
|
317
|
+
firstOutputAfter(
|
|
318
|
+
STUDY,
|
|
319
|
+
STUDY.facts.kind === "read" ? STUDY.facts.files : [],
|
|
320
|
+
),
|
|
321
|
+
firstOutputAfter(STUDY, [{ path: "RESULTS.md", ...D, order: "same" }]),
|
|
322
|
+
]).toEqual([OUT, null]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("gates as they stood at the first output are silent; each one changed since is a finding", () => {
|
|
326
|
+
const moved: PlanGates = {
|
|
327
|
+
...GATES,
|
|
328
|
+
we_lose_if: { result: "share", below: 0.1 },
|
|
329
|
+
funnel: ["no linter rule", "clone failed"],
|
|
330
|
+
recall_audit: { n_files: 50, min_recall: 0.5 },
|
|
331
|
+
};
|
|
332
|
+
const at = {
|
|
333
|
+
id: "P1-S01",
|
|
334
|
+
folder: "P1-S01-x",
|
|
335
|
+
plan: "plan.json",
|
|
336
|
+
output: OUT,
|
|
337
|
+
};
|
|
338
|
+
// Guards: deep equality, not identity; one finding per changed gate, in the gates' order.
|
|
339
|
+
expect([
|
|
340
|
+
judgeOrder(withGates({ kind: "gates", gates: structuredClone(GATES) })),
|
|
341
|
+
judgeOrder(withGates({ kind: "gates", gates: GATES }, moved)),
|
|
342
|
+
]).toEqual([
|
|
343
|
+
[],
|
|
344
|
+
[
|
|
345
|
+
{
|
|
346
|
+
problem: "gates-changed",
|
|
347
|
+
...at,
|
|
348
|
+
gate: "we_lose_if",
|
|
349
|
+
then: { result: "share", below: 0.2 },
|
|
350
|
+
now: { result: "share", below: 0.1 },
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
problem: "gates-changed",
|
|
354
|
+
...at,
|
|
355
|
+
gate: "funnel",
|
|
356
|
+
then: ["no linter rule"],
|
|
357
|
+
now: ["no linter rule", "clone failed"],
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
problem: "gates-changed",
|
|
361
|
+
...at,
|
|
362
|
+
gate: "recall_audit",
|
|
363
|
+
then: "none",
|
|
364
|
+
now: { n_files: 50, min_recall: 0.5 },
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
]);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("no gates at the first output is a finding with its reason; a history git cannot read is said", () => {
|
|
371
|
+
const at = {
|
|
372
|
+
id: "P1-S01",
|
|
373
|
+
folder: "P1-S01-x",
|
|
374
|
+
plan: "plan.json",
|
|
375
|
+
output: OUT,
|
|
376
|
+
};
|
|
377
|
+
expect([
|
|
378
|
+
judgeOrder(withGates({ kind: "no-gates", why: "absent" })),
|
|
379
|
+
judgeOrder(withGates({ kind: "unknown", why: "fatal: x" })),
|
|
380
|
+
]).toEqual([
|
|
381
|
+
[{ problem: "gates-not-fixed", ...at, why: "absent" }],
|
|
382
|
+
[{ problem: "no-history", id: "P1-S01", why: "fatal: x" }],
|
|
383
|
+
]);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("the gates' messages: which gate, then and now, the output's commit, and what to do", () => {
|
|
387
|
+
const at = {
|
|
388
|
+
id: "P1-S01",
|
|
389
|
+
folder: "P1-S01-x",
|
|
390
|
+
plan: "plan.json",
|
|
391
|
+
output: OUT,
|
|
392
|
+
};
|
|
393
|
+
const when = `\`RESULTS.md\`, entered history in ${"a".repeat(12)} (${A.date})`;
|
|
394
|
+
const fix =
|
|
395
|
+
"What to do: run the study again now that the plan v2 is committed; if the outputs must stand, declare the study `exploratory` with the reason.";
|
|
396
|
+
// Guards: the whole text for each finding and each reason.
|
|
397
|
+
expect(
|
|
398
|
+
[
|
|
399
|
+
{
|
|
400
|
+
problem: "gates-changed" as const,
|
|
401
|
+
...at,
|
|
402
|
+
gate: "we_lose_if" as const,
|
|
403
|
+
then: { result: "share", below: 0.2 },
|
|
404
|
+
now: { result: "share", below: 0.1 },
|
|
405
|
+
},
|
|
406
|
+
...(["absent", "v1", "unreadable"] as const).map((why) => ({
|
|
407
|
+
problem: "gates-not-fixed" as const,
|
|
408
|
+
...at,
|
|
409
|
+
why,
|
|
410
|
+
})),
|
|
411
|
+
].map(orderMessage),
|
|
412
|
+
).toEqual([
|
|
413
|
+
`The gate \`we_lose_if\` in the plan of \`P1-S01\`, \`repro/P1-S01-x/plan.json\`, was {"result":"share","below":0.2} when its first output, ${when}, and is {"result":"share","below":0.1} now. A gate is fixed before the run: moved after a result exists, it can only follow the result. What to do: put the gate back as it was; if it was wrong, say so in the paper and run the study again under the corrected plan — never edit the history to make it pass.` +
|
|
414
|
+
PAGE,
|
|
415
|
+
`The plan of \`P1-S01\`, \`repro/P1-S01-x/plan.json\`, was not in history yet when its first output, ${when}: its gates were not fixed before the run. ${fix}` +
|
|
416
|
+
PAGE,
|
|
417
|
+
`The plan of \`P1-S01\`, \`repro/P1-S01-x/plan.json\`, was a plan v1, which declares no gates, when its first output, ${when}: its gates were not fixed before the run. ${fix}` +
|
|
418
|
+
PAGE,
|
|
419
|
+
`The plan of \`P1-S01\`, \`repro/P1-S01-x/plan.json\`, was no plan paperlint could read when its first output, ${when}: its gates were not fixed before the run. ${fix}` +
|
|
420
|
+
PAGE,
|
|
421
|
+
]);
|
|
422
|
+
});
|