mandrel 1.85.0 → 1.86.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.
@@ -34,54 +34,107 @@ export function isWorktreeLockFailure(stderr) {
34
34
 
35
35
  /* node:coverage ignore next */
36
36
  export function isWorkingTreeClean(cwd) {
37
- const res = gitSpawn(cwd, 'status', '--porcelain');
38
- if (res.status !== 0) return false;
39
- return res.stdout.trim() === '';
37
+ return defaultFfProbes.isClean(cwd);
40
38
  }
41
39
 
42
40
  /* node:coverage ignore next */
43
41
  export function fetchRef(cwd, remoteName, ref) {
44
- const res = gitSpawn(cwd, 'fetch', '--quiet', remoteName, ref);
45
- if (res.status !== 0) return { ok: false, stderr: res.stderr };
46
- return { ok: true };
42
+ return defaultFfProbes.fetch(cwd, remoteName, ref);
47
43
  }
48
44
 
49
45
  /* node:coverage ignore next */
50
46
  export function canFastForward(cwd, baseBranch, remoteName) {
51
- const ref = `${remoteName}/${baseBranch}`;
52
- const ahead = gitSpawn(
53
- cwd,
54
- 'rev-list',
55
- '--left-right',
56
- '--count',
57
- `${baseBranch}...${ref}`,
58
- );
59
- if (ahead.status !== 0) {
60
- return { ok: false, behind: 0, reason: 'rev-list-failed' };
61
- }
62
- const parts = ahead.stdout.trim().split(/\s+/);
63
- const localAhead = Number(parts[0]) || 0;
64
- const remoteAhead = Number(parts[1]) || 0;
65
- if (localAhead > 0) {
66
- return { ok: false, behind: remoteAhead, reason: 'not-fast-forward' };
67
- }
68
- return { ok: true, behind: remoteAhead };
47
+ return defaultFfProbes.canFastForward(cwd, baseBranch, remoteName);
69
48
  }
70
49
 
71
50
  /* node:coverage ignore next */
72
51
  export function checkoutBranch(cwd, branch) {
73
- const res = gitSpawn(cwd, 'checkout', branch);
74
- if (res.status !== 0) return { ok: false, stderr: res.stderr };
75
- return { ok: true };
52
+ return defaultFfProbes.checkout(cwd, branch);
76
53
  }
77
54
 
78
55
  /* node:coverage ignore next */
79
56
  export function mergeFastForward(cwd, ref) {
80
- const res = gitSpawn(cwd, 'merge', '--ff-only', ref);
81
- if (res.status !== 0) return { ok: false, stderr: res.stderr };
82
- return { ok: true };
57
+ return defaultFfProbes.merge(cwd, ref);
83
58
  }
84
59
 
60
+ /**
61
+ * Build the fast-forward probe bundle bound to a `gitSpawn`.
62
+ *
63
+ * This is the **single implementation** of the FF/base-sync git wrappers.
64
+ * The standalone exports above delegate to a default instance bound to the
65
+ * shared `gitSpawn`; callers that need to inject their own spawn for testing
66
+ * (e.g. the epic-cleanup runner) call this factory directly instead of
67
+ * hand-rolling a parallel copy (framework-gap #4379). The bundle also carries
68
+ * `currentBranch` so an injecting caller gets the whole FF surface from one
69
+ * place.
70
+ *
71
+ * @param {(cwd: string, ...args: string[]) => { status: number, stdout: string, stderr: string }} [spawn]
72
+ * @returns {{
73
+ * isClean: (cwd: string) => boolean,
74
+ * currentBranch: (cwd: string) => string|null,
75
+ * fetch: (cwd: string, remoteName: string, ref: string) => { ok: boolean, stderr?: string },
76
+ * canFastForward: (cwd: string, baseBranch: string, remoteName: string) => { ok: boolean, behind: number, reason?: string },
77
+ * checkout: (cwd: string, branch: string) => { ok: boolean, stderr?: string },
78
+ * merge: (cwd: string, ref: string) => { ok: boolean, stderr?: string },
79
+ * }}
80
+ */
81
+ export function makeFfProbes(spawn = gitSpawn) {
82
+ return {
83
+ isClean: (cwd) => {
84
+ const res = spawn(cwd, 'status', '--porcelain');
85
+ return res.status === 0 && String(res.stdout ?? '').trim() === '';
86
+ },
87
+ currentBranch: (cwd) => {
88
+ const res = spawn(cwd, 'symbolic-ref', '--quiet', '--short', 'HEAD');
89
+ return res.status !== 0 ? null : String(res.stdout ?? '').trim() || null;
90
+ },
91
+ fetch: (cwd, remoteName, ref) => {
92
+ const res = spawn(cwd, 'fetch', '--quiet', remoteName, ref);
93
+ return res.status === 0
94
+ ? { ok: true }
95
+ : { ok: false, stderr: res.stderr };
96
+ },
97
+ canFastForward: (cwd, baseBranch, remoteName) => {
98
+ const ref = `${remoteName}/${baseBranch}`;
99
+ const ahead = spawn(
100
+ cwd,
101
+ 'rev-list',
102
+ '--left-right',
103
+ '--count',
104
+ `${baseBranch}...${ref}`,
105
+ );
106
+ if (ahead.status !== 0) {
107
+ return { ok: false, behind: 0, reason: 'rev-list-failed' };
108
+ }
109
+ const parts = String(ahead.stdout ?? '')
110
+ .trim()
111
+ .split(/\s+/);
112
+ const localAhead = Number(parts[0]) || 0;
113
+ const remoteAhead = Number(parts[1]) || 0;
114
+ if (localAhead > 0) {
115
+ return { ok: false, behind: remoteAhead, reason: 'not-fast-forward' };
116
+ }
117
+ return { ok: true, behind: remoteAhead };
118
+ },
119
+ checkout: (cwd, branch) => {
120
+ const res = spawn(cwd, 'checkout', branch);
121
+ return res.status === 0
122
+ ? { ok: true }
123
+ : { ok: false, stderr: res.stderr };
124
+ },
125
+ merge: (cwd, ref) => {
126
+ const res = spawn(cwd, 'merge', '--ff-only', ref);
127
+ return res.status === 0
128
+ ? { ok: true }
129
+ : { ok: false, stderr: res.stderr };
130
+ },
131
+ };
132
+ }
133
+
134
+ // Default instance bound to the shared gitSpawn; the standalone wrappers
135
+ // above delegate to it so there is exactly one FF-probe implementation.
136
+ const defaultFfProbes = makeFfProbes(gitSpawn);
137
+
85
138
  /* node:coverage ignore next */
86
139
  export function removeWorktree(worktreePath, cwd) {
87
140
  const plain = gitSpawn(cwd, 'worktree', 'remove', worktreePath);
@@ -40,7 +40,11 @@
40
40
  * - runs `git remote prune` to drop stale `<remote>/...` tracking
41
41
  * refs left behind by `gh pr merge --delete-branch`;
42
42
  * - deletes the `wt-branch` scratch ref left by `story-close.js`'s
43
- * internal merge worktree when it is no longer checked out.
43
+ * internal merge worktree when it is no longer checked out;
44
+ * - fast-forwards the base branch to `<remote>/<baseBranch>` after the
45
+ * confirmed merge so the local checkout converges to origin with no
46
+ * manual `git pull` (skipped when the epic branch is kept for an
47
+ * open PR).
44
48
  * 3. Record one classification entry per invocation (`reaped`,
45
49
  * `no-state`, `failed`, or `skipped-duplicate`) so failures surface
46
50
  * in the lifecycle ledger alongside Cleaner's archival outcome.
@@ -243,8 +247,8 @@ export class BranchCleaner {
243
247
  * Pure: condense a `reapEpicBranches()` result into the counts that the
244
248
  * classification log carries. Exported for tests.
245
249
  *
246
- * @param {{ reaped: Array<object>, pruned: { pruned: string[] }|null, wtBranch: { deleted: boolean }|null }} result
247
- * @returns {{ branchesDeleted: number, worktreesRemoved: number, tracksPruned: number, wtBranchDeleted: boolean }}
250
+ * @param {{ reaped: Array<object>, pruned: { pruned: string[] }|null, wtBranch: { deleted: boolean }|null, fastForward: { applied: boolean }|null }} result
251
+ * @returns {{ branchesDeleted: number, worktreesRemoved: number, tracksPruned: number, wtBranchDeleted: boolean, fastForwarded: boolean }}
248
252
  */
249
253
  export function summarizeReap(result) {
250
254
  const reaped = Array.isArray(result?.reaped) ? result.reaped : [];
@@ -255,5 +259,6 @@ export function summarizeReap(result) {
255
259
  ).length,
256
260
  tracksPruned: result?.pruned?.pruned?.length ?? 0,
257
261
  wtBranchDeleted: result?.wtBranch?.deleted === true,
262
+ fastForwarded: result?.fastForward?.applied === true,
258
263
  };
259
264
  }
@@ -47,6 +47,51 @@ export const DEFAULT_GATE_REGISTRY = {
47
47
  },
48
48
  };
49
49
 
50
+ /** Gate names that fan out to per-kind refreshers via `_gateKind` tags. */
51
+ const COMPOSITE_GATE_KINDS = new Set(['check-baselines']);
52
+
53
+ /**
54
+ * Resolve the refresh metadata + the regression subset a single failure
55
+ * cycle should act on.
56
+ *
57
+ * A direct per-kind gate (`check-maintainability` / `check-crap`) maps
58
+ * straight through. The unified `check-baselines` gate is a **composite**:
59
+ * its projected regressions are tagged with `_gateKind`, and each attribution
60
+ * cycle picks the first regressed kind not already refreshed this cycle
61
+ * (`cycleState.refreshedKinds`), scoping classify + refresh to that one kind.
62
+ * The retry loop re-drives for any remaining kinds — so a close that regresses
63
+ * both maintainability and CRAP converges in two cycles instead of dead-ending
64
+ * on an unrecognised gate name (framework-gap #4377).
65
+ *
66
+ * @returns {{ meta: object, regressions: Array } | null}
67
+ */
68
+ export function resolveGateMeta({
69
+ gateName,
70
+ regressions,
71
+ cycleState = null,
72
+ gateRegistry = DEFAULT_GATE_REGISTRY,
73
+ }) {
74
+ if (!Array.isArray(regressions) || regressions.length === 0) return null;
75
+
76
+ const direct = gateRegistry[gateName];
77
+ if (direct) return { meta: direct, regressions };
78
+
79
+ if (COMPOSITE_GATE_KINDS.has(gateName)) {
80
+ const refreshed = cycleState?.refreshedKinds ?? new Set();
81
+ for (const row of regressions) {
82
+ const kind = row?._gateKind;
83
+ const subMeta = kind ? gateRegistry[`check-${kind}`] : null;
84
+ if (subMeta && !refreshed.has(subMeta.kind)) {
85
+ return {
86
+ meta: subMeta,
87
+ regressions: regressions.filter((r) => r?._gateKind === kind),
88
+ };
89
+ }
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
50
95
  /**
51
96
  * Top-level: handle a baseline gate failure by classifying drift and
52
97
  * either auto-refreshing (attributable-only) or posting friction (any
@@ -88,11 +133,14 @@ export async function handleBaselineGateFailure({
88
133
  gateRegistry = DEFAULT_GATE_REGISTRY,
89
134
  deps = {},
90
135
  } = {}) {
91
- const meta = gateRegistry[gateName];
92
- if (!meta) return { action: 'rethrow' };
93
- if (!Array.isArray(regressions) || regressions.length === 0) {
94
- return { action: 'rethrow' };
95
- }
136
+ const resolved = resolveGateMeta({
137
+ gateName,
138
+ regressions,
139
+ cycleState,
140
+ gateRegistry,
141
+ });
142
+ if (!resolved) return { action: 'rethrow' };
143
+ const { meta, regressions: scopedRegressions } = resolved;
96
144
 
97
145
  const classify = deps.classifyBaselineDrift ?? defaultClassifyBaselineDrift;
98
146
  const renderBody =
@@ -114,7 +162,7 @@ export async function handleBaselineGateFailure({
114
162
 
115
163
  const epicRef = `origin/${epicBranch}`;
116
164
  const { attributable, nonAttributable } = classify({
117
- regressions,
165
+ regressions: scopedRegressions,
118
166
  storyDiffPaths,
119
167
  epicRef,
120
168
  cwd,
@@ -244,6 +244,24 @@ export const PROJECTORS = {
244
244
  'check-crap': projectCrapForGate,
245
245
  };
246
246
 
247
+ /**
248
+ * Composite gates fan out to per-kind projectors. The baseline pipeline was
249
+ * unified behind a single `check-baselines` gate (per-kind pipeline: schema →
250
+ * floor → tolerance), but the attribution layer still keys on the original
251
+ * per-kind gate names. Without this map a `check-baselines` failure projects
252
+ * zero regressions, the gate-failure handler sees an empty list, and the
253
+ * auto-refresh path silently no-ops — so a legitimate MI/CRAP regression
254
+ * hard-fails the close instead of self-healing (framework-gap #4377).
255
+ */
256
+ export const COMPOSITE_SUBGATES = {
257
+ 'check-baselines': ['check-maintainability', 'check-crap'],
258
+ };
259
+
260
+ /** `check-maintainability` → `maintainability`. */
261
+ function gateKind(gateName) {
262
+ return gateName.replace(/^check-/, '');
263
+ }
264
+
247
265
  export function projectRegressionsForGate({
248
266
  gateName,
249
267
  cwd,
@@ -253,14 +271,27 @@ export function projectRegressionsForGate({
253
271
  projectMaintainability = defaultProjectMaintainabilityRegressions,
254
272
  getBaselines = defaultGetBaselines,
255
273
  }) {
256
- const project = PROJECTORS[gateName];
257
- if (!project) return [];
258
- return project({
274
+ const ctx = {
259
275
  cwd,
260
276
  epicBranch,
261
277
  storyBranch,
262
278
  config,
263
279
  projectMaintainability,
264
280
  getBaselines,
265
- });
281
+ };
282
+ const subGates = COMPOSITE_SUBGATES[gateName];
283
+ if (subGates) {
284
+ // Union the per-kind regressions, tagging each row with its baseline
285
+ // kind so the gate-failure handler can refresh the right baseline (one
286
+ // kind per attribution cycle; the retry loop converges the rest).
287
+ return subGates.flatMap((sub) => {
288
+ const project = PROJECTORS[sub];
289
+ if (!project) return [];
290
+ const kind = gateKind(sub);
291
+ return project(ctx).map((row) => ({ _gateKind: kind, ...row }));
292
+ });
293
+ }
294
+ const project = PROJECTORS[gateName];
295
+ if (!project) return [];
296
+ return project(ctx);
266
297
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * single-story-sweep/protection-ctx.js
3
+ *
4
+ * Shared builder for the `evaluateProtection` context the boot-sweep
5
+ * engine ([`sweepMergedBranches`](../single-story-sweep.js)) threads into
6
+ * every candidate protection check. Single-homed here so the three boot
7
+ * callers — `single-story-init.js`, `epic-deliver-prepare.js`, and the
8
+ * `boot-sweep.js` CLI — build an identical ctx instead of each re-wiring
9
+ * the git/gh/ticket ports.
10
+ *
11
+ * Story #2990: the sweep protection-ctx `ghRunner` stays on raw
12
+ * `spawnSync('gh', …)` (not the `lib/gh-exec.js` async facade) because
13
+ * `executeCleanup` invokes the protection checks inside a synchronous
14
+ * candidate-filter loop. The runner contract is the legacy
15
+ * `(args, opts) => stdout string` shape.
16
+ */
17
+
18
+ import { spawnSync as defaultSpawnSync } from 'node:child_process';
19
+ import { gitSpawn } from '../git-utils.js';
20
+
21
+ /**
22
+ * Build the synchronous `gh` runner the sweep uses for its
23
+ * candidate-protection checks.
24
+ *
25
+ * Story #4073: the `spawnImpl` seam injects the `spawnSync` boundary so
26
+ * the runner's success/error handling can be unit-tested without a live
27
+ * `gh` binary. It defaults to `child_process.spawnSync`, so the
28
+ * production CLI path is unchanged.
29
+ *
30
+ * @param {string} cwd Repo root used as the default spawn cwd.
31
+ * @param {typeof defaultSpawnSync} [spawnImpl] Injectable spawn boundary —
32
+ * defaults to `child_process.spawnSync`.
33
+ * @returns {(args: string[], opts?: { cwd?: string }) => string}
34
+ */
35
+ export function makeGhRunner(cwd, spawnImpl = defaultSpawnSync) {
36
+ return (args, opts) => {
37
+ const result = spawnImpl('gh', args, {
38
+ cwd: opts?.cwd ?? cwd,
39
+ encoding: 'utf-8',
40
+ shell: false,
41
+ });
42
+ if (result.status !== 0) {
43
+ throw new Error(
44
+ `gh ${args.join(' ')} exit ${result.status}: ${result.stderr ?? ''}`,
45
+ );
46
+ }
47
+ return result.stdout ?? '';
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Build the `evaluateProtection` ctx bag: the repo root, the `gitSpawn`
53
+ * port, the synchronous `gh` runner, and a `getTicket` port bound to the
54
+ * supplied provider.
55
+ *
56
+ * @param {{
57
+ * cwd: string,
58
+ * provider: { getTicket: (id: number) => Promise<object> },
59
+ * spawnImpl?: typeof defaultSpawnSync,
60
+ * }} args
61
+ * @returns {{
62
+ * repoRoot: string,
63
+ * gitSpawn: typeof gitSpawn,
64
+ * ghRunner: (args: string[], opts?: { cwd?: string }) => string,
65
+ * getTicket: (id: number) => Promise<object>,
66
+ * }}
67
+ */
68
+ export function buildProtectionCtx({ cwd, provider, spawnImpl }) {
69
+ return {
70
+ repoRoot: cwd,
71
+ gitSpawn,
72
+ ghRunner: makeGhRunner(cwd, spawnImpl),
73
+ getTicket: (id) => provider.getTicket(id),
74
+ };
75
+ }