mandrel 2.34.0 → 2.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.agents/docs/agentrc-reference.json +3 -1
  2. package/.agents/docs/configuration.md +2 -0
  3. package/.agents/docs/quality-gates.md +30 -0
  4. package/.agents/docs/workflows.md +2 -1
  5. package/.agents/schemas/agentrc.schema.json +11 -0
  6. package/.agents/schemas/audit-rules.json +44 -0
  7. package/.agents/schemas/audit-rules.schema.json +1 -1
  8. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  10. package/.agents/scripts/check-doc-links.js +23 -2
  11. package/.agents/scripts/git-cleanup.js +2 -0
  12. package/.agents/scripts/lib/baselines/kinds/crap.js +7 -2
  13. package/.agents/scripts/lib/close-validation/projections/crap.js +8 -6
  14. package/.agents/scripts/lib/config/ci.js +18 -0
  15. package/.agents/scripts/lib/config-settings-schema-delivery.js +13 -0
  16. package/.agents/scripts/lib/coverage-capture-fullscope.js +5 -2
  17. package/.agents/scripts/lib/coverage-capture.js +96 -26
  18. package/.agents/scripts/lib/findings/route-finding.js +98 -35
  19. package/.agents/scripts/lib/maintainability-utils.js +6 -14
  20. package/.agents/scripts/lib/observability/source-classifier.js +0 -1
  21. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +22 -7
  22. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -14
  23. package/.agents/scripts/lib/orchestration/git-cleanup/phases/merged-tip.js +132 -0
  24. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +56 -11
  25. package/.agents/scripts/lib/orchestration/merge-block-class.js +10 -1
  26. package/.agents/scripts/lib/orchestration/merge-poll.js +164 -0
  27. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +145 -0
  28. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -5
  29. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +9 -1
  30. package/.agents/scripts/lib/source-extensions.js +76 -0
  31. package/.agents/scripts/notify.js +4 -10
  32. package/.agents/skills/core/documentation-and-adrs/SKILL.md +1 -1
  33. package/.agents/workflows/audit-adrs.md +270 -0
  34. package/.agents/workflows/audit-documentation.md +22 -6
  35. package/docs/CHANGELOG.md +33 -0
  36. package/package.json +3 -3
  37. package/.agents/scripts/generate-lifecycle-docs.js +0 -237
@@ -13,14 +13,15 @@
13
13
  * stamped into Issue bodies.
14
14
  * 3. `routeFinding(finding, { searchIssues, searchCandidates })` — classify a
15
15
  * finding against existing Issues into one of `new | update-existing |
16
- * duplicate | regression-of-closed`. Routing is a two-stage pass: a
17
- * meaning-first **semantic candidate** pass runs FIRST (when a
18
- * `searchCandidates` port is injected, e.g. wired to
19
- * `semantic-issue-search.js`), then the exact **fingerprint
20
- * confirmation** pass runs SECOND over that candidate pool. When no
21
- * semantic port is injected the helper falls back to a fingerprint-only
22
- * lookup via the `searchIssues` port. Either way the ports query BOTH
23
- * open and closed issues; a closed fingerprint match yields
16
+ * duplicate | regression-of-closed`. Routing gathers a candidate pool,
17
+ * then confirms identity against it. **Every wired port runs, and their
18
+ * results union** (Story #5079): the exact `searchIssues(sha)` lookup is
19
+ * what reliably retrieves an Issue by its footer sha, while the
20
+ * meaning-first `searchCandidates` pass (wired to
21
+ * `semantic-issue-search.js`) widens that pool to catch a reworded
22
+ * finding whose sha has drifted. The semantic pass **adds** to the
23
+ * fingerprint lookup; it never replaces it. Whichever ports are wired
24
+ * query BOTH open and closed issues; a closed fingerprint match yields
24
25
  * `regression-of-closed`.
25
26
  *
26
27
  * Pure orchestration: no network I/O lives here. The `searchIssues` /
@@ -455,36 +456,97 @@ function confirmCandidates(hits, { sha, semanticKey = '' }) {
455
456
  }
456
457
 
457
458
  /**
458
- * Route a finding against existing Issues with a two-stage pass.
459
- *
460
- * **Stage 1 — semantic candidate search (first).** When a `searchCandidates`
461
- * port is injected, it runs first to surface issues that *describe the same
462
- * problem by meaning* across BOTH open and closed issues (and, when the
463
- * caller wires it, an Epic's sub-issues). This widens the net beyond an exact
464
- * fingerprint so a reworded title or a moved file does not hide a real
465
- * duplicate. When no `searchCandidates` port is supplied the helper skips
466
- * straight to Stage 2 over the `searchIssues` lookup — the legacy
467
- * fingerprint-only behaviour, preserved verbatim.
468
- *
469
- * **Stage 2 — fingerprint confirmation (second).** Whatever candidates Stage 1
470
- * produced are filtered down to those that actually carry the finding's
471
- * fingerprint footer, then resolved:
459
+ * Union candidate pools into one flat pool, keeping first-seen order and
460
+ * dropping an issue number an earlier pool already contributed.
461
+ *
462
+ * The fingerprint pool is passed first, so when both ports return the same
463
+ * Issue it is that pool's record the one retrieved by exact identity — that
464
+ * survives into confirmation. Records without a usable number are left for
465
+ * {@link confirmCandidates} to reject, exactly as a single port's would be.
466
+ *
467
+ * @param {Array<unknown>} pools
468
+ * @returns {Array<object>}
469
+ */
470
+ function unionCandidatePools(pools) {
471
+ const seen = new Set();
472
+ return pools.flat().filter((issue) => {
473
+ const number = issue?.number;
474
+ if (typeof number !== 'number') return true;
475
+ const fresh = !seen.has(number);
476
+ seen.add(number);
477
+ return fresh;
478
+ });
479
+ }
480
+
481
+ /**
482
+ * Gather the candidate pool for a finding from every wired port.
483
+ *
484
+ * A run with a single wired port returns that port's result **verbatim**, so
485
+ * the fingerprint-only wiring (`qa-explore`, and every caller that injects no
486
+ * semantic port) keeps its behaviour exactly — including how a non-array
487
+ * return is handled downstream by {@link confirmCandidates}.
488
+ *
489
+ * Both ports are awaited together; a rejection from either propagates rather
490
+ * than degrading silently to a partial pool.
491
+ *
492
+ * @param {object} finding
493
+ * @param {string} sha — the finding's full fingerprint.
494
+ * @param {{ searchIssues?: Function, searchCandidates?: Function }} ports
495
+ * @returns {Promise<Array<object>|unknown>}
496
+ */
497
+ async function gatherCandidates(finding, sha, ports) {
498
+ const call = (port, arg) => (typeof port === 'function' ? [port(arg)] : []);
499
+ const pools = await Promise.all([
500
+ ...call(ports.searchIssues, sha),
501
+ ...call(ports.searchCandidates, finding),
502
+ ]);
503
+ return pools.length === 1 ? pools[0] : unionCandidatePools(pools);
504
+ }
505
+
506
+ /**
507
+ * Route a finding against existing Issues: gather candidates, then confirm.
508
+ *
509
+ * **Gather — every wired port runs, and their pools union (Story #5079).** The
510
+ * two ports answer different questions and neither subsumes the other:
511
+ *
512
+ * - `searchIssues(sha)` is the **exact** lookup. A fingerprint sha is one
513
+ * high-signal term, so it retrieves the Issue whose footer carries it.
514
+ * - `searchCandidates(finding)` is the **meaning-first** pass. It widens the
515
+ * pool to Issues describing the same problem under a different title, so a
516
+ * reworded finding or a moved file still confirms by semantic key.
517
+ *
518
+ * This was a ternary until Story #5079: an injected semantic port *replaced*
519
+ * the fingerprint lookup instead of widening it. Production always injects
520
+ * one, so `searchIssues` was dead code on the live path and dedup rested
521
+ * entirely on a ~20-token bag-of-words query that does not reliably retrieve
522
+ * the Issue. The audit loop consequently re-filed Stories it had already
523
+ * filed, against the workflow's "Never open a duplicate Issue" constraint.
524
+ * Running both ports and unioning their pools is what closes that loop.
525
+ *
526
+ * A port that rejects **propagates**. A pool gathered from only some of its
527
+ * sources is not a smaller pool, it is an unknown one, so the caller
528
+ * (`classifyGroupsAgainstGitHub`) must record a degraded lookup rather than
529
+ * report a confident `new`.
530
+ *
531
+ * **Confirm.** The pooled candidates are filtered down to those that actually
532
+ * carry the finding's fingerprint footer — or, when `semanticKeyConfirm` is
533
+ * on, its location-based semantic-key footer — then resolved:
472
534
  * - An open match → `update-existing` (or `duplicate` when more than one
473
535
  * open issue carries the fingerprint).
474
536
  * - A closed match (no open match) → `regression-of-closed`.
475
537
  * - No confirmed match → `new`.
476
538
  *
477
- * The decision enum is identical on both paths.
539
+ * The decision enum is identical however the candidates were gathered.
478
540
  *
479
541
  * @param {object} finding
480
542
  * @param {object} ports
481
543
  * @param {(sha: string) => Promise<Array<{ number: number, state: string, body?: string }>>} [ports.searchIssues]
482
- * Fingerprint-keyed lookup over open+closed issues. Required when
483
- * `searchCandidates` is not supplied.
544
+ * Fingerprint-keyed lookup over open+closed issues. Runs whenever it is
545
+ * supplied. Required when `searchCandidates` is not.
484
546
  * @param {(finding: object) => Promise<Array<{ number: number, state: string, title?: string, body?: string }>>} [ports.searchCandidates]
485
547
  * Meaning-first candidate search over open+closed issues (and Epic
486
- * sub-issues). When supplied, runs FIRST; its candidates are then
487
- * fingerprint-confirmed.
548
+ * sub-issues). Runs whenever it is supplied, alongside `searchIssues` rather
549
+ * than instead of it; the union is then confirmed by footer.
488
550
  * @param {object} [options]
489
551
  * @param {boolean} [options.semanticKeyConfirm=false] — also confirm a
490
552
  * candidate by the location-based semantic-key footer, not the fingerprint
@@ -510,15 +572,14 @@ export async function routeFinding(
510
572
  const { full: sha } = fingerprintFinding(finding);
511
573
  const semanticKey = options.semanticKeyConfirm ? semanticKeyFor(finding) : '';
512
574
 
513
- // Stage 1: semantic candidate pass first (when wired); else fingerprint
514
- // lookup. Both yield a candidate pool drawn from open AND closed issues.
515
- const hits =
516
- typeof searchCandidates === 'function'
517
- ? await searchCandidates(finding)
518
- : await searchIssues(sha);
575
+ // Gather: every wired port runs, and their pools union (Story #5079).
576
+ const hits = await gatherCandidates(finding, sha, {
577
+ searchIssues,
578
+ searchCandidates,
579
+ });
519
580
 
520
- // Stage 2: confirm identity by fingerprint footer (and, when opted in, the
521
- // location-based semantic-key footer) over the candidate pool.
581
+ // Confirm identity by fingerprint footer (and, when opted in, the
582
+ // location-based semantic-key footer) over the pooled candidates.
522
583
  const confirmed = confirmCandidates(hits, { sha, semanticKey });
523
584
 
524
585
  return decideFromConfirmed(confirmed, sha);
@@ -526,6 +587,8 @@ export async function routeFinding(
526
587
 
527
588
  export const __testing = {
528
589
  MARKER,
590
+ gatherCandidates,
591
+ unionCandidatePools,
529
592
  SEMANTIC_MARKER,
530
593
  SEP,
531
594
  confirmCandidates,
@@ -6,6 +6,7 @@ import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
6
6
  import { Logger } from './Logger.js';
7
7
  import { scoreFile } from './maintainability-engine.js';
8
8
  import { isScored, reportUnscorable } from './maintainability-unscorable.js';
9
+ import { isScorableSourceFile } from './source-extensions.js';
9
10
 
10
11
  const MAINTAINABILITY_WORKER_URL = new URL(
11
12
  './workers/maintainability-worker.js',
@@ -16,17 +17,6 @@ const MAINTAINABILITY_WORKER_URL = new URL(
16
17
  // POOL_SERIAL_THRESHOLD docstring for the tuning rationale).
17
18
  const SERIAL_THRESHOLD = POOL_SERIAL_THRESHOLD;
18
19
 
19
- const JS_EXTS = new Set(['.js', '.mjs', '.cjs']);
20
- const TS_EXTS = new Set(['.ts', '.tsx', '.mts', '.cts']);
21
- const SUPPORTED_EXTS = new Set([...JS_EXTS, ...TS_EXTS]);
22
-
23
- /**
24
- * @returns {boolean} True when the path's extension is one the engines score.
25
- */
26
- function isSupportedSourceFile(filePath) {
27
- return SUPPORTED_EXTS.has(path.extname(String(filePath)).toLowerCase());
28
- }
29
-
30
20
  const IGNORED_DIRS = new Set([
31
21
  'node_modules',
32
22
  '.git',
@@ -68,8 +58,10 @@ export function isIgnoredByGlobs(filePath, ignoreGlobs = [], cwd) {
68
58
  }
69
59
 
70
60
  /**
71
- * Recursively scans a directory for JS/TS source files. Accepts `.js`,
72
- * `.mjs`, `.cjs`, `.ts`, `.tsx`, `.mts`, and `.cts`. Directories listed
61
+ * Recursively scans a directory for JS/TS source files, selecting them by
62
+ * the shared `SCORABLE_SOURCE_EXTENSIONS` set (`source-extensions.js`) so the
63
+ * walk, the coverage-freshness check and the close-validation CRAP projection
64
+ * cannot drift apart. Directories listed
73
65
  * in `IGNORED_DIRS` (including `coverage` and `.next`, added in 5.29.0
74
66
  * to skip vitest's istanbul HTML scaffolding and Next.js build output)
75
67
  * are skipped.
@@ -101,7 +93,7 @@ export function scanDirectory(dir, fileList = [], opts = {}) {
101
93
  if (!IGNORED_DIRS.has(entry.name)) {
102
94
  scanDirectory(filePath, fileList, opts);
103
95
  }
104
- } else if (entry.isFile() && isSupportedSourceFile(entry.name)) {
96
+ } else if (entry.isFile() && isScorableSourceFile(entry.name)) {
105
97
  if (isIgnoredByGlobs(filePath, ignoreGlobs, matchCwd)) {
106
98
  continue;
107
99
  }
@@ -113,7 +113,6 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
113
113
  'evidence-gate.js',
114
114
  'generate-config-docs.js',
115
115
  'generate-lens-checklists.js',
116
- 'generate-lifecycle-docs.js',
117
116
  'generate-skills-index.js',
118
117
  'generate-workflows-doc.js',
119
118
  'git-cleanup.js',
@@ -37,15 +37,19 @@ import {
37
37
  removeWorktree,
38
38
  worktreesByBranch,
39
39
  } from './git-probes.js';
40
+ import { probeAncestry } from './merged-tip.js';
40
41
  import { parsePrunedRefs } from './prune.js';
41
42
 
42
43
  const TAG = '[git-cleanup]';
43
44
 
45
+ /** Fields a planner verdict forwards onto its `skipped[]` entry. */
46
+ const SKIP_DETAIL_FIELDS = ['prNumber', 'tipSha', 'mergedSha', 'detail'];
47
+
44
48
  function skipEntryFromVerdict(branch, verdict) {
45
49
  const entry = { branch, reason: verdict.reason };
46
- if (verdict.prNumber != null) entry.prNumber = verdict.prNumber;
47
- if (verdict.tipSha) entry.tipSha = verdict.tipSha;
48
- if (verdict.mergedSha) entry.mergedSha = verdict.mergedSha;
50
+ for (const field of SKIP_DETAIL_FIELDS) {
51
+ if (verdict[field] != null) entry[field] = verdict[field];
52
+ }
49
53
  return entry;
50
54
  }
51
55
 
@@ -88,6 +92,7 @@ function evaluateLocalBranch({
88
92
  wtMap,
89
93
  remoteName,
90
94
  branchTipShaFn,
95
+ ancestryFn,
91
96
  contentEquivalentFn,
92
97
  branchLastCommitFn,
93
98
  }) {
@@ -102,6 +107,7 @@ function evaluateLocalBranch({
102
107
  remoteName,
103
108
  localExists: true,
104
109
  branchTipShaFn,
110
+ ancestryFn,
105
111
  });
106
112
  if (verdict.kind === 'skip') {
107
113
  return { skip: skipEntryFromVerdict(branch, verdict) };
@@ -134,6 +140,7 @@ function evaluateLocalBranch({
134
140
  worktreePath: wt?.path ?? null,
135
141
  detectedBy,
136
142
  localExists: true,
143
+ behindMerge: verdict.reason === 'tip-behind-merge',
137
144
  },
138
145
  };
139
146
  }
@@ -147,6 +154,7 @@ function collectRemoteOnlyCandidates({
147
154
  filter,
148
155
  prProbe,
149
156
  branchTipShaFn,
157
+ ancestryFn,
150
158
  skipped,
151
159
  }) {
152
160
  const out = [];
@@ -162,6 +170,7 @@ function collectRemoteOnlyCandidates({
162
170
  remoteName,
163
171
  localExists: false,
164
172
  branchTipShaFn,
173
+ ancestryFn,
165
174
  });
166
175
  if (verdict.kind === 'no-pr') continue;
167
176
  if (verdict.kind === 'skip') {
@@ -176,6 +185,7 @@ function collectRemoteOnlyCandidates({
176
185
  worktreePath: null,
177
186
  detectedBy: 'remote-only',
178
187
  localExists: false,
188
+ behindMerge: verdict.reason === 'tip-behind-merge',
179
189
  });
180
190
  }
181
191
  return out;
@@ -187,10 +197,12 @@ function collectRemoteOnlyCandidates({
187
197
  * The PR probe classifies each candidate by the **latest** PR on the head
188
198
  * ref rather than any historical merge. Branches whose latest PR is OPEN
189
199
  * or CLOSED-not-merged are skipped with `reason: 'latest-pr-open'` /
190
- * `reason: 'latest-pr-closed-not-merged'`. When the latest PR is MERGED
191
- * but the branch tip has diverged from the PR's `headRefOid` (post-merge
192
- * force-push), the branch is skipped with
193
- * `reason: 'tip-diverged-from-merge'`.
200
+ * `reason: 'latest-pr-closed-not-merged'`. A MERGED PR whose `headRefOid`
201
+ * differs from the branch tip is resolved by ancestry in
202
+ * `merged-tip.js` — a tip *behind* the merged head becomes a candidate
203
+ * carrying `behindMerge: true`, a tip *ahead* of it keeps the
204
+ * `tip-diverged-from-merge` force-push skip, and an unresolvable rev
205
+ * skips as `unverifiable`.
194
206
  *
195
207
  * Performance (Story #3333): when the caller does not inject its own
196
208
  * `prProbe`, the planner fires **one** bulk `gh pr list --state all`
@@ -250,6 +262,7 @@ export function planCleanup(ctx) {
250
262
  prIndexFn = probeAllPrs,
251
263
  prFallback = probeLatestPr,
252
264
  branchTipShaFn = branchTipSha,
265
+ ancestryFn = probeAncestry,
253
266
  contentEquivalentFn = probeContentEquivalent,
254
267
  branchLastCommitFn = branchLastCommitAt,
255
268
  refExistsFn = refExists,
@@ -301,6 +314,7 @@ export function planCleanup(ctx) {
301
314
  wtMap,
302
315
  remoteName,
303
316
  branchTipShaFn,
317
+ ancestryFn,
304
318
  contentEquivalentFn,
305
319
  branchLastCommitFn,
306
320
  });
@@ -318,6 +332,7 @@ export function planCleanup(ctx) {
318
332
  filter,
319
333
  prProbe,
320
334
  branchTipShaFn,
335
+ ancestryFn,
321
336
  skipped,
322
337
  }),
323
338
  );
@@ -17,6 +17,7 @@ import { execFileSync } from 'node:child_process';
17
17
 
18
18
  import { gitSpawn } from '../../../git-utils.js';
19
19
  import { parseWorktreePorcelain } from '../../../worktree-manager.js';
20
+ import { resolveMergedTip } from './merged-tip.js';
20
21
 
21
22
  export {
22
23
  canFastForward,
@@ -452,6 +453,12 @@ export const __testing = { validSha, firstLsRemoteSha, firstStdoutLine };
452
453
  * into `skipped[]` and continues.
453
454
  * - `{ kind: 'no-pr' }` — caller continues without skipping.
454
455
  *
456
+ * A MERGED PR whose `headRefOid` differs from the branch tip is handed
457
+ * to {@link resolveMergedTip}, which resolves it by ancestry — see that
458
+ * module for the `tip-behind-merge` / `tip-diverged-from-merge` /
459
+ * `unverifiable` taxonomy and why a bare SHA inequality could not
460
+ * express it.
461
+ *
455
462
  * @param {{
456
463
  * prInfo: { number?: number, state?: string, mergedAt?: string|null, headRefOid?: string|null } | null,
457
464
  * branch: string,
@@ -459,8 +466,10 @@ export const __testing = { validSha, firstLsRemoteSha, firstStdoutLine };
459
466
  * remoteName: string,
460
467
  * localExists: boolean,
461
468
  * branchTipShaFn: (args: { cwd: string, branch: string, remoteName: string, localExists: boolean }) => string | null,
469
+ * ancestryFn?: Function,
470
+ * mergedTipFn?: typeof resolveMergedTip,
462
471
  * }} args
463
- * @returns {{ kind: 'candidate', prInfo: object } | { kind: 'skip', reason: string, prNumber?: number, tipSha?: string|null, mergedSha?: string|null } | { kind: 'no-pr' }}
472
+ * @returns {{ kind: 'candidate', prInfo: object, reason?: string, tipSha?: string, mergedSha?: string } | { kind: 'skip', reason: string, prNumber?: number, tipSha?: string|null, mergedSha?: string|null, detail?: string } | { kind: 'no-pr' }}
464
473
  */
465
474
  export function classifyLatestPr({
466
475
  prInfo,
@@ -469,6 +478,8 @@ export function classifyLatestPr({
469
478
  remoteName,
470
479
  localExists,
471
480
  branchTipShaFn,
481
+ ancestryFn,
482
+ mergedTipFn = resolveMergedTip,
472
483
  }) {
473
484
  if (!prInfo) return { kind: 'no-pr' };
474
485
  const state =
@@ -494,17 +505,14 @@ export function classifyLatestPr({
494
505
  prNumber: prInfo.number ?? null,
495
506
  };
496
507
  }
497
- if (prInfo.headRefOid) {
498
- const tipSha = branchTipShaFn({ cwd, branch, remoteName, localExists });
499
- if (tipSha && tipSha !== prInfo.headRefOid) {
500
- return {
501
- kind: 'skip',
502
- reason: 'tip-diverged-from-merge',
503
- prNumber: prInfo.number ?? null,
504
- tipSha,
505
- mergedSha: prInfo.headRefOid,
506
- };
507
- }
508
- }
509
- return { kind: 'candidate', prInfo };
508
+ const tipVerdict = mergedTipFn({
509
+ prInfo,
510
+ branch,
511
+ cwd,
512
+ remoteName,
513
+ localExists,
514
+ branchTipShaFn,
515
+ ancestryFn,
516
+ });
517
+ return tipVerdict ?? { kind: 'candidate', prInfo };
510
518
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * merged-tip.js — resolve a MERGED PR's head against the branch tip
3
+ * (Story #5086).
4
+ *
5
+ * Owns the ancestry probe and the taxonomy the branches-phase classifier
6
+ * applies when a merged PR's `headRefOid` and the branch tip disagree.
7
+ * Split out of `git-probes.js` so the classifier reads as one call and
8
+ * the taxonomy's own documentation sits next to the code it governs.
9
+ *
10
+ * @module lib/orchestration/git-cleanup/phases/merged-tip
11
+ */
12
+
13
+ import { gitSpawn } from '../../../git-utils.js';
14
+
15
+ /**
16
+ * Tri-state ancestry probe: is `ancestorSha` reachable from
17
+ * `descendantSha`?
18
+ *
19
+ * Mirrors the contract `checkHeadAncestor` in
20
+ * `lib/worktree/lifecycle/merge-reachability.js` proved out for the
21
+ * worktree-reap gate — the two cannot share an implementation because
22
+ * that one takes a `ctx.git.gitSpawn` / `ctx.repoRoot` bag while
23
+ * git-cleanup's probes take a bare `cwd`.
24
+ *
25
+ * `git merge-base --is-ancestor` exits **0** (ancestor), **1** (not an
26
+ * ancestor) or **128** (a rev it cannot resolve). Folding 128 into
27
+ * "not an ancestor" is the bug this probe exists to prevent: a merged
28
+ * head absent from the local object DB would silently read as a
29
+ * divergence and re-emit the wrong post-merge-force-push diagnosis. Both
30
+ * revs are therefore resolved with `git rev-parse -q --verify` first, and
31
+ * any failure fails closed to the `error` arm — so `merge-base` never
32
+ * runs against a rev git cannot resolve.
33
+ *
34
+ * @param {{ cwd: string, ancestorSha: string, descendantSha: string, spawn?: typeof gitSpawn }} args
35
+ * @returns {{ outcome: 'ancestor' } | { outcome: 'not-ancestor' } | { outcome: 'error', reason: string }}
36
+ */
37
+ export function probeAncestry({
38
+ cwd,
39
+ ancestorSha,
40
+ descendantSha,
41
+ spawn = gitSpawn,
42
+ }) {
43
+ for (const rev of [ancestorSha, descendantSha]) {
44
+ const res = spawn(
45
+ cwd,
46
+ 'rev-parse',
47
+ '--quiet',
48
+ '--verify',
49
+ `${rev}^{commit}`,
50
+ );
51
+ if (res.status !== 0) {
52
+ return { outcome: 'error', reason: `unresolvable rev ${rev}` };
53
+ }
54
+ }
55
+ const res = spawn(
56
+ cwd,
57
+ 'merge-base',
58
+ '--is-ancestor',
59
+ ancestorSha,
60
+ descendantSha,
61
+ );
62
+ if (res.status === 0) return { outcome: 'ancestor' };
63
+ if (res.status === 1) return { outcome: 'not-ancestor' };
64
+ return {
65
+ outcome: 'error',
66
+ reason: (res.stderr || res.stdout || 'unknown').trim(),
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Resolve a MERGED PR's `headRefOid` against the branch's current tip.
72
+ *
73
+ * Returns `null` when there is nothing to resolve — the PR row carries no
74
+ * `headRefOid`, the tip cannot be read, or the tip already matches the
75
+ * merged head — leaving the caller's plain-candidate path untouched.
76
+ *
77
+ * Otherwise the tip is classified by **ancestry**, never by the bare SHA
78
+ * inequality this replaced. That inequality could not tell a branch that
79
+ * is *behind* the merged head from one force-pushed *past* it, and
80
+ * reported both as the latter — advising the operator to push a follow-up
81
+ * commit that, for a stale pre-merge snapshot, does not exist. The three
82
+ * arms:
83
+ *
84
+ * - **ancestor** — 0 commits ahead, every commit landed with the PR:
85
+ * a reap candidate tagged `reason: 'tip-behind-merge'`.
86
+ * - **not-ancestor** — equivalently "≥1 commit ahead", which is why one
87
+ * probe settles the whole taxonomy and no `rev-list` count is needed:
88
+ * the unchanged `tip-diverged-from-merge` force-push skip.
89
+ * - **error** — a rev the local object DB cannot resolve:
90
+ * `reason: 'unverifiable'` carrying the probe's `detail`. Never a
91
+ * silent pass, and never a force-push label.
92
+ *
93
+ * @param {object} args
94
+ * @returns {{ kind: 'candidate', prInfo: object, reason: string, tipSha: string, mergedSha: string } | { kind: 'skip', reason: string, prNumber: number|null, tipSha: string, mergedSha: string, detail?: string } | null}
95
+ */
96
+ export function resolveMergedTip({
97
+ prInfo,
98
+ branch,
99
+ cwd,
100
+ remoteName,
101
+ localExists,
102
+ branchTipShaFn,
103
+ ancestryFn = probeAncestry,
104
+ }) {
105
+ const mergedSha = prInfo.headRefOid;
106
+ if (!mergedSha) return null;
107
+ const tipSha = branchTipShaFn({ cwd, branch, remoteName, localExists });
108
+ if (!tipSha || tipSha === mergedSha) return null;
109
+ const ancestry = ancestryFn({
110
+ cwd,
111
+ ancestorSha: tipSha,
112
+ descendantSha: mergedSha,
113
+ });
114
+ if (ancestry.outcome === 'ancestor') {
115
+ return {
116
+ kind: 'candidate',
117
+ prInfo,
118
+ reason: 'tip-behind-merge',
119
+ tipSha,
120
+ mergedSha,
121
+ };
122
+ }
123
+ const errored = ancestry.outcome === 'error';
124
+ return {
125
+ kind: 'skip',
126
+ reason: errored ? 'unverifiable' : 'tip-diverged-from-merge',
127
+ prNumber: prInfo.number ?? null,
128
+ tipSha,
129
+ mergedSha,
130
+ ...(errored ? { detail: ancestry.reason } : {}),
131
+ };
132
+ }
@@ -61,6 +61,41 @@ function contentMergedNote(candidate) {
61
61
  : '';
62
62
  }
63
63
 
64
+ /**
65
+ * Pure: render a single behind-the-merged-head candidate annotation.
66
+ *
67
+ * A branch whose tip is a strict ancestor of its merged PR head is a
68
+ * stale pre-merge snapshot — reapable, because every commit on it landed
69
+ * with the PR, but reapable for a different reason than a branch whose
70
+ * tip *matches* the merged head. It used to be skipped outright as a
71
+ * post-merge force-push; the note keeps the two visibly distinct in the
72
+ * dry-run list and the confirmation prompt so the operator can see why a
73
+ * branch that is not at the merged head is nonetheless offered.
74
+ */
75
+ function behindMergeNote(candidate) {
76
+ return candidate.behindMerge
77
+ ? ' (tip behind the merged head — content already landed)'
78
+ : '';
79
+ }
80
+
81
+ /** Pure: every provenance annotation a candidate row carries, in order. */
82
+ function candidateNotes(candidate) {
83
+ return `${contentMergedNote(candidate)}${behindMergeNote(candidate)}`;
84
+ }
85
+
86
+ /**
87
+ * Pure: one candidate row — its detection provenance, worktree, locality
88
+ * and annotations. Split out of {@link renderDryRun} so that renderer
89
+ * stays a loop over rows rather than growing a fourth inline ternary
90
+ * every time a candidate gains a new dimension.
91
+ */
92
+ function renderCandidateRow(c) {
93
+ const pr = c.prNumber ? `PR #${c.prNumber}` : c.detectedBy;
94
+ const wt = c.hasWorktree ? ` (worktree: ${c.worktreePath})` : '';
95
+ const remoteOnly = c.localExists === false ? ' (remote-only)' : '';
96
+ return ` • ${c.branch} — ${pr}${wt}${remoteOnly}${candidateNotes(c)}`;
97
+ }
98
+
64
99
  /**
65
100
  * Pure: render the branch-phase candidate list as the operator-facing text
66
101
  * block.
@@ -87,14 +122,7 @@ export function renderDryRun(plan, opts = {}) {
87
122
  if (plan.candidates.length === 0) {
88
123
  lines.push(' (no merged branches to clean up)');
89
124
  } else {
90
- for (const c of plan.candidates) {
91
- const pr = c.prNumber ? `PR #${c.prNumber}` : c.detectedBy;
92
- const wt = c.hasWorktree ? ` (worktree: ${c.worktreePath})` : '';
93
- const remoteOnly = c.localExists === false ? ' (remote-only)' : '';
94
- lines.push(
95
- ` • ${c.branch} — ${pr}${wt}${remoteOnly}${contentMergedNote(c)}`,
96
- );
97
- }
125
+ for (const c of plan.candidates) lines.push(renderCandidateRow(c));
98
126
  }
99
127
  const skipped = plan.skipped ?? [];
100
128
  const currentHeadSkip = skipped.find((s) => s.reason === 'current-head');
@@ -141,6 +169,17 @@ export function renderCandidateList({ plan, opts = {}, baseBranch = null }) {
141
169
  return renderDryRun(plan, { baseBranch, execute: !opts.dryRun });
142
170
  }
143
171
 
172
+ /**
173
+ * Pure: the tip / merged short-SHA pair both merged-tip skip lines quote,
174
+ * with a placeholder for either side the planner could not resolve.
175
+ */
176
+ function shortShaPair(skip) {
177
+ return {
178
+ tip: skip.tipSha ? skip.tipSha.slice(0, 7) : '<unknown>',
179
+ merged: skip.mergedSha ? skip.mergedSha.slice(0, 7) : '<unknown>',
180
+ };
181
+ }
182
+
144
183
  /**
145
184
  * Pure: render a single latest-PR-state skip line. Returns null when the
146
185
  * skip reason is not one of the latest-PR family — `renderDryRun` filters
@@ -148,7 +187,7 @@ export function renderCandidateList({ plan, opts = {}, baseBranch = null }) {
148
187
  * `current-head`, `filtered`) stay quiet here. `not-merged` gets its own
149
188
  * renderer ({@link renderNotMergedSkipLine}).
150
189
  *
151
- * @param {{ branch: string, reason: string, prNumber?: number, tipSha?: string, mergedSha?: string }} skip
190
+ * @param {{ branch: string, reason: string, prNumber?: number, tipSha?: string, mergedSha?: string, detail?: string }} skip
152
191
  * @returns {string | null}
153
192
  */
154
193
  export function renderLatestPrSkipLine(skip) {
@@ -161,13 +200,19 @@ export function renderLatestPrSkipLine(skip) {
161
200
  return `${TAG} ⏭️ ${skip.branch} skipped — ${prRef} is still open`;
162
201
  }
163
202
  if (skip.reason === 'tip-diverged-from-merge') {
164
- const tip = skip.tipSha ? skip.tipSha.slice(0, 7) : '<unknown>';
165
- const merged = skip.mergedSha ? skip.mergedSha.slice(0, 7) : '<unknown>';
203
+ const { tip, merged } = shortShaPair(skip);
166
204
  return (
167
205
  `${TAG} ⏭️ ${skip.branch} skipped — tip ${tip} diverges from ${prRef}'s merged ${merged} (post-merge force-push); ` +
168
206
  `resolve by deleting manually (\`git branch -D ${skip.branch}\`) or pushing the follow-up commit`
169
207
  );
170
208
  }
209
+ if (skip.reason === 'unverifiable') {
210
+ const { tip, merged } = shortShaPair(skip);
211
+ return (
212
+ `${TAG} ⏭️ ${skip.branch} skipped — cannot verify tip ${tip} against ${prRef}'s merged ${merged}${skip.detail ? `: ${skip.detail}` : ''}; ` +
213
+ `fetch the missing commit or inspect the branch by hand before deleting it`
214
+ );
215
+ }
171
216
  if (skip.reason === 'latest-pr-unknown-state') {
172
217
  return `${TAG} ⏭️ ${skip.branch} skipped — ${prRef} has an unrecognized state`;
173
218
  }