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.
Files changed (114) hide show
  1. package/CLAUDE.md +9 -1
  2. package/dist/adapters/git/index.d.ts.map +1 -1
  3. package/dist/adapters/git/index.js +13 -0
  4. package/dist/adapters/git/index.js.map +1 -1
  5. package/dist/adapters/study-registry/index.d.ts +142 -1
  6. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  7. package/dist/adapters/study-registry/index.js +160 -1
  8. package/dist/adapters/study-registry/index.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +3 -0
  11. package/dist/cli.js.map +1 -1
  12. package/dist/domain/decide-first.d.ts +43 -0
  13. package/dist/domain/decide-first.d.ts.map +1 -1
  14. package/dist/domain/decide-first.js +45 -2
  15. package/dist/domain/decide-first.js.map +1 -1
  16. package/dist/domain/study-gates.d.ts +96 -0
  17. package/dist/domain/study-gates.d.ts.map +1 -0
  18. package/dist/domain/study-gates.js +56 -0
  19. package/dist/domain/study-gates.js.map +1 -0
  20. package/dist/domain/study-mode.d.ts +6 -0
  21. package/dist/domain/study-mode.d.ts.map +1 -1
  22. package/dist/domain/study-mode.js.map +1 -1
  23. package/dist/domain/study-registry.d.ts +24 -4
  24. package/dist/domain/study-registry.d.ts.map +1 -1
  25. package/dist/domain/study-registry.js +39 -2
  26. package/dist/domain/study-registry.js.map +1 -1
  27. package/dist/domain/study-result.d.ts +151 -0
  28. package/dist/domain/study-result.d.ts.map +1 -0
  29. package/dist/domain/study-result.js +175 -0
  30. package/dist/domain/study-result.js.map +1 -0
  31. package/dist/outputs-before-decision-rule.d.ts.map +1 -1
  32. package/dist/outputs-before-decision-rule.js +37 -3
  33. package/dist/outputs-before-decision-rule.js.map +1 -1
  34. package/dist/ports/git-history.d.ts +12 -0
  35. package/dist/ports/git-history.d.ts.map +1 -1
  36. package/dist/ports/study-registry-codec.d.ts +3 -0
  37. package/dist/ports/study-registry-codec.d.ts.map +1 -1
  38. package/dist/skills/build-label-page/scripts/label-page-cli.d.ts +6 -0
  39. package/dist/skills/build-label-page/scripts/label-page-commands.d.ts +14 -0
  40. package/dist/skills/build-label-page/scripts/label-page-core.d.ts +149 -0
  41. package/dist/skills/build-label-page/scripts/label-page-html.d.ts +5 -0
  42. package/dist/skills/build-label-page/scripts/label-page.d.ts +2 -0
  43. package/dist/study-modes.d.ts +3 -0
  44. package/dist/study-modes.d.ts.map +1 -1
  45. package/dist/study-modes.js +8 -1
  46. package/dist/study-modes.js.map +1 -1
  47. package/dist/study-registry-rule.d.ts.map +1 -1
  48. package/dist/study-registry-rule.js +1 -0
  49. package/dist/study-registry-rule.js.map +1 -1
  50. package/dist/study-result-rule.d.ts +12 -0
  51. package/dist/study-result-rule.d.ts.map +1 -0
  52. package/dist/study-result-rule.js +87 -0
  53. package/dist/study-result-rule.js.map +1 -0
  54. package/docs/design/study-gates.md +208 -0
  55. package/docs/rules/repro/outputs-before-decision.md +8 -0
  56. package/docs/rules/repro/study-registered.md +38 -15
  57. package/docs/rules/repro/study-result.md +100 -0
  58. package/docs/rules.md +1 -0
  59. package/eslint.config.mjs +1 -1
  60. package/hooks/hooks.harness.ts +51 -13
  61. package/hooks/paper-skills-nudge.hook.d.mts +22 -0
  62. package/hooks/paper-skills-nudge.hook.mjs +85 -23
  63. package/hooks/paper-skills-nudge.test.ts +82 -4
  64. package/package.json +2 -1
  65. package/plugin/hooks/hooks.json +7 -2
  66. package/schemas/plan.v2.schema.json +341 -0
  67. package/skills/argument-arc/SKILL.md.spec.ts +1 -1
  68. package/skills/build-label-page/SKILL.md +85 -54
  69. package/skills/build-label-page/SKILL.md.spec.ts +85 -53
  70. package/skills/build-label-page/build-label-page.test.ts +10 -941
  71. package/skills/build-label-page/references/checklist.md +45 -3
  72. package/skills/build-label-page/references/closed-choice-generator.md +88 -0
  73. package/skills/build-label-page/scripts/label-page-cli.js +72 -0
  74. package/skills/build-label-page/scripts/label-page-cli.ts +85 -0
  75. package/skills/build-label-page/scripts/label-page-commands.js +14 -0
  76. package/skills/build-label-page/scripts/label-page-commands.ts +14 -0
  77. package/skills/build-label-page/scripts/label-page-core.js +850 -0
  78. package/skills/build-label-page/scripts/label-page-core.test.ts +1164 -0
  79. package/skills/build-label-page/scripts/label-page-core.ts +1210 -0
  80. package/skills/build-label-page/scripts/label-page-html.js +33 -0
  81. package/skills/build-label-page/scripts/label-page-html.test.ts +413 -0
  82. package/skills/build-label-page/scripts/label-page-html.ts +43 -0
  83. package/skills/build-label-page/scripts/label-page.js +70 -0
  84. package/skills/build-label-page/scripts/label-page.test.ts +71 -0
  85. package/skills/build-label-page/scripts/label-page.ts +71 -0
  86. package/skills/build-label-page/scripts/page-template.html +513 -0
  87. package/skills/find-venue/SKILL.md.spec.ts +1 -1
  88. package/skills/paper-status/SKILL.md.spec.ts +1 -1
  89. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  90. package/src/adapters/git/index.test.ts +33 -0
  91. package/src/adapters/git/index.ts +19 -0
  92. package/src/adapters/study-registry/gates.test.ts +181 -0
  93. package/src/adapters/study-registry/index.ts +219 -1
  94. package/src/adapters/study-registry/mode.test.ts +6 -0
  95. package/src/cli.ts +3 -0
  96. package/src/domain/decide-first.test.ts +131 -0
  97. package/src/domain/decide-first.ts +92 -2
  98. package/src/domain/study-gates.ts +125 -0
  99. package/src/domain/study-measurements.test.ts +71 -0
  100. package/src/domain/study-mode.ts +6 -0
  101. package/src/domain/study-registry.ts +64 -2
  102. package/src/domain/study-result.test.ts +300 -0
  103. package/src/domain/study-result.ts +358 -0
  104. package/src/outputs-before-decision-rule.test.ts +115 -2
  105. package/src/outputs-before-decision-rule.ts +50 -2
  106. package/src/ports/git-history.ts +12 -0
  107. package/src/ports/study-registry-codec.ts +3 -0
  108. package/src/rule-cases.test.ts +74 -0
  109. package/src/studies.test.ts +3 -0
  110. package/src/study-modes.ts +10 -1
  111. package/src/study-registry-rule.ts +1 -0
  112. package/src/study-result-rule.test.ts +281 -0
  113. package/src/study-result-rule.ts +123 -0
  114. package/skills/build-label-page/references/example-generator.py +0 -803
@@ -15,8 +15,10 @@ import {
15
15
  } from "./adapters/node/index.ts";
16
16
  import { studyRegistryCodec } from "./adapters/study-registry/index.ts";
17
17
  import type { StudyMode } from "./domain/study-mode.ts";
18
+ import { PLAN_V2 } from "./domain/study-gates.ts";
18
19
  import { planText, registryText } from "./domain/study-registry.ts";
19
20
  import { outputsBeforeDecisionRules } from "./outputs-before-decision-rule.ts";
21
+ import type { GitOrder } from "./ports/git-history.ts";
20
22
  import { commit, initRepo } from "../test/git-repo.ts";
21
23
  import { useTempDir } from "../test/support.ts";
22
24
  import { ROW, planOf } from "../test/study-tree.ts";
@@ -38,12 +40,18 @@ const T = (h: number): string =>
38
40
  `2026-10-16T${String(h).padStart(2, "0")}:00:00+05:00`;
39
41
 
40
42
  /** The rule's message ids over the paper in `repo`, reached through `file`. */
41
- const lint = (repo: string, file = "p/PIPELINE-STATUS.md"): string[] => {
43
+ const lint = (
44
+ repo: string,
45
+ file = "p/PIPELINE-STATUS.md",
46
+ git: GitOrder = gitHistory(spawnProcess(), {
47
+ PATH: String(process.env.PATH),
48
+ }),
49
+ ): string[] => {
42
50
  const rule = outputsBeforeDecisionRules({
43
51
  files: nodeFiles,
44
52
  entries: nodeListEntries,
45
53
  codec: studyRegistryCodec,
46
- git: gitHistory(spawnProcess(), { PATH: String(process.env.PATH) }),
54
+ git,
47
55
  })["outputs-before-decision"];
48
56
  const ids: string[] = [];
49
57
  rule
@@ -249,3 +257,108 @@ it("loud on a decision under a linked directory: git records the link, not the f
249
257
  // would be read from the link's history. The study is reported instead.
250
258
  expect(lint(repo)).toEqual(["decision-linked"]);
251
259
  });
260
+
261
+ // ── a plan v2's gates are fixed before the first output ─────────────────────────────────────────
262
+
263
+ /** A ready plan v2 of the decide-first study, losing below `below`, its question `question`. */
264
+ const v2 = (below: number, question = "Does the hint help?"): string =>
265
+ planText({
266
+ ...planOf({ ...DECIDED, we_lose_if: { result: "share", below } }),
267
+ question,
268
+ $schema: PLAN_V2,
269
+ second_check: [{ step: "fits", min_kappa: 0.6 }],
270
+ self_test: "none",
271
+ funnel: ["no linter rule"],
272
+ recall_audit: "none",
273
+ });
274
+ const V2_PAPER = { ...PAPER, [`${S}/plan.json`]: v2(0.2) };
275
+
276
+ /** The decided history: the paper with its plan, then the decision, then an output. */
277
+ const decidedRun = (
278
+ name: string,
279
+ first: Readonly<Record<string, string>> = V2_PAPER,
280
+ ): string => {
281
+ const repo = join(root, name);
282
+ initRepo(repo);
283
+ commit(repo, first, T(9));
284
+ commit(repo, { [`${S}/decision.md`]: "exclude run 7\n" }, T(10));
285
+ commit(repo, { [`${S}/RESULTS.md`]: "41 of 60\n" }, T(11));
286
+ return repo;
287
+ };
288
+
289
+ it("loud when a gate of a plan v2 changed after the first output: the threshold cannot follow the result", () => {
290
+ const repo = decidedRun("gate-moved");
291
+ commit(repo, { [`${S}/plan.json`]: v2(0.1) }, T(12));
292
+ expect(lint(repo)).toEqual(["gates-changed"]);
293
+ });
294
+
295
+ it("loud when the plan v2 entered history only after the first output: its gates were not fixed before it", () => {
296
+ const repo = decidedRun(
297
+ "gate-late",
298
+ Object.fromEntries(
299
+ Object.entries(V2_PAPER).filter(([k]) => k !== `${S}/plan.json`),
300
+ ),
301
+ );
302
+ commit(repo, { [`${S}/plan.json`]: v2(0.2) }, T(12));
303
+ expect(lint(repo)).toEqual(["gates-not-fixed"]);
304
+ });
305
+
306
+ it("silent when the gates stand as they were at the first output, even if other text of the plan changed", () => {
307
+ const same = decidedRun("gate-same");
308
+ const reworded = decidedRun("gate-reworded");
309
+ commit(
310
+ reworded,
311
+ { [`${S}/plan.json`]: v2(0.2, "Do the hints help?") },
312
+ T(12),
313
+ );
314
+ const v1 = decidedRun("gate-v1", PAPER);
315
+ commit(v1, { [`${S}/plan.json`]: planText(planOf(DECIDED)) + "\n" }, T(12));
316
+ // Guards: an unchanged plan; a non-gate edit; a plan v1, which has no gates to freeze.
317
+ expect([lint(same), lint(reworded), lint(v1)]).toEqual([[], [], []]);
318
+ });
319
+
320
+ it("loud when the plan at the first output was a v1 or no plan at all, and says why when git cannot show it", () => {
321
+ const v1 = decidedRun("gate-was-v1", PAPER);
322
+ commit(v1, { [`${S}/plan.json`]: v2(0.2) }, T(12));
323
+ const garbled = decidedRun("gate-was-garbled", {
324
+ ...V2_PAPER,
325
+ [`${S}/plan.json`]: "{ not json",
326
+ });
327
+ commit(garbled, { [`${S}/plan.json`]: v2(0.2) }, T(12));
328
+ const real = gitHistory(spawnProcess(), { PATH: String(process.env.PATH) });
329
+ const blind = {
330
+ ...real,
331
+ fileAt: () => ({ kind: "unknown", why: "git show failed" }) as const,
332
+ };
333
+ expect([
334
+ lint(v1),
335
+ lint(garbled),
336
+ lint(decidedRun("gate-blind"), undefined, blind),
337
+ ]).toEqual([["gates-not-fixed"], ["gates-not-fixed"], ["no-history"]]);
338
+ });
339
+
340
+ it("asks nothing of the gates while no output followed the decision, or no order could be read", () => {
341
+ const waiting = join(root, "gate-waiting");
342
+ initRepo(waiting);
343
+ commit(waiting, V2_PAPER, T(9));
344
+ commit(waiting, { [`${S}/decision.md`]: "exclude run 7\n" }, T(10));
345
+ const dir = "repro/P1-S01-outcome/decisions";
346
+ const unread = join(root, "gate-unread");
347
+ initRepo(unread);
348
+ commit(
349
+ unread,
350
+ {
351
+ ...V2_PAPER,
352
+ [`${S}/plan.json`]: v2(0.2).replace(
353
+ '"file": "repro/P1-S01-outcome/decision.md"',
354
+ `"file": "${dir}"`,
355
+ ),
356
+ [`p/${dir}/a.md`]: "exclude run 7\n",
357
+ },
358
+ T(9),
359
+ );
360
+ commit(unread, { [`${S}/RESULTS.md`]: "41 of 60\n" }, T(10));
361
+ // Guards: a plan v2 whose study has not run yet; one whose history yields no order is reported
362
+ // for that alone, never for gates it could not compare.
363
+ expect([lint(waiting), lint(unread)]).toEqual([[], ["decision-not-file"]]);
364
+ });
@@ -18,11 +18,16 @@ import { filter, map, pipe } from "remeda";
18
18
  import { callerPath } from "./caller-path.ts";
19
19
  import { decisionEntry, modeStudies, type ModeStudy } from "./study-modes.ts";
20
20
  import {
21
+ firstOutputAfter,
21
22
  judgeOrder,
22
23
  orderMessage,
23
24
  type DecideFirstStudy,
25
+ type GateHistory,
24
26
  type OrderFinding,
27
+ type PlanThen,
25
28
  } from "./domain/decide-first.ts";
29
+ import { gatesOf, type PlanGates } from "./domain/study-gates.ts";
30
+ import type { StudyKind } from "./domain/study-registry.ts";
26
31
  import { REPRO_DIR } from "./domain/repro-layout.ts";
27
32
  import type { DecideFirst } from "./domain/study-mode.ts";
28
33
  import type {
@@ -51,11 +56,11 @@ export const decideFirstStudies = (
51
56
  return pipe(
52
57
  modeStudies(registryOf(paper, deps), reader, deps.codec),
53
58
  filter(isDecidedAndReady),
54
- map(({ row, plan, mode }): DecideFirstStudy => {
59
+ map(({ row, plan, mode, gates }): DecideFirstStudy => {
55
60
  const folder = join(paper, REPRO_DIR, row.folder);
56
61
  const decision = join(paper, mode.decision.file);
57
62
  const on = decisionEntry(reader, mode.decision.file);
58
- return {
63
+ const study: DecideFirstStudy = {
59
64
  id: row.id,
60
65
  folder: row.folder,
61
66
  plan,
@@ -66,10 +71,51 @@ export const decideFirstStudies = (
66
71
  ? deps.git.order(callerPath(folder), callerPath(decision))
67
72
  : on,
68
73
  };
74
+ return {
75
+ ...study,
76
+ gates: gateHistory(study, { now: gates, kind: row.kind, folder }, deps),
77
+ };
69
78
  }),
70
79
  );
71
80
  };
72
81
 
82
+ /**
83
+ * The gates of a plan v2 now, and the plan at the study's first output after its decision: none for
84
+ * a plan v1, a history not read, or a study with no such output yet.
85
+ */
86
+ const gateHistory = (
87
+ s: DecideFirstStudy,
88
+ from: {
89
+ readonly now: PlanGates | null;
90
+ readonly kind: StudyKind;
91
+ readonly folder: string;
92
+ },
93
+ deps: OrderDeps,
94
+ ): GateHistory | undefined => {
95
+ if (from.now === null || s.facts.kind !== "read") return undefined;
96
+ const output = firstOutputAfter(s, s.facts.files);
97
+ if (output === null) return undefined;
98
+ const at = deps.git.fileAt(callerPath(from.folder), output.commit, s.plan);
99
+ return { now: from.now, output, then: planThen(at, from.kind, deps) };
100
+ };
101
+
102
+ /** What the plan held at that commit, by gates. */
103
+ const planThen = (
104
+ at: ReturnType<OrderDeps["git"]["fileAt"]>,
105
+ kind: StudyKind,
106
+ deps: OrderDeps,
107
+ ): PlanThen => {
108
+ if (at.kind === "unknown") return at;
109
+ if (at.kind === "absent") return { kind: "no-gates", why: "absent" };
110
+ const parsed = deps.codec.parsePlan(at.text, kind);
111
+ if (parsed.kind === "unreadable")
112
+ return { kind: "no-gates", why: "unreadable" };
113
+ const gates = gatesOf(parsed.plan);
114
+ return gates === null
115
+ ? { kind: "no-gates", why: "v1" }
116
+ : { kind: "gates", gates };
117
+ };
118
+
73
119
  /** Only a ready plan decides: a draft may name no outputs, or an empty decision file. */
74
120
  const isDecidedAndReady = (
75
121
  s: ModeStudy,
@@ -85,6 +131,8 @@ const PROBLEMS: readonly OrderFinding["problem"][] = [
85
131
  "shallow",
86
132
  "undecided-output",
87
133
  "output-before-decision",
134
+ "gates-changed",
135
+ "gates-not-fixed",
88
136
  ];
89
137
 
90
138
  const NAME = "repro/outputs-before-decision";
@@ -35,10 +35,22 @@ export interface GitHistory {
35
35
  readonly committed: (paper: AbsolutePath, file: AbsolutePath) => Committed;
36
36
  }
37
37
 
38
+ /** A file as it stood at a commit: its text, not there, or why git could not say. */
39
+ export type FileAt =
40
+ | { readonly kind: "text"; readonly text: string }
41
+ | { readonly kind: "absent" }
42
+ | { readonly kind: "unknown"; readonly why: string };
43
+
38
44
  export interface GitOrder {
39
45
  /** The order of `decision`'s first commit against every file's under `studyDir` (a folder that exists). */
40
46
  readonly order: (
41
47
  studyDir: AbsolutePath,
42
48
  decision: AbsolutePath,
43
49
  ) => OrderFacts;
50
+ /** `path` (relative to `studyDir`) as it stood at `commit`: what a plan's gates were at the first output. */
51
+ readonly fileAt: (
52
+ studyDir: AbsolutePath,
53
+ commit: string,
54
+ path: string,
55
+ ) => FileAt;
44
56
  }
@@ -8,6 +8,7 @@ import type {
8
8
  ParsedRegistry,
9
9
  StudyKind,
10
10
  } from "../domain/study-registry.ts";
11
+ import type { ParsedResult } from "../domain/study-result.ts";
11
12
 
12
13
  export interface StudyRegistryCodec {
13
14
  /** A `studies.json`'s text: the registry, or the one reason it is not one of this schema. */
@@ -19,4 +20,6 @@ export interface StudyRegistryCodec {
19
20
  readonly parsePlan: (text: string, kind: StudyKind) => ParsedPlan;
20
21
  /** A plan still named `prereg.json`, loose: the same three outcomes. */
21
22
  readonly parseLegacyPlan: (text: string) => ParsedPlan;
23
+ /** A study's `result.json`: its numbers, or why it is none (src/domain/study-result.ts). */
24
+ readonly parseResult: (text: string) => ParsedResult;
22
25
  }
@@ -19,6 +19,7 @@ import { run, rulePlugins } from "./cli.ts";
19
19
  import { sha256Hex } from "./domain/sha256.ts";
20
20
  import { lockText, type LockEntry } from "./domain/versions.ts";
21
21
  import type { StudyMode } from "./domain/study-mode.ts";
22
+ import { PLAN_V2 } from "./domain/study-gates.ts";
22
23
  import {
23
24
  planTemplate,
24
25
  planText,
@@ -127,6 +128,54 @@ const STUDY_IN_MODE = (
127
128
  };
128
129
  };
129
130
 
131
+ /**
132
+ * The registered study, done on a ready plan v2 — its claim lost below 0.5, kappa 0.6 on one step, a
133
+ * self-test, one drop reason — with `result` as its `result.json` (none when null).
134
+ */
135
+ const DONE_ON_PLAN_V2 = (
136
+ result: Readonly<Record<string, unknown>> | null,
137
+ ): Readonly<Record<string, string>> => {
138
+ const r: StudyRegistry = {
139
+ ...REGISTRY,
140
+ studies: REGISTRY.studies.map((s) => ({
141
+ ...s,
142
+ status: "done",
143
+ result: "0.7 pass",
144
+ result_file: "README.md",
145
+ plan: "plan.json",
146
+ })),
147
+ };
148
+ const plan: StudyPlan = {
149
+ $schema: PLAN_V2,
150
+ question: "Which step does the work?",
151
+ population: { source: "tasks", draw: { kind: "all", method: "census" } },
152
+ arms: [],
153
+ primary_outcome: "pass rate",
154
+ author_check: null,
155
+ mode: {
156
+ kind: "exploratory",
157
+ reason: "the rule judges the result, not the order",
158
+ },
159
+ second_check: [{ step: "pass", min_kappa: 0.6 }],
160
+ self_test: { made_by: "selftest.mjs" },
161
+ funnel: ["timed out"],
162
+ recall_audit: "none",
163
+ };
164
+ const facts: IndexInput = {
165
+ plans: new Map([["P1-S01-ablation/plan.json", { kind: "ready", plan }]]),
166
+ history: new Map(),
167
+ };
168
+ return {
169
+ [`${P}/repro/studies.json`]: registryText(r),
170
+ [`${P}/repro/README.md`]: withIndex("# repro\n", r, facts),
171
+ [`${P}/repro/P1-S01-ablation/README.md`]: "# ablation\n",
172
+ [`${P}/repro/P1-S01-ablation/plan.json`]: planText(plan),
173
+ ...(result === null
174
+ ? {}
175
+ : { [`${P}/repro/P1-S01-ablation/result.json`]: JSON.stringify(result) }),
176
+ };
177
+ };
178
+
130
179
  /** `study/declare-sample` is off by default; a paper turns it on in its `paperlint.json`. */
131
180
  const DECLARE_SAMPLE_ON: Readonly<Record<string, string>> = {
132
181
  [`${P}/paperlint.json`]: JSON.stringify({
@@ -992,6 +1041,31 @@ const CASES: Readonly<Record<string, RuleCases>> = {
992
1041
  ),
993
1042
  ),
994
1043
  },
1044
+ // A done study on a plan v2 whose result.json counts no drop for its declared funnel reason.
1045
+ "repro/study-result": {
1046
+ reports: {
1047
+ tree: paper(
1048
+ tex("x"),
1049
+ DONE_ON_PLAN_V2({
1050
+ kappa: { pass: 0.71 },
1051
+ self_test: { passed: 4, failed: 0 },
1052
+ funnel: {},
1053
+ }),
1054
+ ),
1055
+ file: `${P}/PIPELINE-STATUS.md`,
1056
+ severity: 2,
1057
+ line: 1,
1058
+ },
1059
+ // The same study, every gate shown in its result.json.
1060
+ silent: paper(
1061
+ tex("x"),
1062
+ DONE_ON_PLAN_V2({
1063
+ kappa: { pass: 0.71 },
1064
+ self_test: { passed: 4, failed: 0 },
1065
+ funnel: { "timed out": 2 },
1066
+ }),
1067
+ ),
1068
+ },
995
1069
  // A study's plan still named prereg.json: read as its plan, and a warning to rename it.
996
1070
  "repro/study-plan-name": {
997
1071
  reports: {
@@ -93,6 +93,9 @@ function studies(
93
93
  decisionDeleted: false,
94
94
  files: [],
95
95
  }),
96
+ fileAt: () => {
97
+ throw new Error("new and index never read a file at a commit");
98
+ },
96
99
  },
97
100
  log: (l) => log.push(l),
98
101
  err: (l) => err.push(l),
@@ -7,6 +7,7 @@
7
7
  import { posix } from "node:path";
8
8
  import { filter, map, pipe } from "remeda";
9
9
  import type { NotAFile } from "./domain/decide-first.ts";
10
+ import { gatesOf, type PlanGates } from "./domain/study-gates.ts";
10
11
  import type { StudyMode } from "./domain/study-mode.ts";
11
12
  import {
12
13
  studiesOf,
@@ -52,6 +53,8 @@ export interface ModeStudy {
52
53
  readonly plan: string;
53
54
  readonly mode: StudyMode;
54
55
  readonly empty: readonly PlanTextField[];
56
+ /** A plan v2's gates as HEAD holds them; null for a plan v1. */
57
+ readonly gates: PlanGates | null;
55
58
  }
56
59
 
57
60
  /** What reading the plan of one row gave: its mode study, nothing, or a link on the way to it. */
@@ -81,7 +84,13 @@ export const readModeStudy = (
81
84
  const empty = parsed.kind === "draft" ? parsed.empty : [];
82
85
  return {
83
86
  kind: "mode",
84
- study: { row: s, plan, mode: parsed.plan.mode, empty },
87
+ study: {
88
+ row: s,
89
+ plan,
90
+ mode: parsed.plan.mode,
91
+ empty,
92
+ gates: gatesOf(parsed.plan),
93
+ },
85
94
  };
86
95
  };
87
96
 
@@ -190,6 +190,7 @@ const PROBLEMS: readonly StudyFinding["problem"][] = [
190
190
  "unknown-requirement",
191
191
  "headline-approval",
192
192
  "bad-plan",
193
+ "plan-version",
193
194
  "author-check",
194
195
  ];
195
196