mandrel 2.61.0 → 2.63.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/configuration.md +0 -1
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +1 -1
- package/.agents/schemas/baselines/baseline-envelope.schema.json +2 -19
- package/.agents/schemas/baselines/bundle-size.schema.json +0 -24
- package/.agents/schemas/baselines/coverage.schema.json +0 -26
- package/.agents/schemas/baselines/crap.schema.json +0 -28
- package/.agents/schemas/baselines/duplication.schema.json +0 -38
- package/.agents/schemas/baselines/maintainability.schema.json +0 -45
- package/.agents/schemas/baselines/mutation.schema.json +0 -28
- package/.agents/scripts/check-dead-exports.js +1 -1
- package/.agents/scripts/lib/audit-baselines/headroom.js +1 -1
- package/.agents/scripts/lib/audit-baselines/kinds.js +12 -7
- package/.agents/scripts/lib/audit-baselines/rollup.js +41 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +14 -3
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +1 -1
- package/.agents/scripts/lib/baselines/envelope.js +5 -50
- package/.agents/scripts/lib/baselines/merge-envelopes.js +16 -79
- package/.agents/scripts/lib/baselines/reader.js +7 -7
- package/.agents/scripts/lib/baselines/refresh-service.js +2 -7
- package/.agents/scripts/lib/baselines/writer.js +16 -45
- package/.agents/scripts/lib/coverage-baseline.js +0 -2
- package/.agents/scripts/lib/cyclomatic-ceiling.js +6 -18
- package/.agents/scripts/lib/git/sync-from-base.js +1 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +20 -27
- package/.agents/scripts/lib/orchestration/merge-queue.js +158 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +115 -49
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +49 -1
- package/.agents/scripts/merge-baseline.js +13 -11
- package/.agents/workflows/audit-baselines.md +2 -1
- package/docs/CHANGELOG.md +18 -0
- package/lib/cli/registry.js +1 -1
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.63.0-baseline-merge-queue-shape.js +96 -0
- package/package.json +1 -1
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
* Non-fatal: a failure returns `{ enabled: false, reason }`. The arm runs from
|
|
4
4
|
* the primary worktree so `--delete-branch`'s local `git checkout <base>`
|
|
5
5
|
* cannot collide with a worktree holding the base.
|
|
6
|
+
*
|
|
7
|
+
* A merge-queue base is armed with a bare `--auto` instead: `gh` refuses
|
|
8
|
+
* `--delete-branch` under a queue, the queue rule picks the strategy, and the
|
|
9
|
+
* repo's `delete_branch_on_merge` owns the remote branch reap.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
import { gh as defaultGh, describeGhFailure } from '../../../gh-exec.js';
|
|
@@ -11,6 +15,12 @@ import {
|
|
|
11
15
|
decideAdvisoryGateBlock,
|
|
12
16
|
deriveRedHeadRuns,
|
|
13
17
|
} from '../../merge-poll.js';
|
|
18
|
+
import {
|
|
19
|
+
armForMergeQueue,
|
|
20
|
+
disarmByDequeue,
|
|
21
|
+
MERGE_QUEUE_REFUSAL,
|
|
22
|
+
readMergeQueueState,
|
|
23
|
+
} from '../../merge-queue.js';
|
|
14
24
|
|
|
15
25
|
/**
|
|
16
26
|
* The operator deliberately owns the merge, so close does not wait for it;
|
|
@@ -104,8 +114,9 @@ async function directMergeFallback({ exec, prNumber, armCwd, autoReason }) {
|
|
|
104
114
|
* gh?: ReturnType<typeof import('../../../gh-exec.js').createGh>,
|
|
105
115
|
* runner?: (args: string[], opts: object) => ({ status: number, stdout?: string, stderr?: string } | Promise<{ status: number, stdout?: string, stderr?: string }>),
|
|
106
116
|
* resolveArmCwd?: (cwd: string) => string,
|
|
107
|
-
*
|
|
108
|
-
*
|
|
117
|
+
* queueRequired?: boolean,
|
|
118
|
+
* }} opts `queueRequired`: the base requires a merge queue.
|
|
119
|
+
* @returns {Promise<{ enabled: boolean, reason?: string, localCleanupDeferred?: boolean, directMerged?: boolean, mergeQueue?: boolean }>}
|
|
109
120
|
*/
|
|
110
121
|
export async function enableAutoMergeWith({
|
|
111
122
|
cwd,
|
|
@@ -113,9 +124,19 @@ export async function enableAutoMergeWith({
|
|
|
113
124
|
gh,
|
|
114
125
|
runner,
|
|
115
126
|
resolveArmCwd = resolveAutoMergeArmCwd,
|
|
127
|
+
queueRequired = false,
|
|
116
128
|
}) {
|
|
117
129
|
const exec = runner ?? makeDefaultGhAutoMergeRunner(gh ?? defaultGh);
|
|
118
130
|
const armCwd = resolveArmCwd(cwd);
|
|
131
|
+
const arm = queueRequired ? armForMergeQueue : armNativeAutoMerge;
|
|
132
|
+
return arm({ exec, prNumber, armCwd });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `--auto --squash --delete-branch`, falling back to a direct squash-merge
|
|
137
|
+
* only when native auto-merge is unavailable.
|
|
138
|
+
*/
|
|
139
|
+
async function armNativeAutoMerge({ exec, prNumber, armCwd }) {
|
|
119
140
|
try {
|
|
120
141
|
const result = await exec(
|
|
121
142
|
[
|
|
@@ -134,6 +155,10 @@ export async function enableAutoMergeWith({
|
|
|
134
155
|
// The remote side stands; the land tail finishes the local ref reap.
|
|
135
156
|
return { enabled: true, localCleanupDeferred: true, reason: detail };
|
|
136
157
|
}
|
|
158
|
+
if (MERGE_QUEUE_REFUSAL.test(String(result.stderr ?? ''))) {
|
|
159
|
+
// The detection probe missed the queue; the refusal names it.
|
|
160
|
+
return armForMergeQueue({ exec, prNumber, armCwd });
|
|
161
|
+
}
|
|
137
162
|
if (isAutoMergeUnavailable(result.stderr)) {
|
|
138
163
|
return directMergeFallback({
|
|
139
164
|
exec,
|
|
@@ -218,15 +243,32 @@ const NOT_ARMED = /not enabled|isn't enabled|is not set|no auto-?merge/i;
|
|
|
218
243
|
|
|
219
244
|
/**
|
|
220
245
|
* Lives here because `lifecycle-lint` confines `gh pr merge` to this module.
|
|
221
|
-
* Never throws.
|
|
246
|
+
* An enqueued PR is dequeued instead. Never throws.
|
|
222
247
|
*
|
|
223
248
|
* @param {{ prNumber?: number|string, prRef?: string, gh?: object,
|
|
224
|
-
* progress?: (tag: string, msg: string) => void
|
|
225
|
-
*
|
|
249
|
+
* progress?: (tag: string, msg: string) => void,
|
|
250
|
+
* readMergeQueueStateFn?: typeof readMergeQueueState }} args `prRef` wins
|
|
251
|
+
* over `prNumber` when both are given.
|
|
226
252
|
* @returns {Promise<{ disarmed: boolean, alreadyUnarmed: boolean, detail: string }>}
|
|
227
253
|
*/
|
|
228
|
-
export async function disarmAutoMerge({
|
|
254
|
+
export async function disarmAutoMerge({
|
|
255
|
+
prNumber,
|
|
256
|
+
prRef,
|
|
257
|
+
gh,
|
|
258
|
+
progress,
|
|
259
|
+
readMergeQueueStateFn = readMergeQueueState,
|
|
260
|
+
}) {
|
|
229
261
|
const ref = String(prRef ?? prNumber);
|
|
262
|
+
const queue = await readMergeQueueStateFn({ prNumber, prRef, gh });
|
|
263
|
+
const outcome =
|
|
264
|
+
queue?.inQueue === true && queue.prNodeId
|
|
265
|
+
? await disarmByDequeue({ prNodeId: queue.prNodeId, ref, gh, progress })
|
|
266
|
+
: await disableNativeAutoMerge({ ref, gh, progress });
|
|
267
|
+
warnIfStillArmed({ outcome, ref, progress });
|
|
268
|
+
return outcome;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function disableNativeAutoMerge({ ref, gh, progress }) {
|
|
230
272
|
try {
|
|
231
273
|
await (gh ?? defaultGh).pr.merge(ref, ['--disable-auto']);
|
|
232
274
|
progress?.(
|
|
@@ -235,9 +277,7 @@ export async function disarmAutoMerge({ prNumber, prRef, gh, progress }) {
|
|
|
235
277
|
);
|
|
236
278
|
return { disarmed: true, alreadyUnarmed: false, detail: 'disarmed' };
|
|
237
279
|
} catch (err) {
|
|
238
|
-
|
|
239
|
-
warnIfStillArmed({ outcome, ref, progress });
|
|
240
|
-
return outcome;
|
|
280
|
+
return classifyDisarmFailure(describeGhFailure(err));
|
|
241
281
|
}
|
|
242
282
|
}
|
|
243
283
|
|
|
@@ -307,6 +347,62 @@ async function evaluateAdvisoryGate({
|
|
|
307
347
|
};
|
|
308
348
|
}
|
|
309
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Report an arm that stood, naming how it will land.
|
|
352
|
+
*
|
|
353
|
+
* @returns {{ autoMergeEnabled: true, autoMergeReason: null, localCleanupDeferred: boolean, directMerged?: boolean, mergeQueue?: boolean }}
|
|
354
|
+
*/
|
|
355
|
+
function reportArmed({ result, prNumber, progress }) {
|
|
356
|
+
if (result.mergeQueue) {
|
|
357
|
+
progress(
|
|
358
|
+
'PR',
|
|
359
|
+
`✅ Auto-merge enabled on PR #${prNumber} (merge queue — the queue rule picks the strategy).`,
|
|
360
|
+
);
|
|
361
|
+
return {
|
|
362
|
+
autoMergeEnabled: true,
|
|
363
|
+
autoMergeReason: null,
|
|
364
|
+
localCleanupDeferred: false,
|
|
365
|
+
mergeQueue: true,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
if (result.directMerged) {
|
|
369
|
+
progress(
|
|
370
|
+
'PR',
|
|
371
|
+
`✅ Native auto-merge unavailable on PR #${prNumber} — direct squash-merge landed it` +
|
|
372
|
+
(result.localCleanupDeferred
|
|
373
|
+
? " (gh's LOCAL branch cleanup deferred to the land tail; the merge stands)."
|
|
374
|
+
: '.'),
|
|
375
|
+
);
|
|
376
|
+
return {
|
|
377
|
+
autoMergeEnabled: true,
|
|
378
|
+
autoMergeReason: null,
|
|
379
|
+
directMerged: true,
|
|
380
|
+
localCleanupDeferred: Boolean(result.localCleanupDeferred),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
if (result.localCleanupDeferred) {
|
|
384
|
+
progress(
|
|
385
|
+
'PR',
|
|
386
|
+
`⚠️ Auto-merge armed on PR #${prNumber}, but gh's LOCAL branch cleanup failed ` +
|
|
387
|
+
`(${result.reason}) — deferring the local ref reap to the land tail; the merge stands.`,
|
|
388
|
+
);
|
|
389
|
+
return {
|
|
390
|
+
autoMergeEnabled: true,
|
|
391
|
+
autoMergeReason: null,
|
|
392
|
+
localCleanupDeferred: true,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
progress(
|
|
396
|
+
'PR',
|
|
397
|
+
`✅ Auto-merge enabled on PR #${prNumber} (squash, delete-branch).`,
|
|
398
|
+
);
|
|
399
|
+
return {
|
|
400
|
+
autoMergeEnabled: true,
|
|
401
|
+
autoMergeReason: null,
|
|
402
|
+
localCleanupDeferred: false,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
310
406
|
/**
|
|
311
407
|
* Dispatch auto-merge enablement.
|
|
312
408
|
*
|
|
@@ -319,7 +415,7 @@ async function evaluateAdvisoryGate({
|
|
|
319
415
|
* gh?: ReturnType<typeof import('../../../gh-exec.js').createGh>,
|
|
320
416
|
* progress: (tag: string, msg: string) => void,
|
|
321
417
|
* }} args
|
|
322
|
-
* @returns {Promise<{ autoMergeEnabled: boolean, autoMergeReason: string|null, localCleanupDeferred?: boolean, directMerged?: boolean }>}
|
|
418
|
+
* @returns {Promise<{ autoMergeEnabled: boolean, autoMergeReason: string|null, localCleanupDeferred?: boolean, directMerged?: boolean, mergeQueue?: boolean }>}
|
|
323
419
|
* `localCleanupDeferred`: the arm stands but the land tail owns the local
|
|
324
420
|
* ref reap. `directMerged`: landed by direct squash-merge instead.
|
|
325
421
|
*/
|
|
@@ -334,6 +430,7 @@ export async function runAutoMergePhase({
|
|
|
334
430
|
gh,
|
|
335
431
|
progress,
|
|
336
432
|
readPrWaitProbeFn = readAdvisoryProbe,
|
|
433
|
+
readMergeQueueStateFn = readMergeQueueState,
|
|
337
434
|
}) {
|
|
338
435
|
if (noAutoMerge) {
|
|
339
436
|
progress('PR', '⏭ Auto-merge disabled (--no-auto-merge).');
|
|
@@ -384,45 +481,14 @@ export async function runAutoMergePhase({
|
|
|
384
481
|
},
|
|
385
482
|
};
|
|
386
483
|
}
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
: '.'),
|
|
396
|
-
);
|
|
397
|
-
return {
|
|
398
|
-
autoMergeEnabled: true,
|
|
399
|
-
autoMergeReason: null,
|
|
400
|
-
directMerged: true,
|
|
401
|
-
localCleanupDeferred: Boolean(result.localCleanupDeferred),
|
|
402
|
-
};
|
|
403
|
-
}
|
|
404
|
-
if (result.localCleanupDeferred) {
|
|
405
|
-
progress(
|
|
406
|
-
'PR',
|
|
407
|
-
`⚠️ Auto-merge armed on PR #${prNumber}, but gh's LOCAL branch cleanup failed ` +
|
|
408
|
-
`(${result.reason}) — deferring the local ref reap to the land tail; the merge stands.`,
|
|
409
|
-
);
|
|
410
|
-
return {
|
|
411
|
-
autoMergeEnabled: true,
|
|
412
|
-
autoMergeReason: null,
|
|
413
|
-
localCleanupDeferred: true,
|
|
414
|
-
};
|
|
415
|
-
}
|
|
416
|
-
progress(
|
|
417
|
-
'PR',
|
|
418
|
-
`✅ Auto-merge enabled on PR #${prNumber} (squash, delete-branch).`,
|
|
419
|
-
);
|
|
420
|
-
return {
|
|
421
|
-
autoMergeEnabled: true,
|
|
422
|
-
autoMergeReason: null,
|
|
423
|
-
localCleanupDeferred: false,
|
|
424
|
-
};
|
|
425
|
-
}
|
|
484
|
+
const queue = await readMergeQueueStateFn({ prNumber, gh });
|
|
485
|
+
const result = await enableAutoMergeWith({
|
|
486
|
+
cwd,
|
|
487
|
+
prNumber,
|
|
488
|
+
gh,
|
|
489
|
+
queueRequired: queue?.queueRequired === true,
|
|
490
|
+
});
|
|
491
|
+
if (result.enabled) return reportArmed({ result, prNumber, progress });
|
|
426
492
|
progress(
|
|
427
493
|
'PR',
|
|
428
494
|
`⚠️ Auto-merge enablement failed (${result.reason}) — operator can merge manually.`,
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
readRunSummary,
|
|
50
50
|
resolveAdvisoryGateVerdict,
|
|
51
51
|
} from '../../merge-poll.js';
|
|
52
|
+
import { readMergeQueueState } from '../../merge-queue.js';
|
|
52
53
|
import { NEXT_COMMANDS } from '../../story-deliver-terminal.js';
|
|
53
54
|
import {
|
|
54
55
|
postStructuredComment,
|
|
@@ -117,6 +118,30 @@ function readString(value, absent = undefined) {
|
|
|
117
118
|
return typeof value === 'string' && value ? value : absent;
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
/**
|
|
122
|
+
* `inMergeQueue` for a green open PR, the only state GitHub enqueues from;
|
|
123
|
+
* `undefined` otherwise, so the common poll pays no extra call. An enqueued
|
|
124
|
+
* PR has no auto-merge request and can read BLOCKED — neither is a disarm.
|
|
125
|
+
*/
|
|
126
|
+
async function readInMergeQueue({
|
|
127
|
+
view,
|
|
128
|
+
checksStatus,
|
|
129
|
+
gh,
|
|
130
|
+
ghTimeoutMs,
|
|
131
|
+
readMergeQueueStateFn,
|
|
132
|
+
}) {
|
|
133
|
+
const prNodeId = readString(view?.id);
|
|
134
|
+
if (view?.state !== 'OPEN' || checksStatus !== 'success' || !prNodeId) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
const queue = await readMergeQueueStateFn({
|
|
138
|
+
prNodeId,
|
|
139
|
+
gh,
|
|
140
|
+
timeoutMs: ghTimeoutMs,
|
|
141
|
+
});
|
|
142
|
+
return typeof queue?.inQueue === 'boolean' ? queue.inQueue : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
120
145
|
/**
|
|
121
146
|
* One probe per poll iteration. A failed or timed-out read degrades to
|
|
122
147
|
* `{ checksStatus: 'pending', error }` so a flaky read is never a verdict.
|
|
@@ -127,10 +152,12 @@ export async function readPrWaitProbe({
|
|
|
127
152
|
prNumber,
|
|
128
153
|
gh = defaultGh,
|
|
129
154
|
ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
|
|
155
|
+
readMergeQueueStateFn = readMergeQueueState,
|
|
130
156
|
}) {
|
|
131
157
|
try {
|
|
132
158
|
const view = await withGhTimeout(
|
|
133
159
|
gh.pr.view(prNumber, [
|
|
160
|
+
'id',
|
|
134
161
|
'state',
|
|
135
162
|
'mergedAt',
|
|
136
163
|
'createdAt',
|
|
@@ -142,13 +169,21 @@ export async function readPrWaitProbe({
|
|
|
142
169
|
ghTimeoutMs,
|
|
143
170
|
`gh pr view ${prNumber}`,
|
|
144
171
|
);
|
|
172
|
+
const checksStatus = deriveChecksStatus(view?.statusCheckRollup);
|
|
145
173
|
return {
|
|
146
174
|
state: readString(view?.state, null),
|
|
147
175
|
mergedAt: readString(view?.mergedAt, null),
|
|
148
176
|
createdAt: readString(view?.createdAt, null),
|
|
149
177
|
mergeStateStatus: readString(view?.mergeStateStatus),
|
|
150
178
|
reviewDecision: readString(view?.reviewDecision),
|
|
151
|
-
checksStatus
|
|
179
|
+
checksStatus,
|
|
180
|
+
inMergeQueue: await readInMergeQueue({
|
|
181
|
+
view,
|
|
182
|
+
checksStatus,
|
|
183
|
+
gh,
|
|
184
|
+
ghTimeoutMs,
|
|
185
|
+
readMergeQueueStateFn,
|
|
186
|
+
}),
|
|
152
187
|
// Head-anchored, so superseded/pending runs don't read as red; `null`
|
|
153
188
|
// when the rollup is empty (the consecutive-probe fallback owns that).
|
|
154
189
|
requiredRunEvidence: deriveRequiredRunEvidence(view?.statusCheckRollup),
|
|
@@ -644,6 +679,18 @@ async function resolveAdvisoryUnlanded({
|
|
|
644
679
|
};
|
|
645
680
|
}
|
|
646
681
|
|
|
682
|
+
/**
|
|
683
|
+
* An enqueued PR reads BLOCKED with every check green, which the classifier
|
|
684
|
+
* would call human-required — but the queue lands it unattended.
|
|
685
|
+
*/
|
|
686
|
+
function queuedExhaustionVerdict(probe, cumulativeMs) {
|
|
687
|
+
if (probe?.inMergeQueue !== true) return {};
|
|
688
|
+
return {
|
|
689
|
+
blockClassOverride: 'checks-pending-timeout',
|
|
690
|
+
reasonOverride: `PR is still in the merge queue after ${Math.round(cumulativeMs / 1000)} seconds — GitHub merges it when the queue's checks pass; a queued base may need a larger delivery.mergeWatch.maxBudgetSeconds`,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
647
694
|
/** Classify, emit `merge.unlanded`, post friction, block — all best-effort. */
|
|
648
695
|
async function blockOnUnlanded({
|
|
649
696
|
storyId,
|
|
@@ -1126,6 +1173,7 @@ export async function runConfirmMergePhase({
|
|
|
1126
1173
|
exhausted: true,
|
|
1127
1174
|
elapsedSeconds: Math.round(cumulativeMs / 1000),
|
|
1128
1175
|
},
|
|
1176
|
+
...queuedExhaustionVerdict(probe, cumulativeMs),
|
|
1129
1177
|
};
|
|
1130
1178
|
}
|
|
1131
1179
|
}
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* merge-baseline.js — git merge driver for `baselines/*.json`.
|
|
5
5
|
*
|
|
6
|
-
* A text merge of a baseline
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* A text merge of a baseline conflicts on rows adjacent in sort order or
|
|
7
|
+
* appended at the tail, and can splice both sides' rows into a set no scorer
|
|
8
|
+
* produced. This merges rows by identity instead. The committed shape carries
|
|
9
|
+
* no stamp or rollup, so disjoint non-adjacent refreshes also merge textually
|
|
10
|
+
* where this driver never runs (GitHub). Files that are not a known row
|
|
11
|
+
* baseline go to `git merge-file` unchanged.
|
|
10
12
|
*
|
|
11
13
|
* node .agents/scripts/merge-baseline.js %O %A %B %P
|
|
12
14
|
*
|
|
@@ -115,7 +117,7 @@ function delegateToGit(basePath, oursPath, theirsPath) {
|
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
/**
|
|
118
|
-
* Envelope kinds
|
|
120
|
+
* Envelope kinds or plain row baselines (cyclomatic,
|
|
119
121
|
* dead-exports*); anything else resolves `null` and goes back to git.
|
|
120
122
|
*
|
|
121
123
|
* @param {unknown} ours
|
|
@@ -269,8 +271,8 @@ function markConflicts({ kind, merged, oursPath, label }) {
|
|
|
269
271
|
}
|
|
270
272
|
|
|
271
273
|
/**
|
|
272
|
-
* Names the regenerate command:
|
|
273
|
-
*
|
|
274
|
+
* Names the regenerate command: hand-resolved rows describe a tree nobody
|
|
275
|
+
* scored.
|
|
274
276
|
*
|
|
275
277
|
* @param {{ kind: string, label: string, rowConflicts: Array<object>, envelopeConflicts: Array<object> }} args
|
|
276
278
|
*/
|
|
@@ -286,9 +288,9 @@ function reportConflicts({ kind, label, rowConflicts, envelopeConflicts }) {
|
|
|
286
288
|
);
|
|
287
289
|
}
|
|
288
290
|
process.stderr.write(
|
|
289
|
-
`merge-baseline: ${label} is conflicted —
|
|
290
|
-
`
|
|
291
|
-
|
|
291
|
+
`merge-baseline: ${label} is conflicted — hand-resolved rows describe a ` +
|
|
292
|
+
`tree nobody scored. After resolving the markers, regenerate it: ` +
|
|
293
|
+
`${baselineRegenerateRemedy(kind)}\n`,
|
|
292
294
|
);
|
|
293
295
|
}
|
|
294
296
|
|
|
@@ -302,7 +304,7 @@ runAsCli(import.meta.url, main, {
|
|
|
302
304
|
usage: {
|
|
303
305
|
invocation: 'node .agents/scripts/merge-baseline.js %O %A %B %P',
|
|
304
306
|
summary:
|
|
305
|
-
'Git merge driver for baselines/*.json. Merges per-kind envelopes by ROW IDENTITY — disjoint refreshes merge clean
|
|
307
|
+
'Git merge driver for baselines/*.json. Merges per-kind envelopes by ROW IDENTITY — disjoint refreshes merge clean even where their rows are adjacent, and a row both sides changed differently conflicts. A baselines file that is not a known per-kind envelope is handed back to git merge-file unchanged. Exit 0 clean, 1 conflicted.',
|
|
306
308
|
flags: [
|
|
307
309
|
['%O', 'Merge ancestor (git supplies this).'],
|
|
308
310
|
['%A', 'Our version — the driver writes its result here.'],
|
|
@@ -114,7 +114,8 @@ Two envelope-level reads come **before** any finding:
|
|
|
114
114
|
gate **High** (delivery reads that file every run), a dead glob **Low**.
|
|
115
115
|
|
|
116
116
|
2. **Staleness.** Two clocks. `staleDays` is whole days since the baseline's
|
|
117
|
-
own
|
|
117
|
+
own stamp where it carries one, else its last-commit date (row-set
|
|
118
|
+
baselines carry no stamp); `staleCommits` is commits touching the measured surface
|
|
118
119
|
since the baseline was last committed, with `surfaceStale` its boolean. A
|
|
119
120
|
`null` on either is never a fabricated zero — the stamp is unreadable, git
|
|
120
121
|
cannot answer, or the rows are not file paths — and is itself the finding.
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,24 @@ All notable changes to this project will be documented in this file.
|
|
|
15
15
|
-->
|
|
16
16
|
<!-- markdownlint-disable-file MD004 MD012 MD037 -->
|
|
17
17
|
|
|
18
|
+
## [2.63.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.62.0...mandrel-v2.63.0) (2026-09-18)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### ⚠ BREAKING CHANGES
|
|
22
|
+
|
|
23
|
+
* committed baselines/*.json row-set files no longer carry `generatedAt` or `rollup`, and the per-kind schemas reject both keys. Run `mandrel migrate` (step 2.63.0, run by `mandrel update`) to rewrite existing baselines. `MANDREL_BASELINE_GENERATED_AT` is retired.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
* make committed row-set baselines merge clean without the custom merge driver ([#5400](https://github.com/dsj1984/mandrel/issues/5400)) ([#5401](https://github.com/dsj1984/mandrel/issues/5401)) ([8a2df0a](https://github.com/dsj1984/mandrel/commit/8a2df0aeb6c75c2c08ef70eeb970b88bef716bde))
|
|
28
|
+
|
|
29
|
+
## [2.62.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.61.0...mandrel-v2.62.0) (2026-09-18)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
* **close:** arm auto-merge on a merge-queue-protected base ([#5395](https://github.com/dsj1984/mandrel/issues/5395)) ([#5396](https://github.com/dsj1984/mandrel/issues/5396)) ([6d739ea](https://github.com/dsj1984/mandrel/commit/6d739eadb8ae00bedeb7279b1a765c3e0cc5791e))
|
|
35
|
+
|
|
18
36
|
## [2.61.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.60.0...mandrel-v2.61.0) (2026-09-18)
|
|
19
37
|
|
|
20
38
|
|
package/lib/cli/registry.js
CHANGED
|
@@ -814,7 +814,7 @@ export function runMergeDriver({ cwd, fsImpl = fs, runner = spawn } = {}) {
|
|
|
814
814
|
if (command === '') {
|
|
815
815
|
return {
|
|
816
816
|
ok: false,
|
|
817
|
-
detail: `${BASELINE_MERGE_DRIVER_CONFIG_KEY} is unset — baselines/*.json will fall back to git's text merge, which conflicts on
|
|
817
|
+
detail: `${BASELINE_MERGE_DRIVER_CONFIG_KEY} is unset — baselines/*.json will fall back to git's text merge, which conflicts on adjacent rows and can splice rows neither branch scored`,
|
|
818
818
|
remedy: BASELINE_MERGE_DRIVER_REMEDY,
|
|
819
819
|
};
|
|
820
820
|
}
|
package/lib/migrations/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { retireLintBaselineCommand } from './steps/2.32.0-retire-lint-baseline-c
|
|
|
15
15
|
import { retireDeliveryLimitKnobs } from './steps/2.57.0-retire-delivery-limit-knobs.js';
|
|
16
16
|
import { retirePlanningLimitKnobs } from './steps/2.57.0-retire-planning-limit-knobs.js';
|
|
17
17
|
import { retireAuditResultsAutoFile } from './steps/2.60.0-retire-audit-results-autofile.js';
|
|
18
|
+
import { baselineMergeQueueShape } from './steps/2.63.0-baseline-merge-queue-shape.js';
|
|
18
19
|
import { stripRemovedAgentrcKeys } from './steps/strip-removed-agentrc-keys.js';
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -38,6 +39,7 @@ export const migrations = [
|
|
|
38
39
|
retireDeliveryLimitKnobs,
|
|
39
40
|
retireAuditResultsAutoFile,
|
|
40
41
|
stripRemovedAgentrcKeys,
|
|
42
|
+
baselineMergeQueueShape,
|
|
41
43
|
];
|
|
42
44
|
|
|
43
45
|
export { compareVersions };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// lib/migrations/steps/2.63.0-baseline-merge-queue-shape.js
|
|
2
|
+
/**
|
|
3
|
+
* Strip `generatedAt` and `rollup` from the committed row-set baselines. Both
|
|
4
|
+
* were rewritten on every refresh, so two branches refreshing disjoint rows
|
|
5
|
+
* always conflicted on GitHub, which never runs the custom merge driver.
|
|
6
|
+
* Readers now derive the rollup from `rows`. Files are identified by their
|
|
7
|
+
* `$schema` tail; any other baseline is left byte-identical.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import nodeFs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
const BASELINES_DIR = 'baselines';
|
|
14
|
+
|
|
15
|
+
/** `$schema` tail → the keys the new shape no longer carries. */
|
|
16
|
+
const RETIRED_KEYS_BY_SCHEMA = Object.freeze({
|
|
17
|
+
'coverage.schema.json': ['generatedAt', 'rollup'],
|
|
18
|
+
'crap.schema.json': ['generatedAt', 'rollup'],
|
|
19
|
+
'maintainability.schema.json': ['generatedAt', 'rollup'],
|
|
20
|
+
'duplication.schema.json': ['generatedAt', 'rollup'],
|
|
21
|
+
'mutation.schema.json': ['generatedAt', 'rollup'],
|
|
22
|
+
'bundle-size.schema.json': ['generatedAt', 'rollup'],
|
|
23
|
+
'cyclomatic.schema.json': ['generatedAt', 'rollup'],
|
|
24
|
+
'dead-exports.schema.json': ['generatedAt'],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {unknown} envelope
|
|
29
|
+
* @returns {string[]}
|
|
30
|
+
*/
|
|
31
|
+
function retiredKeysPresent(envelope) {
|
|
32
|
+
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const schema = envelope.$schema;
|
|
36
|
+
if (typeof schema !== 'string') return [];
|
|
37
|
+
const keys = RETIRED_KEYS_BY_SCHEMA[schema.split('/').pop()] ?? [];
|
|
38
|
+
return keys.filter((key) => Object.hasOwn(envelope, key));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {{ projectRoot?: string }} ctx
|
|
43
|
+
* @param {typeof nodeFs} fsImpl
|
|
44
|
+
* @returns {Array<{ file: string, envelope: object, keys: string[] }>}
|
|
45
|
+
*/
|
|
46
|
+
function pendingBaselines(ctx, fsImpl) {
|
|
47
|
+
const dir = path.join(ctx?.projectRoot ?? process.cwd(), BASELINES_DIR);
|
|
48
|
+
let names;
|
|
49
|
+
try {
|
|
50
|
+
names = fsImpl.readdirSync(dir);
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const name of [...names].sort()) {
|
|
56
|
+
if (!String(name).endsWith('.json')) continue;
|
|
57
|
+
const file = path.join(dir, String(name));
|
|
58
|
+
let envelope;
|
|
59
|
+
try {
|
|
60
|
+
envelope = JSON.parse(fsImpl.readFileSync(file, 'utf8'));
|
|
61
|
+
} catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const keys = retiredKeysPresent(envelope);
|
|
65
|
+
if (keys.length > 0) out.push({ file, envelope, keys });
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const baselineMergeQueueShape = {
|
|
71
|
+
version: '2.63.0',
|
|
72
|
+
description:
|
|
73
|
+
'strip generatedAt and rollup from committed row-set baselines so ' +
|
|
74
|
+
'disjoint refreshes merge textually on GitHub, where the custom merge ' +
|
|
75
|
+
'driver never runs (Story #5400)',
|
|
76
|
+
/**
|
|
77
|
+
* @param {{ projectRoot?: string, fs?: typeof nodeFs }} [ctx]
|
|
78
|
+
* @param {typeof nodeFs} [fsImpl]
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
detect(ctx, fsImpl = ctx?.fs ?? nodeFs) {
|
|
82
|
+
return pendingBaselines(ctx, fsImpl).length > 0;
|
|
83
|
+
},
|
|
84
|
+
/**
|
|
85
|
+
* @param {{ projectRoot?: string, fs?: typeof nodeFs }} [ctx]
|
|
86
|
+
* @param {typeof nodeFs} [fsImpl]
|
|
87
|
+
* @returns {void}
|
|
88
|
+
*/
|
|
89
|
+
apply(ctx, fsImpl = ctx?.fs ?? nodeFs) {
|
|
90
|
+
for (const { file, envelope, keys } of pendingBaselines(ctx, fsImpl)) {
|
|
91
|
+
const next = { ...envelope };
|
|
92
|
+
for (const key of keys) delete next[key];
|
|
93
|
+
fsImpl.writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
};
|
package/package.json
CHANGED