mandrel 2.62.0 → 2.64.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/ci-red-handling.js +73 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +56 -11
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +108 -9
- package/.agents/scripts/merge-baseline.js +13 -11
- package/.agents/scripts/pr-watch-with-update.js +13 -19
- 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
|
@@ -13,7 +13,11 @@ import {
|
|
|
13
13
|
} from '../config/temp-paths.js';
|
|
14
14
|
import { gh as defaultGh } from '../gh-exec.js';
|
|
15
15
|
import { gitSpawn as defaultGitSpawn, getStoryBranch } from '../git-utils.js';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
CHECKS_FAILED_CLASS,
|
|
18
|
+
deriveChecksStatus,
|
|
19
|
+
isPrMerged,
|
|
20
|
+
} from './merge-poll.js';
|
|
17
21
|
import { NEXT_COMMANDS } from './story-deliver-terminal.js';
|
|
18
22
|
import { STATE_LABELS } from './ticketing.js';
|
|
19
23
|
|
|
@@ -302,6 +306,56 @@ function decideExecuting({ storyId, branch, pr, closeArtifacts, evidence }) {
|
|
|
302
306
|
};
|
|
303
307
|
}
|
|
304
308
|
|
|
309
|
+
/**
|
|
310
|
+
* A close that blocked on a red required check disarmed auto-merge and wrote
|
|
311
|
+
* the CI digest, so the next step is the ci-remediation loop — never this
|
|
312
|
+
* probe again. `null` for every other blocked class (or with no PR to watch).
|
|
313
|
+
*
|
|
314
|
+
* @returns {{ shape: string, nextCommand: string, detail: string, evidence: string[] } | null}
|
|
315
|
+
*/
|
|
316
|
+
function decideBlockedChecksFailed({ storyId, pr, closeArtifacts, evidence }) {
|
|
317
|
+
const envelope = closeArtifacts?.envelope;
|
|
318
|
+
if (envelope?.blocked?.blockClass !== CHECKS_FAILED_CLASS) return null;
|
|
319
|
+
const prNumber = pr?.number ?? envelope?.pr?.number ?? null;
|
|
320
|
+
if (!prNumber) return null;
|
|
321
|
+
return {
|
|
322
|
+
shape: 'blocked-checks-failed',
|
|
323
|
+
nextCommand: NEXT_COMMANDS.watchCi(storyId, prNumber),
|
|
324
|
+
detail:
|
|
325
|
+
`Story is \`agent::blocked\` because a required check on PR #${prNumber} went red. ` +
|
|
326
|
+
`The close disarmed auto-merge and wrote the CI digest ` +
|
|
327
|
+
`(\`story-${storyId}-ci-digest.json\` under the configured tempRoot). Per ` +
|
|
328
|
+
`\`.agents/rules/ci-remediation.md\`, either fix the failure at source and push a new ` +
|
|
329
|
+
`commit on \`story-${storyId}\`, or — when the root cause is outside this delivery — run ` +
|
|
330
|
+
`\`node .agents/scripts/file-ci-gap.js --story ${storyId} --pr ${prNumber} --verdict <verdict> ` +
|
|
331
|
+
`--owner <consumer|framework|platform> --evidence "<proof reading>"\`. Then run the watcher: ` +
|
|
332
|
+
`a green on a new head SHA, or the one rerun a filed capacity / unreproducible-tier verdict ` +
|
|
333
|
+
`admits, re-arms auto-merge.`,
|
|
334
|
+
evidence,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* A blocked Story: the checks-failed route when it applies, else the
|
|
340
|
+
* class-specific remediation the friction comment already carries.
|
|
341
|
+
*
|
|
342
|
+
* @returns {{ shape: string, nextCommand: string, detail: string, evidence: string[] }}
|
|
343
|
+
*/
|
|
344
|
+
function decideBlocked({ storyId, pr, closeArtifacts, evidence }) {
|
|
345
|
+
return (
|
|
346
|
+
decideBlockedChecksFailed({ storyId, pr, closeArtifacts, evidence }) ?? {
|
|
347
|
+
shape: 'blocked',
|
|
348
|
+
nextCommand: NEXT_COMMANDS.recover(storyId),
|
|
349
|
+
detail:
|
|
350
|
+
`Story is at \`agent::blocked\`. The block was already classified when it was ` +
|
|
351
|
+
`filed — read the \`friction\` comment on #${storyId} for the class-specific ` +
|
|
352
|
+
`remediation, resolve it, then transition back to \`agent::executing\`. ` +
|
|
353
|
+
`Re-run this probe afterwards to confirm the strand cleared.`,
|
|
354
|
+
evidence,
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
305
359
|
/**
|
|
306
360
|
* The pure decision table: exactly one verdict, never a list.
|
|
307
361
|
*
|
|
@@ -357,16 +411,7 @@ export function decideRecovery({
|
|
|
357
411
|
}
|
|
358
412
|
|
|
359
413
|
if (label === STATE_LABELS.BLOCKED) {
|
|
360
|
-
return {
|
|
361
|
-
shape: 'blocked',
|
|
362
|
-
nextCommand: NEXT_COMMANDS.recover(storyId),
|
|
363
|
-
detail:
|
|
364
|
-
`Story is at \`agent::blocked\`. The block was already classified when it was ` +
|
|
365
|
-
`filed — read the \`friction\` comment on #${storyId} for the class-specific ` +
|
|
366
|
-
`remediation, resolve it, then transition back to \`agent::executing\`. ` +
|
|
367
|
-
`Re-run this probe afterwards to confirm the strand cleared.`,
|
|
368
|
-
evidence,
|
|
369
|
-
};
|
|
414
|
+
return decideBlocked({ storyId, pr, closeArtifacts, evidence });
|
|
370
415
|
}
|
|
371
416
|
|
|
372
417
|
if (label === STATE_LABELS.DONE) {
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { pollUntil } from '../../../util/poll-loop.js';
|
|
28
28
|
import { applyBehindUpdate } from '../../behind-recovery.js';
|
|
29
29
|
import { isRerunPermitted } from '../../check-state.js';
|
|
30
|
+
import { recordRequiredRed as defaultRecordRequiredRed } from '../../ci-red-handling.js';
|
|
30
31
|
import {
|
|
31
32
|
emitMergeFlipFailed as defaultEmitMergeFlipFailed,
|
|
32
33
|
MERGED_FLIP_FAILED_BLOCK_CLASS,
|
|
@@ -36,6 +37,7 @@ import { classifyMergeBlock as defaultClassifyMergeBlock } from '../../merge-blo
|
|
|
36
37
|
import {
|
|
37
38
|
ADVISORY_GATE_INCONCLUSIVE_CLASS,
|
|
38
39
|
ADVISORY_GATE_RED_CLASS,
|
|
40
|
+
CHECKS_FAILED_CLASS,
|
|
39
41
|
DEFAULT_INTERVAL_SECONDS,
|
|
40
42
|
DEFAULT_MAX_BUDGET_SECONDS,
|
|
41
43
|
decideAdvisoryGateBlock,
|
|
@@ -297,14 +299,9 @@ function advisoryGateRemedy({ storyId, blockClass }) {
|
|
|
297
299
|
* @param {{ storyId: number, prNumber: number|null, blockClass: string }} args
|
|
298
300
|
* @returns {string}
|
|
299
301
|
*/
|
|
300
|
-
function unlandedRemedy({ storyId, prNumber, blockClass }) {
|
|
301
|
-
if (blockClass ===
|
|
302
|
-
return (
|
|
303
|
-
`A required check is **red**. Fix the failure and push a new commit on \`story-${storyId}\`; ` +
|
|
304
|
-
`the red disarms auto-merge, and only a green on a new head SHA re-arms it — ` +
|
|
305
|
-
`re-running the failed job is forbidden. Watch the checks with:\n\n` +
|
|
306
|
-
`\`\`\`bash\n${NEXT_COMMANDS.watchCi(storyId, prNumber)}\n\`\`\``
|
|
307
|
-
);
|
|
302
|
+
function unlandedRemedy({ storyId, prNumber, blockClass, redRecord }) {
|
|
303
|
+
if (blockClass === CHECKS_FAILED_CLASS) {
|
|
304
|
+
return checksFailedRemedy({ storyId, prNumber, redRecord });
|
|
308
305
|
}
|
|
309
306
|
if (
|
|
310
307
|
blockClass === ADVISORY_GATE_INCONCLUSIVE_CLASS ||
|
|
@@ -319,6 +316,35 @@ function unlandedRemedy({ storyId, prNumber, blockClass }) {
|
|
|
319
316
|
);
|
|
320
317
|
}
|
|
321
318
|
|
|
319
|
+
/**
|
|
320
|
+
* States what the red handling actually did — never claims a disarm or a
|
|
321
|
+
* digest that did not happen — then names both ci-remediation routes.
|
|
322
|
+
*
|
|
323
|
+
* @param {{ storyId: number, prNumber: number|null, redRecord?: object }} args
|
|
324
|
+
* @returns {string}
|
|
325
|
+
*/
|
|
326
|
+
function checksFailedRemedy({ storyId, prNumber, redRecord }) {
|
|
327
|
+
const disarm = redRecord?.disarm;
|
|
328
|
+
const armLine = disarm?.disarmed
|
|
329
|
+
? disarm.alreadyUnarmed
|
|
330
|
+
? 'Auto-merge was already un-armed.'
|
|
331
|
+
: 'Auto-merge was **disarmed**; only a green on a new head SHA, or the one rerun a filed `capacity` / `unreproducible-tier` verdict admits, re-arms it.'
|
|
332
|
+
: `⚠️ Auto-merge could **not** be disarmed (${disarm?.detail ?? 'the red handling did not run'}) — GitHub may still land the PR when the checks read green. Disarm it by hand.`;
|
|
333
|
+
const watch = NEXT_COMMANDS.watchCi(storyId, prNumber);
|
|
334
|
+
const digestLine = redRecord?.digestPaths
|
|
335
|
+
? `CI digest (run link + failure signature): \`${redRecord.digestPaths.jsonPath}\`.`
|
|
336
|
+
: `⚠️ No CI digest was written (${redRecord?.digestError ?? 'no Story scope'}); \`${watch}\` rewrites it.`;
|
|
337
|
+
return (
|
|
338
|
+
`A required check is **red**. ${armLine}\n\n${digestLine}\n\n` +
|
|
339
|
+
`Per \`.agents/rules/ci-remediation.md\`, either fix the failure and push a new commit on ` +
|
|
340
|
+
`\`story-${storyId}\` (re-running the failed job is forbidden), or — when the root cause is ` +
|
|
341
|
+
`outside this delivery — file it:\n\n` +
|
|
342
|
+
`\`\`\`bash\nnode .agents/scripts/file-ci-gap.js --story ${storyId} --pr ${prNumber} ` +
|
|
343
|
+
`--verdict <verdict> --owner <consumer|framework|platform> --evidence "<proof reading>"\n\`\`\`\n\n` +
|
|
344
|
+
`Then watch the checks with:\n\n\`\`\`bash\n${watch}\n\`\`\``
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
322
348
|
function formatUnlandedFriction({
|
|
323
349
|
storyId,
|
|
324
350
|
prNumber,
|
|
@@ -326,12 +352,13 @@ function formatUnlandedFriction({
|
|
|
326
352
|
blockClass,
|
|
327
353
|
reason,
|
|
328
354
|
elapsedSeconds,
|
|
355
|
+
redRecord,
|
|
329
356
|
}) {
|
|
330
357
|
const prLabel =
|
|
331
358
|
Number.isInteger(prNumber) && prNumber > 0
|
|
332
359
|
? `PR #${prNumber}${prUrl ? ` (${prUrl})` : ''}`
|
|
333
360
|
: (prUrl ?? 'the PR');
|
|
334
|
-
const remedy = unlandedRemedy({ storyId, prNumber, blockClass });
|
|
361
|
+
const remedy = unlandedRemedy({ storyId, prNumber, blockClass, redRecord });
|
|
335
362
|
return (
|
|
336
363
|
`### close-and-land: merge did not land\n\n` +
|
|
337
364
|
`Story #${storyId}: the close polled ${prLabel} for merge confirmation and ` +
|
|
@@ -691,6 +718,61 @@ function queuedExhaustionVerdict(probe, cumulativeMs) {
|
|
|
691
718
|
};
|
|
692
719
|
}
|
|
693
720
|
|
|
721
|
+
/**
|
|
722
|
+
* A `checks-failed` fail-fast is a required-check red, so it gets the same
|
|
723
|
+
* first-red handling as the watcher: disarm, then write the CI digest
|
|
724
|
+
* `file-ci-gap.js` reads. Never throws — the block stands regardless.
|
|
725
|
+
*
|
|
726
|
+
* @returns {Promise<object>} the `recordRequiredRed` outcome.
|
|
727
|
+
*/
|
|
728
|
+
async function recordChecksFailedRed({
|
|
729
|
+
storyId,
|
|
730
|
+
prNumber,
|
|
731
|
+
probe,
|
|
732
|
+
cwd,
|
|
733
|
+
config,
|
|
734
|
+
gh,
|
|
735
|
+
progress,
|
|
736
|
+
disarmAutoMergeFn,
|
|
737
|
+
recordRequiredRedFn,
|
|
738
|
+
}) {
|
|
739
|
+
const failures = (probe?.redHeadRuns ?? []).map((run) => ({
|
|
740
|
+
name: run.name ?? 'unknown',
|
|
741
|
+
outcome: String(run.conclusion ?? 'failure').toLowerCase(),
|
|
742
|
+
}));
|
|
743
|
+
try {
|
|
744
|
+
const record = await recordRequiredRedFn({
|
|
745
|
+
storyId,
|
|
746
|
+
prNumber,
|
|
747
|
+
prRef: String(prNumber),
|
|
748
|
+
failures,
|
|
749
|
+
tempRoot: config?.project?.paths?.tempRoot ?? 'temp',
|
|
750
|
+
cwd,
|
|
751
|
+
headSha: probe?.headSha ?? null,
|
|
752
|
+
disarmFn: () => disarmAutoMergeFn({ prNumber, gh, progress }),
|
|
753
|
+
});
|
|
754
|
+
if (record.digestPaths) {
|
|
755
|
+
progress?.(
|
|
756
|
+
'CONFIRM',
|
|
757
|
+
`🧾 CI failure digest → ${record.digestPaths.jsonPath}`,
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
return record;
|
|
761
|
+
} catch (err) {
|
|
762
|
+
const detail = String(err?.message ?? err);
|
|
763
|
+
progress?.(
|
|
764
|
+
'CONFIRM',
|
|
765
|
+
`⚠️ Red-check handling failed (continuing to block): ${detail}`,
|
|
766
|
+
);
|
|
767
|
+
return {
|
|
768
|
+
headSha: probe?.headSha ?? null,
|
|
769
|
+
disarm: { disarmed: false, alreadyUnarmed: false, detail },
|
|
770
|
+
digestPaths: null,
|
|
771
|
+
digestError: detail,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
694
776
|
/** Classify, emit `merge.unlanded`, post friction, block — all best-effort. */
|
|
695
777
|
async function blockOnUnlanded({
|
|
696
778
|
storyId,
|
|
@@ -705,6 +787,7 @@ async function blockOnUnlanded({
|
|
|
705
787
|
emitMergeUnlandedFn,
|
|
706
788
|
blockClassOverride,
|
|
707
789
|
reasonOverride,
|
|
790
|
+
redRecord,
|
|
708
791
|
}) {
|
|
709
792
|
// A verdict decided at detection is emitted as-is, never re-derived (the
|
|
710
793
|
// classifier reads an advisory-gate PR as healthy); the classifier runs
|
|
@@ -757,6 +840,7 @@ async function blockOnUnlanded({
|
|
|
757
840
|
blockClass,
|
|
758
841
|
reason,
|
|
759
842
|
elapsedSeconds,
|
|
843
|
+
redRecord,
|
|
760
844
|
}),
|
|
761
845
|
progress,
|
|
762
846
|
});
|
|
@@ -781,6 +865,7 @@ async function blockOnUnlanded({
|
|
|
781
865
|
reason,
|
|
782
866
|
frictionCommentId,
|
|
783
867
|
elapsedSeconds,
|
|
868
|
+
...(redRecord ? { redRecord } : {}),
|
|
784
869
|
// The envelope's `pr.state` / `pr.checksStatus` come from here.
|
|
785
870
|
prProbe,
|
|
786
871
|
};
|
|
@@ -933,6 +1018,8 @@ async function onMergeObserved({
|
|
|
933
1018
|
* @param {Function} [args.classifyMergeBlockFn]
|
|
934
1019
|
* @param {Function} [args.emitMergeUnlandedFn]
|
|
935
1020
|
* @param {Function} [args.runPostLandTailFn]
|
|
1021
|
+
* @param {Function} [args.disarmAutoMergeFn]
|
|
1022
|
+
* @param {Function} [args.recordRequiredRedFn] The shared first-red handling (disarm + CI digest).
|
|
936
1023
|
* @param {(ms: number) => Promise<void>} [args.sleepFn]
|
|
937
1024
|
* @param {() => number} [args.nowMsFn]
|
|
938
1025
|
* @param {number} [args.ghTimeoutMs] Test seam only, not config.
|
|
@@ -964,6 +1051,7 @@ export async function runConfirmMergePhase({
|
|
|
964
1051
|
emitMergeFlipFailedFn = defaultEmitMergeFlipFailed,
|
|
965
1052
|
runPostLandTailFn = defaultRunPostLandTail,
|
|
966
1053
|
disarmAutoMergeFn = disarmAutoMerge,
|
|
1054
|
+
recordRequiredRedFn = defaultRecordRequiredRed,
|
|
967
1055
|
sleepFn = defaultSleep,
|
|
968
1056
|
nowMsFn = Date.now,
|
|
969
1057
|
ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
|
|
@@ -1127,6 +1215,17 @@ export async function runConfirmMergePhase({
|
|
|
1127
1215
|
},
|
|
1128
1216
|
blockClassOverride: decision.blockClass,
|
|
1129
1217
|
reasonOverride: decision.reason,
|
|
1218
|
+
redRecord: await recordChecksFailedRed({
|
|
1219
|
+
storyId,
|
|
1220
|
+
prNumber,
|
|
1221
|
+
probe,
|
|
1222
|
+
cwd,
|
|
1223
|
+
config,
|
|
1224
|
+
gh: injectedGh,
|
|
1225
|
+
progress,
|
|
1226
|
+
disarmAutoMergeFn,
|
|
1227
|
+
recordRequiredRedFn,
|
|
1228
|
+
}),
|
|
1130
1229
|
};
|
|
1131
1230
|
}
|
|
1132
1231
|
|
|
@@ -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.'],
|
|
@@ -20,6 +20,7 @@ import { runAsCli } from './lib/cli-utils.js';
|
|
|
20
20
|
import { resolveConfig } from './lib/config-resolver.js';
|
|
21
21
|
import { gh as defaultGh } from './lib/gh-exec.js';
|
|
22
22
|
import { Logger } from './lib/Logger.js';
|
|
23
|
+
import { recordRequiredRed } from './lib/orchestration/ci-red-handling.js';
|
|
23
24
|
import {
|
|
24
25
|
blockStoryDelivery,
|
|
25
26
|
classifyFailure,
|
|
@@ -229,25 +230,18 @@ async function handleRedWatch({
|
|
|
229
230
|
blockFn,
|
|
230
231
|
logger,
|
|
231
232
|
}) {
|
|
232
|
-
const disarm = await
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
prRef,
|
|
245
|
-
});
|
|
246
|
-
} catch (err) {
|
|
247
|
-
logger.warn?.(
|
|
248
|
-
`[pr-watch] failed to write CI digest (non-fatal): ${err?.message ?? err}`,
|
|
249
|
-
);
|
|
250
|
-
}
|
|
233
|
+
const { headSha, disarm, digestPaths } = await recordRequiredRed({
|
|
234
|
+
storyId,
|
|
235
|
+
prNumber,
|
|
236
|
+
prRef,
|
|
237
|
+
failures,
|
|
238
|
+
tempRoot,
|
|
239
|
+
cwd,
|
|
240
|
+
disarmFn,
|
|
241
|
+
headShaFn,
|
|
242
|
+
writeDigestFn,
|
|
243
|
+
logger,
|
|
244
|
+
});
|
|
251
245
|
let blocked = false;
|
|
252
246
|
if (!disarm.disarmed) {
|
|
253
247
|
logger.error?.(
|
|
@@ -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.64.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.63.0...mandrel-v2.64.0) (2026-09-19)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
* close-and-land: a checks-failed stop disarms auto-merge and writes the CI digest ([#5405](https://github.com/dsj1984/mandrel/issues/5405)) ([#5406](https://github.com/dsj1984/mandrel/issues/5406)) ([5819d52](https://github.com/dsj1984/mandrel/commit/5819d52a834a6973b3e138961d9735e477f6cb48))
|
|
24
|
+
|
|
25
|
+
## [2.63.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.62.0...mandrel-v2.63.0) (2026-09-18)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### ⚠ BREAKING CHANGES
|
|
29
|
+
|
|
30
|
+
* 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.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
* 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))
|
|
35
|
+
|
|
18
36
|
## [2.62.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.61.0...mandrel-v2.62.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