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.
- package/.agents/docs/agentrc-reference.json +3 -1
- package/.agents/docs/configuration.md +2 -0
- package/.agents/docs/quality-gates.md +30 -0
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/agentrc.schema.json +11 -0
- package/.agents/schemas/audit-rules.json +44 -0
- package/.agents/schemas/audit-rules.schema.json +1 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/check-doc-links.js +23 -2
- package/.agents/scripts/git-cleanup.js +2 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +7 -2
- package/.agents/scripts/lib/close-validation/projections/crap.js +8 -6
- package/.agents/scripts/lib/config/ci.js +18 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +13 -0
- package/.agents/scripts/lib/coverage-capture-fullscope.js +5 -2
- package/.agents/scripts/lib/coverage-capture.js +96 -26
- package/.agents/scripts/lib/findings/route-finding.js +98 -35
- package/.agents/scripts/lib/maintainability-utils.js +6 -14
- package/.agents/scripts/lib/observability/source-classifier.js +0 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +22 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -14
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/merged-tip.js +132 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +56 -11
- package/.agents/scripts/lib/orchestration/merge-block-class.js +10 -1
- package/.agents/scripts/lib/orchestration/merge-poll.js +164 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +145 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -5
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +9 -1
- package/.agents/scripts/lib/source-extensions.js +76 -0
- package/.agents/scripts/notify.js +4 -10
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +1 -1
- package/.agents/workflows/audit-adrs.md +270 -0
- package/.agents/workflows/audit-documentation.md +22 -6
- package/docs/CHANGELOG.md +33 -0
- package/package.json +3 -3
- 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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* semantic
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
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.
|
|
483
|
-
* `searchCandidates` is not
|
|
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).
|
|
487
|
-
*
|
|
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
|
-
//
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
//
|
|
521
|
-
// location-based semantic-key footer) over the
|
|
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
|
|
72
|
-
*
|
|
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() &&
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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'`.
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* `
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
|
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
|
}
|