@llm4ts/flow 2.46.0 → 2.47.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.
@@ -0,0 +1,90 @@
1
+ // Per-criterion test evidence (ADR 0042): every MVP acceptance criterion is
2
+ // encoded by tests whose names carry its tag, `[<story-id>#<n>]`, and the
3
+ // criterion is green only when the test run says so — from a JUnit XML
4
+ // report, or from one filtered test command per criterion. Pass/fail counts
5
+ // alone (GateTriage, OracleGuard) cannot say WHICH criterion holds.
6
+ import * as Schema from "effect/Schema";
7
+ import { defaultOracleRules } from "./OracleGuard.js";
8
+ /** The tag tests of a criterion carry in their name: `[story-id#2]` for its second criterion. */
9
+ export const criterionTag = (storyId, index) => `[${storyId}#${index}]`;
10
+ /** The tags of a story's MVP criteria, in order. */
11
+ export const criterionTags = (storyId, acceptance) => acceptance.map((_, index) => criterionTag(storyId, index + 1));
12
+ export const TestCaseStatus = Schema.Literals(["passed", "failed", "skipped"]);
13
+ const attribute = (attributes, name) => {
14
+ const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*"([^"]*)"`, "u").exec(attributes);
15
+ return match?.[1] === undefined ? undefined : unescapeXml(match[1]);
16
+ };
17
+ const unescapeXml = (text) => text
18
+ .replace(/&lt;/g, "<")
19
+ .replace(/&gt;/g, ">")
20
+ .replace(/&quot;/g, '"')
21
+ .replace(/&apos;/g, "'")
22
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
23
+ .replace(/&amp;/g, "&");
24
+ /**
25
+ * The test cases of a JUnit XML report — the format vitest, jest, pytest,
26
+ * Surefire/Gradle and cargo-nextest all write. A case with a `<failure>` or
27
+ * `<error>` failed, one with `<skipped>` was skipped, any other passed.
28
+ */
29
+ export const parseJUnit = (xml) => {
30
+ const cases = [];
31
+ const pattern = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/gu;
32
+ for (const match of xml.matchAll(pattern)) {
33
+ const attributes = match[1] ?? "";
34
+ const body = match[2] ?? "";
35
+ const status = /<(?:failure|error)\b/u.test(body)
36
+ ? "failed"
37
+ : /<skipped\b/u.test(body)
38
+ ? "skipped"
39
+ : "passed";
40
+ cases.push({
41
+ name: attribute(attributes, "name") ?? "",
42
+ classname: attribute(attributes, "classname") ?? "",
43
+ file: attribute(attributes, "file"),
44
+ status
45
+ });
46
+ }
47
+ return cases;
48
+ };
49
+ /**
50
+ * `missing`: no test names the criterion. `red`: one does and not all of
51
+ * them pass. `green`: some do and every one passes.
52
+ */
53
+ export const CriterionStatus = Schema.Literals(["missing", "red", "green"]);
54
+ const named = (testCase, tag) => testCase.name.includes(tag) || testCase.classname.includes(tag);
55
+ /**
56
+ * A criterion's status from a report. A tag the report does not show but
57
+ * the test sources carry (`written`) is red: a suite that cannot even load
58
+ * — the module under test does not exist yet — reports no case.
59
+ */
60
+ export const statusFromReport = (tag, cases, written) => {
61
+ const mine = cases.filter((testCase) => named(testCase, tag));
62
+ if (mine.length === 0) {
63
+ return written ? "red" : "missing";
64
+ }
65
+ return mine.every((testCase) => testCase.status === "passed") ? "green" : "red";
66
+ };
67
+ export const evidenceSourceFromEnvironment = (environment) => {
68
+ const report = environment.LLM4TS_TEST_REPORT?.trim();
69
+ if (report !== undefined && report.length > 0) {
70
+ return { _tag: "Report", path: report };
71
+ }
72
+ const command = environment.LLM4TS_TEST_ONE?.trim();
73
+ if (command !== undefined && (command.includes("{tag}") || command.includes("{tagRegex}"))) {
74
+ return { _tag: "Command", argv: command.split(/\s+/u) };
75
+ }
76
+ return undefined;
77
+ };
78
+ const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
79
+ /**
80
+ * The per-criterion command with its tag in place: `{tag}` verbatim,
81
+ * `{tagRegex}` escaped for runners whose name filter is a regex (vitest `-t`,
82
+ * jest `-t`, pytest `-k` takes neither — use `{tag}` there).
83
+ */
84
+ export const commandFor = (argv, tag) => argv.map((part) => part.split("{tagRegex}").join(escapeRegex(tag)).split("{tag}").join(tag));
85
+ /** The changed paths that are test files by the oracle's rules: what a red phase freezes. */
86
+ export const testPaths = (paths, rules = defaultOracleRules) => {
87
+ const pattern = new RegExp(rules.testFiles, "u");
88
+ return paths.filter((path) => pattern.test(path));
89
+ };
90
+ //# sourceMappingURL=TestEvidence.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TestEvidence.js","sourceRoot":"","sources":["../src/TestEvidence.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,0EAA0E;AAC1E,uEAAuE;AACvE,4EAA4E;AAC5E,oEAAoE;AACpE,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AAEvC,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAA;AAErD,iGAAiG;AACjG,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,OAAe,EAAE,KAAa,EAAU,EAAE,CAAC,IAAI,OAAO,IAAI,KAAK,GAAG,CAAA;AAE/F,oDAAoD;AACpD,MAAM,CAAC,MAAM,aAAa,GAAG,CAC3B,OAAe,EACf,UAAiC,EACV,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC,YAAY,CAAC,OAAO,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC,CAAA;AAE1F,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,CAAC,CAAC,CAAA;AAU9E,MAAM,SAAS,GAAG,CAAC,UAAkB,EAAE,IAAY,EAAsB,EAAE;IACzE,MAAM,KAAK,GAAG,IAAI,MAAM,CAAC,YAAY,IAAI,oBAAoB,EAAE,GAAG,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAA;IACpF,OAAO,KAAK,EAAE,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAA;AACrE,CAAC,CAAA;AAED,MAAM,WAAW,GAAG,CAAC,IAAY,EAAU,EAAE,CAC3C,IAAI;KACD,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC;KACrB,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC;KACrB,OAAO,CAAC,SAAS,EAAE,GAAG,CAAC;KACvB,OAAO,CAAC,SAAS,EAAE,GAAG,CAAC;KACvB,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC,EAAE,IAAY,EAAE,EAAE,CAAC,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;KAC7E,OAAO,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAA;AAE3B;;;;GAIG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,GAAW,EAA2B,EAAE;IACjE,MAAM,KAAK,GAAoB,EAAE,CAAA;IACjC,MAAM,OAAO,GAAG,sDAAsD,CAAA;IACtE,KAAK,MAAM,KAAK,IAAI,GAAG,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC;QAC1C,MAAM,UAAU,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;QACjC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;QAC3B,MAAM,MAAM,GAAmB,uBAAuB,CAAC,IAAI,CAAC,IAAI,CAAC;YAC/D,CAAC,CAAC,QAAQ;YACV,CAAC,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC;gBACxB,CAAC,CAAC,SAAS;gBACX,CAAC,CAAC,QAAQ,CAAA;QACd,KAAK,CAAC,IAAI,CAAC;YACT,IAAI,EAAE,SAAS,CAAC,UAAU,EAAE,MAAM,CAAC,IAAI,EAAE;YACzC,SAAS,EAAE,SAAS,CAAC,UAAU,EAAE,WAAW,CAAC,IAAI,EAAE;YACnD,IAAI,EAAE,SAAS,CAAC,UAAU,EAAE,MAAM,CAAC;YACnC,MAAM;SACP,CAAC,CAAA;IACJ,CAAC;IACD,OAAO,KAAK,CAAA;AACd,CAAC,CAAA;AAED;;;GAGG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,SAAS,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC,CAAA;AAG3E,MAAM,KAAK,GAAG,CAAC,QAAkB,EAAE,GAAW,EAAW,EAAE,CACzD,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,QAAQ,CAAC,SAAS,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAA;AAEjE;;;;GAIG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAC9B,GAAW,EACX,KAA8B,EAC9B,OAAgB,EACC,EAAE;IACnB,MAAM,IAAI,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC,CAAA;IAC7D,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACtB,OAAO,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAA;IACpC,CAAC;IACD,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAA;AACjF,CAAC,CAAA;AAYD,MAAM,CAAC,MAAM,6BAA6B,GAAG,CAC3C,WAAyD,EAC7B,EAAE;IAC9B,MAAM,MAAM,GAAG,WAAW,CAAC,kBAAkB,EAAE,IAAI,EAAE,CAAA;IACrD,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC9C,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,CAAA;IACzC,CAAC;IACD,MAAM,OAAO,GAAG,WAAW,CAAC,eAAe,EAAE,IAAI,EAAE,CAAA;IACnD,IAAI,OAAO,KAAK,SAAS,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC,EAAE,CAAC;QAC3F,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,CAAA;IACzD,CAAC;IACD,OAAO,SAAS,CAAA;AAClB,CAAC,CAAA;AAED,MAAM,WAAW,GAAG,CAAC,IAAY,EAAU,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAA;AAEzF;;;;GAIG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,IAA2B,EAAE,GAAW,EAAyB,EAAE,CAC5F,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;AAE9F,6FAA6F;AAC7F,MAAM,CAAC,MAAM,SAAS,GAAG,CACvB,KAA4B,EAC5B,QAAqB,kBAAkB,EAChB,EAAE;IACzB,MAAM,OAAO,GAAG,IAAI,MAAM,CAAC,KAAK,CAAC,SAAS,EAAE,GAAG,CAAC,CAAA;IAChD,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAA;AACnD,CAAC,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/flow",
3
- "version": "2.46.0",
3
+ "version": "2.47.0",
4
4
  "description": "Effect-native LLM workflow, persistence, review, and repository automation",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -95,6 +95,7 @@
95
95
  "./Stories": "./dist/Stories.js",
96
96
  "./StoryPlan": "./dist/StoryPlan.js",
97
97
  "./Survey": "./dist/Survey.js",
98
+ "./TestEvidence": "./dist/TestEvidence.js",
98
99
  "./Timing": "./dist/Timing.js",
99
100
  "./Transcript": "./dist/Transcript.js",
100
101
  "./TransientRetry": "./dist/TransientRetry.js",
@@ -129,7 +130,7 @@
129
130
  "typescript"
130
131
  ],
131
132
  "dependencies": {
132
- "@llm4ts/core": "2.46.0"
133
+ "@llm4ts/core": "2.47.0"
133
134
  },
134
135
  "peerDependencies": {
135
136
  "effect": "4.0.0"
package/src/FlowEvents.ts CHANGED
@@ -312,6 +312,28 @@ export class TaskCompleted extends Schema.TaggedClass<TaskCompleted>()("TaskComp
312
312
  executor: Schema.optionalKey(Schema.String)
313
313
  }) {}
314
314
 
315
+ /**
316
+ * An MVP acceptance criterion's test evidence (ADR 0042): `red` after the
317
+ * story's red phase proved its tests fail, `green` once they all pass.
318
+ */
319
+ export class CriterionChecked extends Schema.TaggedClass<CriterionChecked>()("CriterionChecked", {
320
+ story: Schema.String,
321
+ /** The criterion's tag, `[story-id#n]`. */
322
+ tag: Schema.String,
323
+ status: Schema.Literals(["missing", "red", "green"]),
324
+ lane: Schema.optionalKey(Schema.String)
325
+ }) {}
326
+
327
+ /** An outcome left for a later iteration, with why (ADR 0042): written to `parked.md`. */
328
+ export class Parked extends Schema.TaggedClass<Parked>()("Parked", {
329
+ story: Schema.String,
330
+ item: Schema.String,
331
+ reason: Schema.String,
332
+ /** It was part of the MVP: the run cannot call the MVP reached. */
333
+ mvp: Schema.Boolean,
334
+ lane: Schema.optionalKey(Schema.String)
335
+ }) {}
336
+
315
337
  /** A story moved on the epic's board (ADR 0033); the statuses are `BoardSync`'s. */
316
338
  export class StoryStatusChanged extends Schema.TaggedClass<StoryStatusChanged>()(
317
339
  "StoryStatusChanged",
@@ -458,7 +480,9 @@ export const FlowEvent = Schema.Union([
458
480
  TasksPlanned,
459
481
  TaskStarted,
460
482
  TaskCompleted,
461
- StoryStatusChanged
483
+ StoryStatusChanged,
484
+ CriterionChecked,
485
+ Parked
462
486
  ])
463
487
  export type FlowEvent = typeof FlowEvent.Type
464
488
 
package/src/Stories.ts CHANGED
@@ -148,6 +148,50 @@ export class StoryState extends Schema.Class<StoryState>("StoryState")({
148
148
  executor: Schema.optionalKey(Schema.String)
149
149
  }) {}
150
150
 
151
+ /**
152
+ * What a story's red phase left (ADR 0042): the test paths it froze, the
153
+ * commit that holds them, and what it parked on the way. Written once; its
154
+ * presence is what tells a resumed story the red phase is done.
155
+ */
156
+ export const RedTestsVersion = 1
157
+
158
+ export class RedTests extends Schema.Class<RedTests>("RedTests")({
159
+ frozen: Schema.Array(Schema.String),
160
+ commit: Schema.String,
161
+ /** Criteria the red phase could not prove red, each with why: parked for the operator. */
162
+ parked: Schema.Array(Schema.String)
163
+ }) {}
164
+
165
+ /** What a red phase answers: the test paths to freeze and the criteria it parked. */
166
+ export interface RedPhase {
167
+ readonly frozen: ReadonlyArray<string>
168
+ readonly parked: ReadonlyArray<string>
169
+ }
170
+
171
+ /** Where a story's red phase is recorded, beside its task plan. */
172
+ export const redTestsPath = (stateDir: string, storyId: string): string =>
173
+ join(stateDir, `stories/${storyId}.red.json`)
174
+
175
+ /** A change to a test the red phase froze: a gate failure, whatever the change. */
176
+ export const frozenTestsGate = (
177
+ changed: ReadonlyArray<string>,
178
+ frozen: ReadonlyArray<string>
179
+ ): ReviewResult => {
180
+ const touched = changed.filter((path) => frozen.includes(path))
181
+ return ReviewResult.make({
182
+ issues: touched.map((path) =>
183
+ ReviewIssue.make({
184
+ severity: "Critical",
185
+ title: `frozen red test changed: ${path}`,
186
+ description:
187
+ "The red phase froze this test: make it pass by changing production code, and put the test back as it was.",
188
+ file: path
189
+ })
190
+ ),
191
+ summary: touched.length === 0 ? "" : "frozen red tests"
192
+ })
193
+ }
194
+
151
195
  export const EpicReportVersion = 1
152
196
 
153
197
  /** `waiting`: on hold behind a failed predecessor, runs once that is fixed. */
@@ -724,6 +768,19 @@ export interface StoriesOptions {
724
768
  readonly contextChars?: number
725
769
  /** Extra system context per story (house rules, shared read-only excerpts). */
726
770
  readonly system?: (story: Story) => Effect.Effect<string, FlowError>
771
+ /**
772
+ * Test-first (ADR 0042): runs once per story, in its worktree, before its
773
+ * tasks are planned — writes the red tests and proves them red. The
774
+ * executor commits what it wrote and freezes the paths it names: a later
775
+ * change to any of them is a gate failure. Omit for no red phase.
776
+ */
777
+ readonly redPhase?: (
778
+ story: Story,
779
+ seats: StorySeats,
780
+ workDir: string,
781
+ system: string,
782
+ events: FlowEventsShape
783
+ ) => Effect.Effect<RedPhase, FlowError>
727
784
  /** How a story's task plan is produced. Default: the story's own coder plans it. */
728
785
  readonly planTasks?: (
729
786
  seats: StorySeats,
@@ -1145,6 +1202,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1145
1202
  // The notes its tasks carried describe the old definition: gone too.
1146
1203
  yield* files.remove(planPath(story))
1147
1204
  yield* files.remove(notesPath(story))
1205
+ yield* files.remove(redTestsPath(options.stateDir, story.id))
1148
1206
  stored = undefined
1149
1207
  }
1150
1208
  if (stored !== undefined && stored.status === "merged") {
@@ -1394,6 +1452,13 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1394
1452
  )
1395
1453
  return ReviewResult.make({ issues, summary: issues.length === 0 ? "" : "oracle guard" })
1396
1454
  })
1455
+ // The red phase's tests, once written (ADR 0042): frozen for every later change.
1456
+ let redTests = yield* loadVersioned(
1457
+ files,
1458
+ redTestsPath(options.stateDir, story.id),
1459
+ RedTestsVersion,
1460
+ RedTests
1461
+ )
1397
1462
  /**
1398
1463
  * The gates in a worktree of the story: the target's, the story's
1399
1464
  * perimeter, the oracle guard and — for a task run aside — the paths the
@@ -1420,12 +1485,28 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1420
1485
  ],
1421
1486
  task.owns
1422
1487
  )
1488
+ const frozen =
1489
+ redTests === undefined
1490
+ ? ReviewResult.make({ issues: [] })
1491
+ : frozenTestsGate(
1492
+ [
1493
+ ...(yield* where.changedFilesVsBase(redTests.commit)),
1494
+ ...(yield* where.uncommittedFiles)
1495
+ ],
1496
+ redTests.frozen
1497
+ )
1423
1498
  return combined(
1424
1499
  combined(
1425
- combined(target, perimeterGate([...changed.sharedReadOnly, ...changed.outside], story)),
1426
- yield* oracleGate(target, where)
1500
+ combined(
1501
+ combined(
1502
+ target,
1503
+ perimeterGate([...changed.sharedReadOnly, ...changed.outside], story)
1504
+ ),
1505
+ yield* oracleGate(target, where)
1506
+ ),
1507
+ owned
1427
1508
  ),
1428
- owned
1509
+ frozen
1429
1510
  )
1430
1511
  })
1431
1512
  const gates = gatesAt(state.worktree, git, storyGateLogDir(options.stateDir, story.id))
@@ -1599,6 +1680,35 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1599
1680
  )
1600
1681
  })
1601
1682
 
1683
+ // Test-first (ADR 0042): the red tests before any task is planned, once.
1684
+ // A story whose task plan exists already ran it, or predates it.
1685
+ if (
1686
+ options.redPhase !== undefined &&
1687
+ redTests === undefined &&
1688
+ (yield* files.read(planPath(story))) === undefined
1689
+ ) {
1690
+ const red = yield* stage(
1691
+ laneOf(story),
1692
+ `story ${story.id}: red tests`,
1693
+ options.redPhase(story, watchedSeats, state.worktree, system, laneOf(story))
1694
+ )
1695
+ if ((yield* git.uncommittedFiles).length > 0) {
1696
+ yield* git.commitAll(`${story.id}: red tests`)
1697
+ }
1698
+ redTests = RedTests.make({
1699
+ frozen: [...red.frozen],
1700
+ commit: yield* git.checkpoint,
1701
+ parked: [...red.parked]
1702
+ })
1703
+ yield* saveVersioned(
1704
+ files,
1705
+ redTestsPath(options.stateDir, story.id),
1706
+ RedTestsVersion,
1707
+ RedTests,
1708
+ redTests
1709
+ )
1710
+ }
1711
+
1602
1712
  const planTasks = options.planTasks ?? defaultPlanTasks
1603
1713
  // Taken after catch-up and setup: the code as the first task finds it.
1604
1714
  const startingCode = yield* startingCodeOf(
package/src/StoryPlan.ts CHANGED
@@ -45,6 +45,12 @@ export class Story extends Schema.Class<Story>("Story")({
45
45
  Schema.withConstructorDefault(Effect.succeed([])),
46
46
  Schema.withDecodingDefaultKey(Effect.succeed([]))
47
47
  ),
48
+ /**
49
+ * Outcomes cut from this iteration (ADR 0042, epic-mvp): planned, shown,
50
+ * never built now. `acceptance` is the MVP; a story with only `later`
51
+ * outcomes is parked whole. Absent outside epic-mvp.
52
+ */
53
+ later: Schema.optionalKey(Schema.Array(Schema.String)),
48
54
  /**
49
55
  * The story may delete, skip or rewrite tests (ADR 0027): the oracle guard
50
56
  * stays silent for it. Absent or false, a deleted test file, an added skip
@@ -376,6 +382,7 @@ export const storyHash = (story: Story): string =>
376
382
  ? {}
377
383
  : { readFirst: [...story.readFirst].map(normalizePath) }),
378
384
  ...(story.acceptance.length === 0 ? {} : { acceptance: [...story.acceptance] }),
385
+ ...(story.later === undefined || story.later.length === 0 ? {} : { later: [...story.later] }),
379
386
  ...(story.testsChange ? { testsChange: true } : {})
380
387
  })
381
388
  )
@@ -0,0 +1,137 @@
1
+ // Per-criterion test evidence (ADR 0042): every MVP acceptance criterion is
2
+ // encoded by tests whose names carry its tag, `[<story-id>#<n>]`, and the
3
+ // criterion is green only when the test run says so — from a JUnit XML
4
+ // report, or from one filtered test command per criterion. Pass/fail counts
5
+ // alone (GateTriage, OracleGuard) cannot say WHICH criterion holds.
6
+ import * as Schema from "effect/Schema"
7
+ import type { OracleRules } from "./OracleGuard.ts"
8
+ import { defaultOracleRules } from "./OracleGuard.ts"
9
+
10
+ /** The tag tests of a criterion carry in their name: `[story-id#2]` for its second criterion. */
11
+ export const criterionTag = (storyId: string, index: number): string => `[${storyId}#${index}]`
12
+
13
+ /** The tags of a story's MVP criteria, in order. */
14
+ export const criterionTags = (
15
+ storyId: string,
16
+ acceptance: ReadonlyArray<string>
17
+ ): ReadonlyArray<string> => acceptance.map((_, index) => criterionTag(storyId, index + 1))
18
+
19
+ export const TestCaseStatus = Schema.Literals(["passed", "failed", "skipped"])
20
+ export type TestCaseStatus = typeof TestCaseStatus.Type
21
+
22
+ export interface TestCase {
23
+ readonly name: string
24
+ readonly classname: string
25
+ readonly file: string | undefined
26
+ readonly status: TestCaseStatus
27
+ }
28
+
29
+ const attribute = (attributes: string, name: string): string | undefined => {
30
+ const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*"([^"]*)"`, "u").exec(attributes)
31
+ return match?.[1] === undefined ? undefined : unescapeXml(match[1])
32
+ }
33
+
34
+ const unescapeXml = (text: string): string =>
35
+ text
36
+ .replace(/&lt;/g, "<")
37
+ .replace(/&gt;/g, ">")
38
+ .replace(/&quot;/g, '"')
39
+ .replace(/&apos;/g, "'")
40
+ .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
41
+ .replace(/&amp;/g, "&")
42
+
43
+ /**
44
+ * The test cases of a JUnit XML report — the format vitest, jest, pytest,
45
+ * Surefire/Gradle and cargo-nextest all write. A case with a `<failure>` or
46
+ * `<error>` failed, one with `<skipped>` was skipped, any other passed.
47
+ */
48
+ export const parseJUnit = (xml: string): ReadonlyArray<TestCase> => {
49
+ const cases: Array<TestCase> = []
50
+ const pattern = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/gu
51
+ for (const match of xml.matchAll(pattern)) {
52
+ const attributes = match[1] ?? ""
53
+ const body = match[2] ?? ""
54
+ const status: TestCaseStatus = /<(?:failure|error)\b/u.test(body)
55
+ ? "failed"
56
+ : /<skipped\b/u.test(body)
57
+ ? "skipped"
58
+ : "passed"
59
+ cases.push({
60
+ name: attribute(attributes, "name") ?? "",
61
+ classname: attribute(attributes, "classname") ?? "",
62
+ file: attribute(attributes, "file"),
63
+ status
64
+ })
65
+ }
66
+ return cases
67
+ }
68
+
69
+ /**
70
+ * `missing`: no test names the criterion. `red`: one does and not all of
71
+ * them pass. `green`: some do and every one passes.
72
+ */
73
+ export const CriterionStatus = Schema.Literals(["missing", "red", "green"])
74
+ export type CriterionStatus = typeof CriterionStatus.Type
75
+
76
+ const named = (testCase: TestCase, tag: string): boolean =>
77
+ testCase.name.includes(tag) || testCase.classname.includes(tag)
78
+
79
+ /**
80
+ * A criterion's status from a report. A tag the report does not show but
81
+ * the test sources carry (`written`) is red: a suite that cannot even load
82
+ * — the module under test does not exist yet — reports no case.
83
+ */
84
+ export const statusFromReport = (
85
+ tag: string,
86
+ cases: ReadonlyArray<TestCase>,
87
+ written: boolean
88
+ ): CriterionStatus => {
89
+ const mine = cases.filter((testCase) => named(testCase, tag))
90
+ if (mine.length === 0) {
91
+ return written ? "red" : "missing"
92
+ }
93
+ return mine.every((testCase) => testCase.status === "passed") ? "green" : "red"
94
+ }
95
+
96
+ /**
97
+ * Where the evidence comes from: the JUnit report the test gate writes
98
+ * (`LLM4TS_TEST_REPORT`, relative to the app directory), or a command run
99
+ * once per criterion with `{tag}` replaced (`LLM4TS_TEST_ONE`), green when
100
+ * it exits 0.
101
+ */
102
+ export type EvidenceSource =
103
+ | { readonly _tag: "Report"; readonly path: string }
104
+ | { readonly _tag: "Command"; readonly argv: ReadonlyArray<string> }
105
+
106
+ export const evidenceSourceFromEnvironment = (
107
+ environment: Readonly<Record<string, string | undefined>>
108
+ ): EvidenceSource | undefined => {
109
+ const report = environment.LLM4TS_TEST_REPORT?.trim()
110
+ if (report !== undefined && report.length > 0) {
111
+ return { _tag: "Report", path: report }
112
+ }
113
+ const command = environment.LLM4TS_TEST_ONE?.trim()
114
+ if (command !== undefined && (command.includes("{tag}") || command.includes("{tagRegex}"))) {
115
+ return { _tag: "Command", argv: command.split(/\s+/u) }
116
+ }
117
+ return undefined
118
+ }
119
+
120
+ const escapeRegex = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
121
+
122
+ /**
123
+ * The per-criterion command with its tag in place: `{tag}` verbatim,
124
+ * `{tagRegex}` escaped for runners whose name filter is a regex (vitest `-t`,
125
+ * jest `-t`, pytest `-k` takes neither — use `{tag}` there).
126
+ */
127
+ export const commandFor = (argv: ReadonlyArray<string>, tag: string): ReadonlyArray<string> =>
128
+ argv.map((part) => part.split("{tagRegex}").join(escapeRegex(tag)).split("{tag}").join(tag))
129
+
130
+ /** The changed paths that are test files by the oracle's rules: what a red phase freezes. */
131
+ export const testPaths = (
132
+ paths: ReadonlyArray<string>,
133
+ rules: OracleRules = defaultOracleRules
134
+ ): ReadonlyArray<string> => {
135
+ const pattern = new RegExp(rules.testFiles, "u")
136
+ return paths.filter((path) => pattern.test(path))
137
+ }