@ai-dossier/sched 0.2.1 → 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 +179 -17
- package/dist/attribution.d.ts +132 -0
- package/dist/attribution.d.ts.map +1 -0
- package/dist/attribution.js +268 -0
- package/dist/attribution.js.map +1 -0
- package/dist/bisect.d.ts +67 -0
- package/dist/bisect.d.ts.map +1 -0
- package/dist/bisect.js +122 -0
- package/dist/bisect.js.map +1 -0
- package/dist/dispatch.d.ts +48 -1
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +92 -5
- package/dist/dispatch.js.map +1 -1
- package/dist/engine.d.ts +53 -13
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +421 -75
- package/dist/engine.js.map +1 -1
- package/dist/enqueue.d.ts +9 -0
- package/dist/enqueue.d.ts.map +1 -1
- package/dist/enqueue.js +96 -12
- package/dist/enqueue.js.map +1 -1
- package/dist/groundtruth.d.ts +66 -5
- package/dist/groundtruth.d.ts.map +1 -1
- package/dist/groundtruth.js +150 -5
- package/dist/groundtruth.js.map +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +51 -2
- package/dist/index.js.map +1 -1
- package/dist/persist.d.ts.map +1 -1
- package/dist/persist.js +14 -0
- package/dist/persist.js.map +1 -1
- package/dist/recovery.d.ts +263 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +730 -0
- package/dist/recovery.js.map +1 -0
- package/dist/scheduler.d.ts +12 -0
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +53 -58
- package/dist/scheduler.js.map +1 -1
- package/dist/state.d.ts +57 -4
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +238 -8
- package/dist/state.js.map +1 -1
- package/dist/status.d.ts +16 -4
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +10 -4
- package/dist/status.js.map +1 -1
- package/dist/teardown.d.ts +57 -0
- package/dist/teardown.d.ts.map +1 -0
- package/dist/teardown.js +242 -0
- package/dist/teardown.js.map +1 -0
- package/dist/types.d.ts +155 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +39 -5
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@ai-dossier/sched)
|
|
4
4
|
|
|
5
5
|
Deterministic scheduler core for dossier batch cycles — queue, worker slots, typed state
|
|
6
|
-
machines, crash-safe persistence,
|
|
7
|
-
processes, verifying their completion against ground truth,
|
|
8
|
-
stall/escalation ladder
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
machines, crash-safe persistence, the **dispatch engine** (#464: spawning agent
|
|
7
|
+
processes, verifying their completion against ground truth, mechanizing the
|
|
8
|
+
stall/escalation ladder), and since #468 the **PR watcher + tail work** (parked-PR
|
|
9
|
+
watching, script-based teardown, cheap-tier report dispatch — retiring the fleet
|
|
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
|
|
12
|
+
invokes an LLM** — it spawns the agent process the operator configured and reconciles
|
|
13
|
+
the durable record (`ai-dossier runstate` / `gh` / `git`) that the spawned run leaves
|
|
14
|
+
behind.
|
|
11
15
|
|
|
12
16
|
Design: RFC-0001 *Batch Cycles* §B/C.1/D (the RFC lives on branch `docs/batch-cycles-rfc`,
|
|
13
17
|
not yet merged to `main`; the §D state machines are frozen verbatim into the types below).
|
|
@@ -17,14 +21,14 @@ this state machine makes impossible to forget.
|
|
|
17
21
|
|
|
18
22
|
## CLI surface
|
|
19
23
|
|
|
20
|
-
Consumed through the monorepo CLI (`@ai-dossier/cli` ≥ 0.
|
|
24
|
+
Consumed through the monorepo CLI (`@ai-dossier/cli` ≥ 0.19.0):
|
|
21
25
|
|
|
22
26
|
```bash
|
|
23
27
|
ai-dossier sched enqueue --issues 101,105..109 --deps 100 --tier strong # flags
|
|
24
28
|
ai-dossier sched enqueue --from-manifest batch-prep.json # batch-prep output
|
|
25
|
-
ai-dossier sched start # the dispatch engine: spawn, verify, escalate (Ctrl-C stops it)
|
|
29
|
+
ai-dossier sched start # the dispatch engine: spawn, verify, escalate, watch parked PRs (Ctrl-C stops it)
|
|
26
30
|
ai-dossier sched start --once # a single reconcile+refill tick (cron-style)
|
|
27
|
-
ai-dossier sched status # queue
|
|
31
|
+
ai-dossier sched status # queue (+pr/cleanup), parked PRs, slots, batches, blocked/failed
|
|
28
32
|
ai-dossier sched pause # stop NEW assignments; live units keep running
|
|
29
33
|
ai-dossier sched resume
|
|
30
34
|
ai-dossier sched abandon --issue 42 --reason "operator abort"
|
|
@@ -82,6 +86,77 @@ Two engine-safety policies were explicit product decisions on #464:
|
|
|
82
86
|
Only `issue:<n>` units are dispatched today — batch member sequencing is a follow-up
|
|
83
87
|
(#464 non-goal).
|
|
84
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
|
+
|
|
85
160
|
## API surface
|
|
86
161
|
|
|
87
162
|
```ts
|
|
@@ -91,13 +166,46 @@ import {
|
|
|
91
166
|
parseManifest, // batch-prep JSON → EnqueueInput[]
|
|
92
167
|
computeAssignments, // pure: fill idle slots with runnable units, bounded by max_slots
|
|
93
168
|
runnableUnits, // pure: which units may run right now (dep-gated)
|
|
94
|
-
tick, // one engine cycle: reconcile + verify + refill + spawn
|
|
169
|
+
tick, // one engine cycle: reconcile + verify + refill + spawn,
|
|
170
|
+
// and since #468: park-watch, teardown, report dispatch
|
|
95
171
|
runLoop, // the sched start loop (tick, sleep, repeat)
|
|
96
|
-
type TickResult, // what one tick did (spawned/
|
|
97
|
-
|
|
172
|
+
type TickResult, // what one tick did (spawned/parked/merge-accepted/report-dispatched/
|
|
173
|
+
// teardown/completed/redispatched/failed/blocked)
|
|
174
|
+
type EngineDeps, // inject everything the engine touches (store/journal/spawn/ground
|
|
175
|
+
// truth/clock/repoDir/teardownExec)
|
|
98
176
|
createSpawnDeps, // real detached-spawn process I/O
|
|
99
|
-
createExecGroundTruth, // runstate/gh/git ground truth via subprocesses (injectable exec)
|
|
100
|
-
|
|
177
|
+
createExecGroundTruth, // runstate/gh/git ground truth via subprocesses (injectable exec);
|
|
178
|
+
// since #468 also gh pr view PR state + setup info from comments
|
|
179
|
+
resolveDispatch, // config → resolved command/prompt/report-prompt/tier-models/timers
|
|
180
|
+
buildReportPrompt, // report-agent prompt ({issue}/{pr}/{cleanup} substituted)
|
|
181
|
+
reportTierFor, // report (re)dispatch tier after N escalations
|
|
182
|
+
isParkedMilestone, // ship-phase awaiting-merge + pr= → the park signal
|
|
183
|
+
prOfMilestone, // a milestone's pr= key as a positive integer
|
|
184
|
+
parsePrViewJson, // gh pr view --json → PR truth (mergedAt/mergeable/blocked label)
|
|
185
|
+
parseSetupInfo, // gh issue view --json comments → teardown inputs
|
|
186
|
+
runTeardown, // #468 script teardown for a merged unit (pool return / worktree remove)
|
|
187
|
+
isSafeWorktree, // worktree-path containment check (CWE-22)
|
|
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)
|
|
101
209
|
Journal, // append-only events.jsonl
|
|
102
210
|
transitionIssue, transitionBatch, transitionSlot, // typed §D transitions
|
|
103
211
|
TRANSITIONS, // the transition tables themselves (for previews)
|
|
@@ -119,7 +227,8 @@ fake agents and stub ground truth; no LLM calls anywhere.
|
|
|
119
227
|
```
|
|
120
228
|
~/.dossier/sched/<project>/
|
|
121
229
|
├── state.json # hot operational truth — atomic tmp+fsync+rename writes;
|
|
122
|
-
├── config.json # durable intent: max_slots, stall_timeout_ms, reconcile_interval_ms,
|
|
230
|
+
├── config.json # durable intent: max_slots, stall_timeout_ms, reconcile_interval_ms,
|
|
231
|
+
│ # pr_poll_interval_ms, dispatch (incl. report_prompt)
|
|
123
232
|
├── events.jsonl # append-only event journal (the operator's flight recorder)
|
|
124
233
|
├── runs/ # per-unit agent output logs (issue-<n>.log)
|
|
125
234
|
└── .sched-lock/ # cross-process directory mutex (pid; stolen from dead holders)
|
|
@@ -133,14 +242,65 @@ fake agents and stub ground truth; no LLM calls anywhere.
|
|
|
133
242
|
- **Corrupt state is loud**: `load()` throws `CorruptStateError` naming the file —
|
|
134
243
|
never a silent queue reset. `state.json` is deletable and rebuildable from GitHub,
|
|
135
244
|
which remains the system of record.
|
|
136
|
-
- **Schema**: state/config files from #460 (schema 1.0.0)
|
|
137
|
-
automatically (slot `branch`/`last_head
|
|
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`,
|
|
249
|
+
and state-level `last_pr_poll_at` backfill to null).
|
|
138
250
|
- **`max_slots`** bounds live units (`assigned | running | recovering`); dependency
|
|
139
251
|
edges gate readiness — an issue with an unmerged dependency, and a batch behind an
|
|
140
252
|
unmerged batch, are never runnable.
|
|
141
253
|
- **Pause** stops new assignments only; abandon routes through the typed failure rails
|
|
142
254
|
(`evicted → requeued{full}` for batch members — nothing green is discarded).
|
|
143
255
|
|
|
256
|
+
## The PR watcher + tail work (#468)
|
|
257
|
+
|
|
258
|
+
Dispatched runs park their PR on `auto-merge` (detached ship mode — the default
|
|
259
|
+
prompt instructs it) and exit. The engine owns everything after the park:
|
|
260
|
+
|
|
261
|
+
1. **Park detection (AC1)** — an agent exit whose latest milestone is the ship
|
|
262
|
+
phase's `awaiting-merge` (with `pr=`) is a VERIFIED park, not an unverified
|
|
263
|
+
exit: the entry moves to `parked`, the slot is released (a waiting unit
|
|
264
|
+
consumes zero slots), and the watcher takes over.
|
|
265
|
+
2. **PR watching (AC1)** — parked PRs are polled every `pr_poll_interval_ms`
|
|
266
|
+
(default 150 s — "every 2–3 min", persisted `last_pr_poll_at` so a restart
|
|
267
|
+
honors the cadence; checked on each reconcile tick when due, so a
|
|
268
|
+
`reconcile_interval_ms` longer than the interval slows the effective cadence)
|
|
269
|
+
via `gh pr view --json state,mergedAt,mergeable,labels`.
|
|
270
|
+
A merge is accepted only when state is MERGED **and** `mergedAt` is non-null
|
|
271
|
+
**and** the issue is closed — never inferred from an agent exit. An
|
|
272
|
+
unreachable poll pauses the watcher (decision 2, option A).
|
|
273
|
+
3. **Failure states (AC3)** — `CONFLICTING`, closed-unmerged, or the
|
|
274
|
+
`auto-merge-blocked` label fail the unit with the reason and block its
|
|
275
|
+
TRANSITIVE dependents. The engine never merges anything itself.
|
|
276
|
+
4. **Gating on MERGE, not park (AC4)** — `parked` is not a satisfied status:
|
|
277
|
+
dependents stay blocked until the merge lands (`parked → shipped`).
|
|
278
|
+
5. **Teardown as a script (AC2)** — on merge, the run's setup milestone
|
|
279
|
+
(recovered once from the issue's comments — collaborator-authored only, and
|
|
280
|
+
the worktree path must pass a containment check before any destructive
|
|
281
|
+
subprocess) chooses the script: pool-claimed worktrees run
|
|
282
|
+
`worktree-pool return --path <wt> --json` (the pool's own self-check is the
|
|
283
|
+
verification); cold worktrees run
|
|
284
|
+
`git worktree remove --force <wt>` with a path-gone check. Both are
|
|
285
|
+
verify-first idempotent; a failed step records `cleanup=failed-<step>` on
|
|
286
|
+
the entry and in the journal — degradation, never unit failure.
|
|
287
|
+
6. **Report dispatch (AC2)** — once teardown is recorded (when a slot is
|
|
288
|
+
free — a waiting report consumes zero slots), a **mechanical-tier** report
|
|
289
|
+
agent is spawned with the report-phase prompt (`dispatch.report_prompt`;
|
|
290
|
+
`{issue}`/`{pr}`/`{cleanup}` substituted — the cleanup status rides into
|
|
291
|
+
the report). It completes like any agent; a report that stalls climbs the
|
|
292
|
+
same ladder (mechanical → mid → strong, cap 2), and at the cap the unit
|
|
293
|
+
completes (`done`, reason `report-escalation-cap`) with a `report-failed`
|
|
294
|
+
journal event — the work is merged, so dependents are never re-blocked.
|
|
295
|
+
The full-cycle tail-run pattern (re-dispatching a whole run for
|
|
296
|
+
teardown+report) is retired.
|
|
297
|
+
|
|
298
|
+
`sched status` shows parked PRs (zero slots, with the last poll's age), a
|
|
299
|
+
`pr` column and a `cleanup` column on the queue; every watcher decision lands
|
|
300
|
+
in `events.jsonl` (`pr-parked`, `merge-accepted`, `pr-watch-failed`,
|
|
301
|
+
`pr-watch-waiting`, `teardown-done`/`teardown-failed`, `report-dispatched`,
|
|
302
|
+
`report-failed`, `ground-truth-unreachable`).
|
|
303
|
+
|
|
144
304
|
## Development
|
|
145
305
|
|
|
146
306
|
```bash
|
|
@@ -152,4 +312,6 @@ npm test # vitest — state machines, persistence, crash/restart, engine
|
|
|
152
312
|
|
|
153
313
|
No network, no GitHub in unit tests — persistence tests run on temp directories; the
|
|
154
314
|
integration tests spawn fake-agent fixtures against a scratch git repo with stubbed
|
|
155
|
-
ground truth.
|
|
315
|
+
ground truth. The #468 integration tests run the full detached-ship tail
|
|
316
|
+
(park → watch → merge → REAL worktree removal → report) end-to-end, including a
|
|
317
|
+
sched restart mid-watch.
|
|
@@ -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
|