@ai-dossier/sched 0.3.0 → 0.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/README.md CHANGED
@@ -7,7 +7,8 @@ machines, crash-safe persistence, the **dispatch engine** (#464: spawning agent
7
7
  processes, verifying their completion against ground truth, mechanizing the
8
8
  stall/escalation ladder), and since #468 the **PR watcher + tail work** (parked-PR
9
9
  watching, script-based teardown, cheap-tier report dispatch — retiring the fleet
10
- pattern of re-dispatching a full-cycle run for the tail). The scheduler itself **never
10
+ pattern of re-dispatching a full-cycle run for the tail), and since #472 **batch failure
11
+ recovery** (attribution, bisect, one bounded fix, eviction, dissolve). The scheduler itself **never
11
12
  invokes an LLM** — it spawns the agent process the operator configured and reconciles
12
13
  the durable record (`ai-dossier runstate` / `gh` / `git`) that the spawned run leaves
13
14
  behind.
@@ -85,6 +86,77 @@ Two engine-safety policies were explicit product decisions on #464:
85
86
  Only `issue:<n>` units are dispatched today — batch member sequencing is a follow-up
86
87
  (#464 non-goal).
87
88
 
89
+ ## Batch failure recovery (#472)
90
+
91
+ What happens when a batch's aggregate suite goes red, or its PR will not merge
92
+ (RFC-0001 §F.2/F.8/F.9).
93
+
94
+ **Not yet wired into `sched start`** — `tick()` still dispatches only `issue:<n>` units
95
+ (the batch execution loop is a follow-up, see above). These modules are the library
96
+ surface that loop will call, and they are tested standalone against real scratch repos.
97
+
98
+ ```
99
+ validating → attributing → fixing (ONE bounded attempt) → validating
100
+ → evicting (revert the member's commits) → validating
101
+ > ⅓ of members evicted, or a revert conflict → dissolving → members requeued
102
+ awaiting-merge (CONFLICTING | auto-merge-blocked)
103
+ → rebasing → re-validating → shipping
104
+ → (2nd occurrence) dissolving into two half-batches
105
+ ```
106
+
107
+ 1. **Attribution (AC1)** — `attributeByOverlap` maps each failing test to a member by
108
+ focused-test match, then by changed-path overlap. Exactly one candidate attributes;
109
+ more than one is AMBIGUOUS and none is UNATTRIBUTED — neither is ever guessed. When
110
+ the caller supplies a `BisectSpec`, both go to `runAttributionBisect`: a real
111
+ `git bisect run` over the branch's `good..bad` range executing ONLY the failing tests,
112
+ whose first-bad commit is mapped to a member through the `(#N)` subject trailer on the
113
+ branch's issue-boundary commits (every unresolved test is then attributed to that
114
+ member). A first-bad commit with no trailer, or an abbreviated sha matching two
115
+ commits, reports `unattributable` rather than blaming a neighbour. The bisect refuses
116
+ to run at all unless the test command actually discriminates — it must fail at `bad`
117
+ AND pass at `good`, so a missing runner cannot silently convict the earliest member —
118
+ and it always resets the checkout to where it found it. Without a `BisectSpec`,
119
+ overlap is the whole verdict and unresolved tests stay unattributed.
120
+ 2. **One bounded fix attempt (AC2)** — `beginFixAttempt` returns the mid-tier command and
121
+ prompt for the CALLER to spawn (sched never invokes an LLM) and records the attempt.
122
+ A second call for the same member returns `null`: the next step is eviction, so a
123
+ batch cannot burn its budget on one broken member.
124
+ 3. **Eviction (AC2)** — `evictMembers` reverts the member's commits newest-first across
125
+ members (an eviction group reverts together), requeues it as full-cycle with
126
+ `failure_evidence` attached (batch, reason, failing tests, attribution method, reverted
127
+ commits), re-runs the suite and checks the dissolve trigger. A conflicting revert is
128
+ aborted so the worktree is clean and the batch dissolves — the reverts that already
129
+ landed ride along on the abandoned branch, which is why it is abandoned rather than
130
+ reused. An eviction group that reaches an already-shipped member dissolves instead of
131
+ reverting merged work.
132
+ 4. **Dissolve (AC3)** — `dissolveBatch` marks the batch `dissolved` and requeues every
133
+ UNSHIPPED member: `full` (each as its own full-cycle run) or `halved` (one or two fresh
134
+ `forming` half-batches — a single remaining member yields one — entries retagged,
135
+ eviction groups inherited where they survive the split). Shipped and terminal members
136
+ keep their outcome; nothing green is discarded, and no git runs — the batch branch is
137
+ simply left behind unmerged, since sched deletes nothing.
138
+ 5. **PR conflict (AC4)** — `handlePrConflict` rebases the batch branch, re-runs the suite
139
+ and re-ships ONCE. A second occurrence, a conflicting rebase, a failed fetch, an
140
+ unusable `base_branch`, a checkout that is not on the batch branch, or a red suite
141
+ after a clean rebase dissolves into two half-batches.
142
+ 6. **Milestones (AC5)** — every eviction and dissolve posts a `batch-validate` /
143
+ `batch-ship` milestone to the batch ANCHOR issue via `ai-dossier runstate post`, with
144
+ the reason, the evicted/requeued/preserved members and the attribution method (a
145
+ successful re-ship posts `batch-ship awaiting-merge`); each per-member outcome is
146
+ journaled and kept in the batch's `evictions` (the classifier feedback signal). A batch
147
+ with no `anchor` or no `run_id` cannot post — the CLI requires both — so the milestone
148
+ it could not post is journaled in full instead of vanishing.
149
+
150
+ Ten journal events carry the detail: `suite-failed`, `attributed`, `fix-dispatched`,
151
+ `fix-resolved`, `member-evicted`, `revert-conflict`, `batch-rebased`, `batch-dissolved`,
152
+ `batch-split` and `milestone-post-failed`, plus `git-failed` for any git command that
153
+ returned non-zero (the injected `ExecFn` collapses every git failure into `null`, so the
154
+ command that produced one is always recorded).
155
+
156
+ Schema 1.3.0 carries the new state: `BatchEntry` gains `anchor`, `branch`, `run_id`,
157
+ `eviction_groups`, `evictions`, `fix_attempts` and `rebase_attempts`; `QueueEntry` gains
158
+ `failure_evidence`. 1.2.0 states migrate on load.
159
+
88
160
  ## API surface
89
161
 
90
162
  ```ts
@@ -114,6 +186,26 @@ import {
114
186
  runTeardown, // #468 script teardown for a merged unit (pool return / worktree remove)
115
187
  isSafeWorktree, // worktree-path containment check (CWE-22)
116
188
  TEARDOWN_TIMEOUT_MS, // teardown subprocess timeout (120 s)
189
+ attributeByOverlap, // #472 pure stage-1 attribution: failing tests → members
190
+ parseVitestJson, // vitest --reporter=json → failing tests
191
+ parseBoundaryCommits, // git log → issue-boundary commits via the (#N) trailer
192
+ memberRanges, // boundary commits → each member's commit list
193
+ runAttributionBisect, // stage-2: real git bisect over the failing tests only
194
+ beginAttribution, // validating → attributing (overlap, then bisect if needed)
195
+ beginFixAttempt, // the ONE bounded mid-tier fix dispatch instruction
196
+ resolveFixAttempt, // record its outcome, back to validating
197
+ evictMembers, // revert + requeue with evidence + suite re-run + dissolve check
198
+ checkDissolveTrigger, // pure: > ⅓ of members evicted
199
+ dissolveBatch, // full or halved dissolve; preserves everything green
200
+ handlePrConflict, // rebase + re-ship once, then dissolve into halves
201
+ createExecMilestonePoster, // batch milestones via `ai-dossier runstate post`
202
+ expandEvictionGroups, // members that must revert together (§E.4 eviction groups)
203
+ requeueMember, // the one requeue path abandon/evict/dissolve all take
204
+ isPreservedMember, // the single definition of "already green"
205
+ createBatch, // the single BatchEntry constructor
206
+ type RecoveryDeps, // inject exec/repoDir/journal/milestone-poster/suite-runner/clock
207
+ type SuiteRunner, // re-runs the aggregate suite after a revert or rebase
208
+ type BatchMilestonePoster, // batch-milestone sink (createExecMilestonePoster is default)
117
209
  Journal, // append-only events.jsonl
118
210
  transitionIssue, transitionBatch, transitionSlot, // typed §D transitions
119
211
  TRANSITIONS, // the transition tables themselves (for previews)
@@ -150,8 +242,10 @@ fake agents and stub ground truth; no LLM calls anywhere.
150
242
  - **Corrupt state is loud**: `load()` throws `CorruptStateError` naming the file —
151
243
  never a silent queue reset. `state.json` is deletable and rebuildable from GitHub,
152
244
  which remains the system of record.
153
- - **Schema**: state/config files from #460 (schema 1.0.0) and #464 (1.1.0) load and
154
- migrate to 1.2.0 automatically (slot `branch`/`last_head`, entry `pr`/`cleanup`,
245
+ - **Schema**: state/config files from #460 (schema 1.0.0), #464 (1.1.0) and #468 (1.2.0)
246
+ load and migrate to 1.3.0 automatically (slot `branch`/`last_head`/`pid_start`, entry
247
+ `pr`/`cleanup`/`failure_evidence`, batch `anchor`/`branch`/`run_id`/`eviction_groups`/
248
+ `evictions`/`fix_attempts`/`rebase_attempts`,
155
249
  and state-level `last_pr_poll_at` backfill to null).
156
250
  - **`max_slots`** bounds live units (`assigned | running | recovering`); dependency
157
251
  edges gate readiness — an issue with an unmerged dependency, and a batch behind an
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Failure attribution, stage 1 (#472 AC1): map failing tests to batch members
3
+ * without an LLM, using only what the batch run already recorded.
4
+ *
5
+ * Two overlap signals, in order of confidence:
6
+ * 1. **focused tests** — the member's own plan/implement phase named these
7
+ * test files; a failure in one of them is that member's.
8
+ * 2. **changed paths** — the failing test file was changed by the member, or
9
+ * sits under a directory the member changed (the `src/x.ts` →
10
+ * `src/__tests__/x.test.ts` relationship, expressed structurally rather
11
+ * than by naming convention).
12
+ *
13
+ * A test with exactly one candidate member is ATTRIBUTED; more than one is
14
+ * AMBIGUOUS and none is UNATTRIBUTED. Both of those go to stage 2 (`bisect.ts`)
15
+ * — this module never guesses, because a wrong attribution evicts innocent
16
+ * work. Everything here is pure; `recovery.ts` owns the effects.
17
+ */
18
+ /** One failing test, as the aggregate suite reported it. */
19
+ export interface FailingTest {
20
+ /** Test file path, repo-relative where the runner reports it that way. */
21
+ file: string;
22
+ /** Full test name (describe path + title). */
23
+ name: string;
24
+ /** Stable id used in evidence and journal lines: `<file>::<name>`. */
25
+ id: string;
26
+ }
27
+ /** What one batch member touched — the attribution surface, recorded as it ran. */
28
+ export interface MemberFootprint {
29
+ issue: number;
30
+ /** Repo-relative paths the member's commits changed. */
31
+ changedPaths: string[];
32
+ /** Test files the member's own phases ran focused (strongest signal). */
33
+ focusedTests: string[];
34
+ }
35
+ /** A failing test that could belong to more than one member. */
36
+ export interface AmbiguousTest {
37
+ test: FailingTest;
38
+ /** Member issue numbers that all overlap it, ascending. */
39
+ candidates: number[];
40
+ }
41
+ /** Stage-1 attribution verdict for one suite run. */
42
+ export interface OverlapAttribution {
43
+ /** Member issue → the failing tests uniquely attributed to it. */
44
+ attributed: Map<number, FailingTest[]>;
45
+ /** Tests overlapping several members — bisect decides. */
46
+ ambiguous: AmbiguousTest[];
47
+ /** Tests overlapping no member at all — bisect decides. */
48
+ unattributed: FailingTest[];
49
+ }
50
+ /** Build a `FailingTest` with the derived id, so the id format exists once. */
51
+ export declare function failingTest(file: string, name: string): FailingTest;
52
+ /**
53
+ * Stage-1 attribution: overlap only, no git, no LLM (AC1). Deterministic —
54
+ * candidates are compared in ascending issue order so the same inputs always
55
+ * produce the same verdict.
56
+ */
57
+ export declare function attributeByOverlap(failing: readonly FailingTest[], footprints: readonly MemberFootprint[]): OverlapAttribution;
58
+ /** Members with at least one failing test attributed to them, ascending. */
59
+ export declare function offendersOf(attributed: ReadonlyMap<number, unknown>): number[];
60
+ /**
61
+ * Failing tests from `vitest run --reporter=json` output. Tolerates the
62
+ * surrounding noise a real run prints around the JSON document (vitest writes
63
+ * the report to stdout alongside its own banner), and skips any record it
64
+ * cannot read rather than throwing — an unparseable suite report degrades to
65
+ * "no attributable tests", which routes to bisect, never to a guess.
66
+ */
67
+ export declare function parseVitestJson(stdout: string | null): FailingTest[];
68
+ /** One commit on the batch branch, with the member it belongs to. */
69
+ export interface BoundaryCommit {
70
+ sha: string;
71
+ subject: string;
72
+ /** Member issue from the `(#N)` subject trailer; null when the commit has none. */
73
+ issue: number | null;
74
+ }
75
+ /**
76
+ * A full or abbreviated sha as git prints it — 7 to 40 hex characters. Bisect
77
+ * endpoints and revert argv are validated against this before they become git
78
+ * arguments (CWE-88).
79
+ */
80
+ export declare const SHA_RE: RegExp;
81
+ /**
82
+ * A git ref name safe to interpolate into argv (CWE-88) — the pattern
83
+ * `groundtruth.ts` established for branch names off untrusted milestone text.
84
+ * Defined here, beside `SHA_RE`, so the package has one "validate before it
85
+ * becomes a git argument" home rather than a copy per module.
86
+ */
87
+ export declare const SAFE_REF_RE: RegExp;
88
+ /**
89
+ * Parse `git log --reverse --format=%H%x09%s <base>..<head>` — OLDEST FIRST,
90
+ * which is the order eviction ranges and bisect boundaries are built in.
91
+ * Malformed lines are skipped; a line whose subject has no `(#N)` trailer
92
+ * still yields a commit, with `issue: null`.
93
+ */
94
+ export declare function parseBoundaryCommits(gitLogOutput: string | null): BoundaryCommit[];
95
+ /** The commits one member contributed to the batch branch, oldest first. */
96
+ export interface MemberRange {
97
+ issue: number;
98
+ /** Oldest commit of the member. */
99
+ from: string;
100
+ /** Newest commit of the member. */
101
+ to: string;
102
+ /** Every commit of the member, oldest first. */
103
+ commits: string[];
104
+ /**
105
+ * Each commit's index in the boundary list, parallel to `commits`. Grouping
106
+ * by member loses branch order; eviction needs it back to revert newest-first
107
+ * ACROSS members (see `memberRanges`).
108
+ */
109
+ positions: number[];
110
+ }
111
+ /**
112
+ * Group boundary commits (oldest first) by member.
113
+ *
114
+ * `from`/`to` describe the member's span for reporting; `commits` is what a
115
+ * revert actually walks. They differ when members interleave — reverting the
116
+ * literal `from^..to` range would then revert a neighbour's commits too, so
117
+ * eviction reverts the explicit commits instead, ordered by `positions`:
118
+ * for a branch `A1 B1 A2 B2`, evicting both members must revert
119
+ * `B2 A2 B1 A1`, not each member's commits as a block.
120
+ */
121
+ export declare function memberRanges(boundary: readonly BoundaryCommit[]): MemberRange[];
122
+ /**
123
+ * The member owning `sha`, or null when the commit carries no `(#N)` trailer.
124
+ *
125
+ * Either side may be abbreviated (bisect reports a short sha; boundary commits
126
+ * are full), so matching is prefix-based — but an AMBIGUOUS prefix returns null
127
+ * rather than the first hit. Attribution feeds eviction, and eviction reverts
128
+ * work: a wrong answer here destroys an innocent member's commits, so "cannot
129
+ * tell" must never collapse into "probably this one".
130
+ */
131
+ export declare function memberOfCommit(boundary: readonly BoundaryCommit[], sha: string): number | null;
132
+ //# sourceMappingURL=attribution.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attribution.d.ts","sourceRoot":"","sources":["../src/attribution.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,4DAA4D;AAC5D,MAAM,WAAW,WAAW;IAC1B,0EAA0E;IAC1E,IAAI,EAAE,MAAM,CAAC;IACb,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,mFAAmF;AACnF,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,wDAAwD;IACxD,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,yEAAyE;IACzE,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,gEAAgE;AAChE,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,WAAW,CAAC;IAClB,2DAA2D;IAC3D,UAAU,EAAE,MAAM,EAAE,CAAC;CACtB;AAED,qDAAqD;AACrD,MAAM,WAAW,kBAAkB;IACjC,kEAAkE;IAClE,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,EAAE,CAAC,CAAC;IACvC,0DAA0D;IAC1D,SAAS,EAAE,aAAa,EAAE,CAAC;IAC3B,2DAA2D;IAC3D,YAAY,EAAE,WAAW,EAAE,CAAC;CAC7B;AAED,+EAA+E;AAC/E,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,CAEnE;AAqCD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,SAAS,WAAW,EAAE,EAC/B,UAAU,EAAE,SAAS,eAAe,EAAE,GACrC,kBAAkB,CAwBpB;AAED,4EAA4E;AAC5E,wBAAgB,WAAW,CAAC,UAAU,EAAE,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,EAAE,CAE9E;AAID;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,WAAW,EAAE,CA0BpE;AAiCD,qEAAqE;AACrE,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,mFAAmF;IACnF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AASD;;;;GAIG;AACH,eAAO,MAAM,MAAM,QAAsB,CAAC;AAE1C;;;;;GAKG;AACH,eAAO,MAAM,WAAW,QAAkC,CAAC;AAE3D;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,GAAG,cAAc,EAAE,CAalF;AAED,4EAA4E;AAC5E,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,mCAAmC;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,mCAAmC;IACnC,EAAE,EAAE,MAAM,CAAC;IACX,gDAAgD;IAChD,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,SAAS,cAAc,EAAE,GAAG,WAAW,EAAE,CAgB/E;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,SAAS,cAAc,EAAE,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAS9F"}
@@ -0,0 +1,268 @@
1
+ "use strict";
2
+ /**
3
+ * Failure attribution, stage 1 (#472 AC1): map failing tests to batch members
4
+ * without an LLM, using only what the batch run already recorded.
5
+ *
6
+ * Two overlap signals, in order of confidence:
7
+ * 1. **focused tests** — the member's own plan/implement phase named these
8
+ * test files; a failure in one of them is that member's.
9
+ * 2. **changed paths** — the failing test file was changed by the member, or
10
+ * sits under a directory the member changed (the `src/x.ts` →
11
+ * `src/__tests__/x.test.ts` relationship, expressed structurally rather
12
+ * than by naming convention).
13
+ *
14
+ * A test with exactly one candidate member is ATTRIBUTED; more than one is
15
+ * AMBIGUOUS and none is UNATTRIBUTED. Both of those go to stage 2 (`bisect.ts`)
16
+ * — this module never guesses, because a wrong attribution evicts innocent
17
+ * work. Everything here is pure; `recovery.ts` owns the effects.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.SAFE_REF_RE = exports.SHA_RE = void 0;
21
+ exports.failingTest = failingTest;
22
+ exports.attributeByOverlap = attributeByOverlap;
23
+ exports.offendersOf = offendersOf;
24
+ exports.parseVitestJson = parseVitestJson;
25
+ exports.parseBoundaryCommits = parseBoundaryCommits;
26
+ exports.memberRanges = memberRanges;
27
+ exports.memberOfCommit = memberOfCommit;
28
+ /** Build a `FailingTest` with the derived id, so the id format exists once. */
29
+ function failingTest(file, name) {
30
+ return { file, name, id: `${file}::${name}` };
31
+ }
32
+ /** Normalize a path for comparison: strip `./`, collapse `\` to `/`, drop a trailing `/`. */
33
+ function normalizePath(p) {
34
+ const slashed = p.replaceAll('\\', '/').replace(/^\.\//, '');
35
+ return slashed.endsWith('/') ? slashed.slice(0, -1) : slashed;
36
+ }
37
+ /** The directory part of a path, or `''` for a top-level file. */
38
+ function dirOf(p) {
39
+ const idx = p.lastIndexOf('/');
40
+ return idx === -1 ? '' : p.slice(0, idx);
41
+ }
42
+ /** Whether `member` explicitly ran `normalizedFile` focused — the strongest signal. */
43
+ function focusesOn(member, normalizedFile) {
44
+ return member.focusedTests.some((t) => normalizePath(t) === normalizedFile);
45
+ }
46
+ /**
47
+ * Whether `member` overlaps `testFile`. Focused tests win outright; otherwise
48
+ * the file must be one the member changed, or live under a directory it
49
+ * changed. A member that changed only top-level files (dir `''`) never matches
50
+ * by directory — an empty prefix would otherwise own the entire repo.
51
+ */
52
+ function overlaps(member, testFile) {
53
+ const file = normalizePath(testFile);
54
+ if (focusesOn(member, file))
55
+ return true;
56
+ for (const raw of member.changedPaths) {
57
+ const changed = normalizePath(raw);
58
+ if (changed === file)
59
+ return true;
60
+ const dir = dirOf(changed);
61
+ if (dir !== '' && file.startsWith(`${dir}/`))
62
+ return true;
63
+ }
64
+ return false;
65
+ }
66
+ /**
67
+ * Stage-1 attribution: overlap only, no git, no LLM (AC1). Deterministic —
68
+ * candidates are compared in ascending issue order so the same inputs always
69
+ * produce the same verdict.
70
+ */
71
+ function attributeByOverlap(failing, footprints) {
72
+ const attributed = new Map();
73
+ const ambiguous = [];
74
+ const unattributed = [];
75
+ const members = [...footprints].sort((a, b) => a.issue - b.issue);
76
+ for (const test of failing) {
77
+ // A focused-test match is the stronger signal: when any member claims the
78
+ // file explicitly, members that merely changed a neighbouring path are not
79
+ // candidates — otherwise the strong signal could never break a tie.
80
+ const file = normalizePath(test.file);
81
+ const focused = members.filter((m) => focusesOn(m, file));
82
+ const candidates = focused.length > 0 ? focused : members.filter((m) => overlaps(m, test.file));
83
+ if (candidates.length === 1) {
84
+ const issue = candidates[0].issue;
85
+ attributed.set(issue, [...(attributed.get(issue) ?? []), test]);
86
+ }
87
+ else if (candidates.length > 1) {
88
+ ambiguous.push({ test, candidates: candidates.map((m) => m.issue) });
89
+ }
90
+ else {
91
+ unattributed.push(test);
92
+ }
93
+ }
94
+ return { attributed, ambiguous, unattributed };
95
+ }
96
+ /** Members with at least one failing test attributed to them, ascending. */
97
+ function offendersOf(attributed) {
98
+ return [...attributed.keys()].sort((a, b) => a - b);
99
+ }
100
+ // --- Parsers ---
101
+ /**
102
+ * Failing tests from `vitest run --reporter=json` output. Tolerates the
103
+ * surrounding noise a real run prints around the JSON document (vitest writes
104
+ * the report to stdout alongside its own banner), and skips any record it
105
+ * cannot read rather than throwing — an unparseable suite report degrades to
106
+ * "no attributable tests", which routes to bisect, never to a guess.
107
+ */
108
+ function parseVitestJson(stdout) {
109
+ if (stdout === null)
110
+ return [];
111
+ const parsed = extractJsonObject(stdout);
112
+ if (parsed === null)
113
+ return [];
114
+ const results = parsed.testResults;
115
+ if (!Array.isArray(results))
116
+ return [];
117
+ const out = [];
118
+ for (const raw of results) {
119
+ if (raw === null || typeof raw !== 'object')
120
+ continue;
121
+ const suite = raw;
122
+ const file = typeof suite.name === 'string' ? suite.name : null;
123
+ if (file === null || !Array.isArray(suite.assertionResults))
124
+ continue;
125
+ for (const rawAssertion of suite.assertionResults) {
126
+ if (rawAssertion === null || typeof rawAssertion !== 'object')
127
+ continue;
128
+ const assertion = rawAssertion;
129
+ if (assertion.status !== 'failed')
130
+ continue;
131
+ const name = typeof assertion.fullName === 'string'
132
+ ? assertion.fullName
133
+ : typeof assertion.title === 'string'
134
+ ? assertion.title
135
+ : '';
136
+ out.push(failingTest(file, name));
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+ /** The first balanced `{...}` document in `text`, parsed; null when there is none. */
142
+ function extractJsonObject(text) {
143
+ const start = text.indexOf('{');
144
+ if (start === -1)
145
+ return null;
146
+ let depth = 0;
147
+ let inString = false;
148
+ let escaped = false;
149
+ for (let i = start; i < text.length; i++) {
150
+ const ch = text[i];
151
+ if (inString) {
152
+ if (escaped)
153
+ escaped = false;
154
+ else if (ch === '\\')
155
+ escaped = true;
156
+ else if (ch === '"')
157
+ inString = false;
158
+ continue;
159
+ }
160
+ if (ch === '"')
161
+ inString = true;
162
+ else if (ch === '{')
163
+ depth++;
164
+ else if (ch === '}') {
165
+ depth--;
166
+ if (depth === 0) {
167
+ try {
168
+ return JSON.parse(text.slice(start, i + 1));
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ }
174
+ }
175
+ }
176
+ return null;
177
+ }
178
+ /**
179
+ * The established commit ↔ member key: the `(#N)` trailer a squash-merge (and
180
+ * this project's commit convention) leaves at the end of the subject. A commit
181
+ * WITHOUT one maps to `issue: null` — unattributable, never a guess.
182
+ */
183
+ const ISSUE_TRAILER_RE = /\(#(\d+)\)\s*$/;
184
+ /**
185
+ * A full or abbreviated sha as git prints it — 7 to 40 hex characters. Bisect
186
+ * endpoints and revert argv are validated against this before they become git
187
+ * arguments (CWE-88).
188
+ */
189
+ exports.SHA_RE = /^[0-9a-f]{7,40}$/i;
190
+ /**
191
+ * A git ref name safe to interpolate into argv (CWE-88) — the pattern
192
+ * `groundtruth.ts` established for branch names off untrusted milestone text.
193
+ * Defined here, beside `SHA_RE`, so the package has one "validate before it
194
+ * becomes a git argument" home rather than a copy per module.
195
+ */
196
+ exports.SAFE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
197
+ /**
198
+ * Parse `git log --reverse --format=%H%x09%s <base>..<head>` — OLDEST FIRST,
199
+ * which is the order eviction ranges and bisect boundaries are built in.
200
+ * Malformed lines are skipped; a line whose subject has no `(#N)` trailer
201
+ * still yields a commit, with `issue: null`.
202
+ */
203
+ function parseBoundaryCommits(gitLogOutput) {
204
+ if (gitLogOutput === null)
205
+ return [];
206
+ const out = [];
207
+ for (const line of gitLogOutput.split('\n')) {
208
+ if (line.trim() === '')
209
+ continue;
210
+ const tab = line.indexOf('\t');
211
+ const sha = (tab === -1 ? line : line.slice(0, tab)).trim();
212
+ if (!exports.SHA_RE.test(sha))
213
+ continue;
214
+ const subject = tab === -1 ? '' : line.slice(tab + 1);
215
+ const match = ISSUE_TRAILER_RE.exec(subject);
216
+ out.push({ sha, subject, issue: match ? Number.parseInt(match[1], 10) : null });
217
+ }
218
+ return out;
219
+ }
220
+ /**
221
+ * Group boundary commits (oldest first) by member.
222
+ *
223
+ * `from`/`to` describe the member's span for reporting; `commits` is what a
224
+ * revert actually walks. They differ when members interleave — reverting the
225
+ * literal `from^..to` range would then revert a neighbour's commits too, so
226
+ * eviction reverts the explicit commits instead, ordered by `positions`:
227
+ * for a branch `A1 B1 A2 B2`, evicting both members must revert
228
+ * `B2 A2 B1 A1`, not each member's commits as a block.
229
+ */
230
+ function memberRanges(boundary) {
231
+ const byIssue = new Map();
232
+ boundary.forEach((commit, position) => {
233
+ if (commit.issue === null)
234
+ return;
235
+ const group = byIssue.get(commit.issue) ?? { commits: [], positions: [] };
236
+ group.commits.push(commit.sha);
237
+ group.positions.push(position);
238
+ byIssue.set(commit.issue, group);
239
+ });
240
+ return [...byIssue.entries()].map(([issue, group]) => ({
241
+ issue,
242
+ from: group.commits[0],
243
+ to: group.commits[group.commits.length - 1],
244
+ commits: group.commits,
245
+ positions: group.positions,
246
+ }));
247
+ }
248
+ /**
249
+ * The member owning `sha`, or null when the commit carries no `(#N)` trailer.
250
+ *
251
+ * Either side may be abbreviated (bisect reports a short sha; boundary commits
252
+ * are full), so matching is prefix-based — but an AMBIGUOUS prefix returns null
253
+ * rather than the first hit. Attribution feeds eviction, and eviction reverts
254
+ * work: a wrong answer here destroys an innocent member's commits, so "cannot
255
+ * tell" must never collapse into "probably this one".
256
+ */
257
+ function memberOfCommit(boundary, sha) {
258
+ const needle = sha.toLowerCase();
259
+ const matches = boundary.filter((c) => {
260
+ const candidate = c.sha.toLowerCase();
261
+ return candidate.startsWith(needle) || needle.startsWith(candidate);
262
+ });
263
+ const issues = new Set(matches.map((m) => m.issue));
264
+ if (matches.length === 0 || issues.size > 1)
265
+ return null;
266
+ return matches[0].issue;
267
+ }
268
+ //# sourceMappingURL=attribution.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attribution.js","sourceRoot":"","sources":["../src/attribution.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Failure attribution, stage 2 (#472 AC1): when overlap cannot say which
3
+ * member broke a test, `git bisect` decides — a pure script over the batch
4
+ * branch's issue-boundary commits, running ONLY the failing tests. No LLM is
5
+ * involved, and no attribution is ever guessed: a first-bad commit that is not
6
+ * a member's commit reports `unattributable` rather than blaming a neighbour.
7
+ *
8
+ * Every subprocess goes through the injectable `ExecFn` (project.ts pattern),
9
+ * and the bisect state is ALWAYS reset — a repo left mid-bisect poisons every
10
+ * later git operation in that checkout.
11
+ */
12
+ import { type BoundaryCommit } from './attribution';
13
+ import type { ExecFn } from './project';
14
+ /** What a bisect run concluded. */
15
+ export type BisectOutcome =
16
+ /** The first bad commit is a member's — that member owns the failure. */
17
+ {
18
+ kind: 'first-bad';
19
+ sha: string;
20
+ issue: number;
21
+ }
22
+ /** The failing tests pass at `bad` — nothing to attribute (someone already fixed it). */
23
+ | {
24
+ kind: 'green';
25
+ }
26
+ /** A first bad commit exists but belongs to no member (e.g. a batch-level fix commit). */
27
+ | {
28
+ kind: 'unattributable';
29
+ sha: string;
30
+ detail: string;
31
+ }
32
+ /** The bisect could not run or its output could not be read — never a guess. */
33
+ | {
34
+ kind: 'error';
35
+ detail: string;
36
+ };
37
+ export interface BisectOptions {
38
+ /** The batch checkout to bisect in. */
39
+ repoDir: string;
40
+ /** Last known-good commit (the batch base). */
41
+ good: string;
42
+ /** Known-bad commit (the batch branch head). */
43
+ bad: string;
44
+ /**
45
+ * The command `git bisect run` executes at each step. MUST be scoped to the
46
+ * failing tests only — a full suite makes the bisect cost O(members × suite).
47
+ * It is executed as a binary, so it must come from operator configuration —
48
+ * never from issue-comment or suite-output text.
49
+ */
50
+ testCommand: readonly string[];
51
+ /** Boundary commits of the batch branch, for mapping the first-bad sha to a member. */
52
+ boundary: readonly BoundaryCommit[];
53
+ /**
54
+ * Called when cleanup (bisect reset / checkout restore) fails. This module
55
+ * has no journal of its own; the caller routes these into one.
56
+ */
57
+ onWarn?: (detail: string) => void;
58
+ }
59
+ /**
60
+ * Bisect the batch branch for the commit that broke `testCommand`.
61
+ *
62
+ * The known-bad end is verified first: if the failing tests PASS at `bad`
63
+ * there is nothing to attribute (`green`), and starting a bisect from a good
64
+ * "bad" end would otherwise walk the whole range and report nonsense.
65
+ */
66
+ export declare function runAttributionBisect(exec: ExecFn, opts: BisectOptions): BisectOutcome;
67
+ //# sourceMappingURL=bisect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bisect.d.ts","sourceRoot":"","sources":["../src/bisect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,KAAK,cAAc,EAAuC,MAAM,eAAe,CAAC;AACzF,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC,mCAAmC;AACnC,MAAM,MAAM,aAAa;AACvB,yEAAyE;AACvE;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AACnD,yFAAyF;GACvF;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE;AACnB,0FAA0F;GACxF;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE;AACzD,gFAAgF;GAC9E;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtC,MAAM,WAAW,aAAa;IAC5B,uCAAuC;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,gDAAgD;IAChD,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;OAKG;IACH,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B,uFAAuF;IACvF,QAAQ,EAAE,SAAS,cAAc,EAAE,CAAC;IACpC;;;OAGG;IACH,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAKD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,aAAa,GAAG,aAAa,CAuGrF"}