mandrel 1.62.0 → 1.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/scripts/check-action-pinning.js +260 -0
- package/.agents/scripts/check-arch-cycles.js +38 -14
- package/.agents/scripts/epic-deliver-prepare.js +149 -104
- package/.agents/scripts/lib/baseline-snapshot.js +245 -141
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
- package/.agents/scripts/lib/orchestration/code-review.js +206 -168
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
- package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
- package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
- package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
- package/.agents/scripts/lib/signals/detectors/common.js +107 -0
- package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
- package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
- package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
- package/.agents/scripts/lib/story-body/story-body.js +102 -76
- package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
- package/.agents/scripts/single-story-init.js +16 -3
- package/.agents/workflows/audit-architecture.md +9 -0
- package/README.md +1 -1
- package/docs/CHANGELOG.md +28 -0
- package/package.json +1 -1
|
@@ -452,6 +452,219 @@ export function commitSnapshotsToEpicBranch({
|
|
|
452
452
|
* files: Array<{ kind: 'maintainability'|'crap', path: string, didChange: boolean, reason?: 'no-coverage'|'unchanged'|'updated' }>,
|
|
453
453
|
* }>}
|
|
454
454
|
*/
|
|
455
|
+
/**
|
|
456
|
+
* Build the `files[]` entry for a baseline write, choosing between the
|
|
457
|
+
* structural-equality short-circuit (no write, `reason: 'unchanged'`) and
|
|
458
|
+
* the stamp-and-write path (`reason: 'updated'`). Story #4075 — collapses
|
|
459
|
+
* the duplicated short-circuit branch shared by the MI and CRAP passes.
|
|
460
|
+
*
|
|
461
|
+
* @returns {{ entry: object, wrote: boolean }}
|
|
462
|
+
*/
|
|
463
|
+
function commitBaselineEnvelope({
|
|
464
|
+
kind,
|
|
465
|
+
abs,
|
|
466
|
+
envelope,
|
|
467
|
+
priorEnvelope,
|
|
468
|
+
writeFileFn,
|
|
469
|
+
fsImpl,
|
|
470
|
+
}) {
|
|
471
|
+
if (priorEnvelope && envelope === priorEnvelope) {
|
|
472
|
+
return {
|
|
473
|
+
entry: { kind, path: abs, didChange: false, reason: 'unchanged' },
|
|
474
|
+
wrote: false,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
writeFileFn(abs, envelope, { fsImpl });
|
|
478
|
+
return {
|
|
479
|
+
entry: { kind, path: abs, didChange: true, reason: 'updated' },
|
|
480
|
+
wrote: true,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Regenerate the maintainability baseline from a fresh tree scan. Returns
|
|
486
|
+
* `null` when no maintainability baseline path is configured. The scanned
|
|
487
|
+
* source list is returned so the CRAP pass can reuse it when the two passes
|
|
488
|
+
* target the same dirs (Story #3663). Story #4075 — extracted from
|
|
489
|
+
* `regenerateMainFromTree`.
|
|
490
|
+
*/
|
|
491
|
+
async function regenerateMaintainability({
|
|
492
|
+
cwd,
|
|
493
|
+
baselines,
|
|
494
|
+
quality,
|
|
495
|
+
scanDirectoryFn,
|
|
496
|
+
calculateAllFn,
|
|
497
|
+
writeFn,
|
|
498
|
+
writeFileFn,
|
|
499
|
+
loadPriorFn,
|
|
500
|
+
fsImpl,
|
|
501
|
+
}) {
|
|
502
|
+
const miPath = baselines?.maintainability?.path;
|
|
503
|
+
if (typeof miPath !== 'string' || miPath.length === 0) return null;
|
|
504
|
+
|
|
505
|
+
const miTargetDirs = quality?.maintainability?.targetDirs ?? [];
|
|
506
|
+
const miIgnoreGlobs = quality?.maintainability?.ignoreGlobs ?? [];
|
|
507
|
+
const miAbs = path.isAbsolute(miPath) ? miPath : path.resolve(cwd, miPath);
|
|
508
|
+
const miSourceList = [];
|
|
509
|
+
for (const dir of miTargetDirs) {
|
|
510
|
+
const abs = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir);
|
|
511
|
+
scanDirectoryFn(abs, miSourceList, { cwd, ignoreGlobs: miIgnoreGlobs });
|
|
512
|
+
}
|
|
513
|
+
const scores = await calculateAllFn(miSourceList);
|
|
514
|
+
|
|
515
|
+
// Project the scoring helper's `{path: mi}` map onto the writer's
|
|
516
|
+
// canonical row shape. Story #2079 path-canon defence stays in place —
|
|
517
|
+
// the writer would canonicalise again, but doing it here keeps any
|
|
518
|
+
// pre-canonicalised comparison inside the function meaningful.
|
|
519
|
+
const miRows = filterExcludedRows(
|
|
520
|
+
Object.entries(scores).map(([key, mi]) => {
|
|
521
|
+
const rel = path.isAbsolute(key) ? path.relative(cwd, key) : key;
|
|
522
|
+
const posixRel = rel.split(path.sep).join('/');
|
|
523
|
+
return { path: canonicalisePath(posixRel), mi };
|
|
524
|
+
}),
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
const priorMi = loadPriorFn(miAbs, 'maintainability');
|
|
528
|
+
const envelope = writeFn({
|
|
529
|
+
kind: 'maintainability',
|
|
530
|
+
rows: miRows,
|
|
531
|
+
priorEnvelope: priorMi,
|
|
532
|
+
});
|
|
533
|
+
const { entry, wrote } = commitBaselineEnvelope({
|
|
534
|
+
kind: 'maintainability',
|
|
535
|
+
abs: miAbs,
|
|
536
|
+
envelope,
|
|
537
|
+
priorEnvelope: priorMi,
|
|
538
|
+
writeFileFn,
|
|
539
|
+
fsImpl,
|
|
540
|
+
});
|
|
541
|
+
return { entry, wrote, miSourceList, miTargetDirs, miIgnoreGlobs };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Decide whether the CRAP pass can reuse the MI scan's file list — true only
|
|
546
|
+
* when both passes target the same dirs with the same ignore globs
|
|
547
|
+
* (Story #3663).
|
|
548
|
+
*/
|
|
549
|
+
function crapDirsMatchMi({
|
|
550
|
+
miSourceList,
|
|
551
|
+
crapTargetDirs,
|
|
552
|
+
crapIgnoreGlobs,
|
|
553
|
+
miTargetDirs,
|
|
554
|
+
miIgnoreGlobs,
|
|
555
|
+
}) {
|
|
556
|
+
return (
|
|
557
|
+
miSourceList !== null &&
|
|
558
|
+
crapTargetDirs.length === miTargetDirs.length &&
|
|
559
|
+
crapTargetDirs.every((d, i) => d === miTargetDirs[i]) &&
|
|
560
|
+
crapIgnoreGlobs.length === miIgnoreGlobs.length &&
|
|
561
|
+
crapIgnoreGlobs.every((g, i) => g === miIgnoreGlobs[i])
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Regenerate the CRAP baseline from a fresh tree scan + coverage map.
|
|
567
|
+
* Returns `null` when no CRAP baseline path is configured. Story #4075 —
|
|
568
|
+
* extracted from `regenerateMainFromTree`.
|
|
569
|
+
*/
|
|
570
|
+
async function regenerateCrap({
|
|
571
|
+
cwd,
|
|
572
|
+
baselines,
|
|
573
|
+
quality,
|
|
574
|
+
logger,
|
|
575
|
+
miScan,
|
|
576
|
+
scanAndScoreFn,
|
|
577
|
+
loadCoverageFn,
|
|
578
|
+
resolveEscomplexVersionFn,
|
|
579
|
+
resolveTsTranspilerVersionFn,
|
|
580
|
+
writeFn,
|
|
581
|
+
writeFileFn,
|
|
582
|
+
loadPriorFn,
|
|
583
|
+
fsImpl,
|
|
584
|
+
}) {
|
|
585
|
+
const crapPath = baselines?.crap?.path;
|
|
586
|
+
if (typeof crapPath !== 'string' || crapPath.length === 0) return null;
|
|
587
|
+
|
|
588
|
+
const crapCfg = quality?.crap ?? {};
|
|
589
|
+
const crapTargetDirs = Array.isArray(crapCfg.targetDirs)
|
|
590
|
+
? crapCfg.targetDirs
|
|
591
|
+
: [];
|
|
592
|
+
const crapIgnoreGlobs = Array.isArray(crapCfg.ignoreGlobs)
|
|
593
|
+
? crapCfg.ignoreGlobs
|
|
594
|
+
: [];
|
|
595
|
+
const requireCoverage = crapCfg.requireCoverage !== false;
|
|
596
|
+
const coveragePath = crapCfg.coveragePath ?? 'coverage/coverage-final.json';
|
|
597
|
+
const crapAbs = path.isAbsolute(crapPath)
|
|
598
|
+
? crapPath
|
|
599
|
+
: path.resolve(cwd, crapPath);
|
|
600
|
+
const coverageAbs = path.isAbsolute(coveragePath)
|
|
601
|
+
? coveragePath
|
|
602
|
+
: path.resolve(cwd, coveragePath);
|
|
603
|
+
const coverage = loadCoverageFn(coverageAbs);
|
|
604
|
+
|
|
605
|
+
if (!coverage && requireCoverage) {
|
|
606
|
+
logger.warn?.(
|
|
607
|
+
`[baseline-snapshot] ⚠ no coverage at ${coveragePath} — skipping crap regeneration (refresh stays clean for this file).`,
|
|
608
|
+
);
|
|
609
|
+
return {
|
|
610
|
+
entry: {
|
|
611
|
+
kind: 'crap',
|
|
612
|
+
path: crapAbs,
|
|
613
|
+
didChange: false,
|
|
614
|
+
reason: 'no-coverage',
|
|
615
|
+
},
|
|
616
|
+
wrote: false,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const reusePreScan = crapDirsMatchMi({
|
|
621
|
+
miSourceList: miScan?.miSourceList ?? null,
|
|
622
|
+
crapTargetDirs,
|
|
623
|
+
crapIgnoreGlobs,
|
|
624
|
+
miTargetDirs: miScan?.miTargetDirs ?? [],
|
|
625
|
+
miIgnoreGlobs: miScan?.miIgnoreGlobs ?? [],
|
|
626
|
+
});
|
|
627
|
+
const { rows } = await scanAndScoreFn({
|
|
628
|
+
targetDirs: crapTargetDirs,
|
|
629
|
+
coverage,
|
|
630
|
+
requireCoverage,
|
|
631
|
+
cwd,
|
|
632
|
+
ignoreGlobs: crapIgnoreGlobs,
|
|
633
|
+
...(reusePreScan && { preScannedFiles: miScan.miSourceList }),
|
|
634
|
+
});
|
|
635
|
+
// scanAndScore yields rows keyed by `file:`; the per-kind crap module's
|
|
636
|
+
// `projectRow` handles `path ?? file`, so the writer takes either.
|
|
637
|
+
// Filter to actually-scored rows here (crap is nullable for trivial
|
|
638
|
+
// methods); the writer's `assertEnvelope` would reject otherwise.
|
|
639
|
+
const crapRows = (rows ?? []).filter(
|
|
640
|
+
(r) => typeof r?.crap === 'number' && Number.isFinite(r.crap),
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
// CRAP gates need the running scorer's versions present on the
|
|
644
|
+
// envelope-adjacent shape; the V2 envelope itself only carries
|
|
645
|
+
// `kernelVersion`, so we stamp escomplex/tsTranspiler via the writer's
|
|
646
|
+
// `kernelVersion` override and let the existing per-kind module resolve
|
|
647
|
+
// the rest. We also resolve them eagerly so a test stub can pin them
|
|
648
|
+
// deterministically.
|
|
649
|
+
resolveEscomplexVersionFn(cwd);
|
|
650
|
+
resolveTsTranspilerVersionFn();
|
|
651
|
+
|
|
652
|
+
const priorCrap = loadPriorFn(crapAbs, 'crap');
|
|
653
|
+
const envelope = writeFn({
|
|
654
|
+
kind: 'crap',
|
|
655
|
+
rows: crapRows,
|
|
656
|
+
priorEnvelope: priorCrap,
|
|
657
|
+
});
|
|
658
|
+
return commitBaselineEnvelope({
|
|
659
|
+
kind: 'crap',
|
|
660
|
+
abs: crapAbs,
|
|
661
|
+
envelope,
|
|
662
|
+
priorEnvelope: priorCrap,
|
|
663
|
+
writeFileFn,
|
|
664
|
+
fsImpl,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
455
668
|
export async function regenerateMainFromTree({
|
|
456
669
|
cwd = process.cwd(),
|
|
457
670
|
resolveConfig = defaultResolveConfig,
|
|
@@ -476,149 +689,40 @@ export async function regenerateMainFromTree({
|
|
|
476
689
|
const files = [];
|
|
477
690
|
let didChange = false;
|
|
478
691
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
const scores = await calculateAllFn(miSourceList);
|
|
494
|
-
|
|
495
|
-
// Project the scoring helper's `{path: mi}` map onto the writer's
|
|
496
|
-
// canonical row shape. Story #2079 path-canon defence stays in place —
|
|
497
|
-
// the writer would canonicalise again, but doing it here keeps any
|
|
498
|
-
// pre-canonicalised comparison inside the function meaningful.
|
|
499
|
-
const miRows = filterExcludedRows(
|
|
500
|
-
Object.entries(scores).map(([key, mi]) => {
|
|
501
|
-
const rel = path.isAbsolute(key) ? path.relative(cwd, key) : key;
|
|
502
|
-
const posixRel = rel.split(path.sep).join('/');
|
|
503
|
-
return { path: canonicalisePath(posixRel), mi };
|
|
504
|
-
}),
|
|
505
|
-
);
|
|
506
|
-
|
|
507
|
-
const priorMi = loadPriorFn(miAbs, 'maintainability');
|
|
508
|
-
const envelope = writeFn({
|
|
509
|
-
kind: 'maintainability',
|
|
510
|
-
rows: miRows,
|
|
511
|
-
priorEnvelope: priorMi,
|
|
512
|
-
});
|
|
513
|
-
if (priorMi && envelope === priorMi) {
|
|
514
|
-
// Structural-equality short-circuit fired — on-disk bytes are
|
|
515
|
-
// guaranteed identical, no writeFile invocation needed.
|
|
516
|
-
files.push({
|
|
517
|
-
kind: 'maintainability',
|
|
518
|
-
path: miAbs,
|
|
519
|
-
didChange: false,
|
|
520
|
-
reason: 'unchanged',
|
|
521
|
-
});
|
|
522
|
-
} else {
|
|
523
|
-
writeFileFn(miAbs, envelope, { fsImpl });
|
|
524
|
-
didChange = true;
|
|
525
|
-
files.push({
|
|
526
|
-
kind: 'maintainability',
|
|
527
|
-
path: miAbs,
|
|
528
|
-
didChange: true,
|
|
529
|
-
reason: 'updated',
|
|
530
|
-
});
|
|
531
|
-
}
|
|
692
|
+
const miScan = await regenerateMaintainability({
|
|
693
|
+
cwd,
|
|
694
|
+
baselines,
|
|
695
|
+
quality,
|
|
696
|
+
scanDirectoryFn,
|
|
697
|
+
calculateAllFn,
|
|
698
|
+
writeFn,
|
|
699
|
+
writeFileFn,
|
|
700
|
+
loadPriorFn,
|
|
701
|
+
fsImpl,
|
|
702
|
+
});
|
|
703
|
+
if (miScan) {
|
|
704
|
+
files.push(miScan.entry);
|
|
705
|
+
didChange = didChange || miScan.wrote;
|
|
532
706
|
}
|
|
533
707
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
const coverage = loadCoverageFn(coverageAbs);
|
|
553
|
-
if (!coverage && requireCoverage) {
|
|
554
|
-
logger.warn?.(
|
|
555
|
-
`[baseline-snapshot] ⚠ no coverage at ${coveragePath} — skipping crap regeneration (refresh stays clean for this file).`,
|
|
556
|
-
);
|
|
557
|
-
files.push({
|
|
558
|
-
kind: 'crap',
|
|
559
|
-
path: crapAbs,
|
|
560
|
-
didChange: false,
|
|
561
|
-
reason: 'no-coverage',
|
|
562
|
-
});
|
|
563
|
-
} else {
|
|
564
|
-
// Reuse the MI scan's file list when CRAP and MI target the same
|
|
565
|
-
// directories with the same ignore globs — avoids a second full-tree
|
|
566
|
-
// walk over identical source trees (Story #3663).
|
|
567
|
-
const crapDirsMatchMi =
|
|
568
|
-
miSourceList !== null &&
|
|
569
|
-
crapTargetDirs.length === miTargetDirs.length &&
|
|
570
|
-
crapTargetDirs.every((d, i) => d === miTargetDirs[i]) &&
|
|
571
|
-
crapIgnoreGlobs.length === miIgnoreGlobs.length &&
|
|
572
|
-
crapIgnoreGlobs.every((g, i) => g === miIgnoreGlobs[i]);
|
|
573
|
-
const { rows } = await scanAndScoreFn({
|
|
574
|
-
targetDirs: crapTargetDirs,
|
|
575
|
-
coverage,
|
|
576
|
-
requireCoverage,
|
|
577
|
-
cwd,
|
|
578
|
-
ignoreGlobs: crapIgnoreGlobs,
|
|
579
|
-
...(crapDirsMatchMi && { preScannedFiles: miSourceList }),
|
|
580
|
-
});
|
|
581
|
-
// scanAndScore yields rows keyed by `file:`; the per-kind crap module's
|
|
582
|
-
// `projectRow` handles `path ?? file`, so the writer takes either.
|
|
583
|
-
// Filter to actually-scored rows here (crap is nullable for trivial
|
|
584
|
-
// methods); the writer's `assertEnvelope` would reject otherwise.
|
|
585
|
-
const crapRows = (rows ?? []).filter(
|
|
586
|
-
(r) => typeof r?.crap === 'number' && Number.isFinite(r.crap),
|
|
587
|
-
);
|
|
588
|
-
|
|
589
|
-
// CRAP gates need the running scorer's versions present on the
|
|
590
|
-
// envelope-adjacent shape; the V2 envelope itself only carries
|
|
591
|
-
// `kernelVersion`, so we stamp escomplex/tsTranspiler via the writer's
|
|
592
|
-
// `kernelVersion` override and let the existing per-kind module
|
|
593
|
-
// resolve the rest. We also resolve them eagerly so a test stub can
|
|
594
|
-
// pin them deterministically.
|
|
595
|
-
resolveEscomplexVersionFn(cwd);
|
|
596
|
-
resolveTsTranspilerVersionFn();
|
|
597
|
-
|
|
598
|
-
const priorCrap = loadPriorFn(crapAbs, 'crap');
|
|
599
|
-
const envelope = writeFn({
|
|
600
|
-
kind: 'crap',
|
|
601
|
-
rows: crapRows,
|
|
602
|
-
priorEnvelope: priorCrap,
|
|
603
|
-
});
|
|
604
|
-
if (priorCrap && envelope === priorCrap) {
|
|
605
|
-
files.push({
|
|
606
|
-
kind: 'crap',
|
|
607
|
-
path: crapAbs,
|
|
608
|
-
didChange: false,
|
|
609
|
-
reason: 'unchanged',
|
|
610
|
-
});
|
|
611
|
-
} else {
|
|
612
|
-
writeFileFn(crapAbs, envelope, { fsImpl });
|
|
613
|
-
didChange = true;
|
|
614
|
-
files.push({
|
|
615
|
-
kind: 'crap',
|
|
616
|
-
path: crapAbs,
|
|
617
|
-
didChange: true,
|
|
618
|
-
reason: 'updated',
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
}
|
|
708
|
+
const crapResult = await regenerateCrap({
|
|
709
|
+
cwd,
|
|
710
|
+
baselines,
|
|
711
|
+
quality,
|
|
712
|
+
logger,
|
|
713
|
+
miScan,
|
|
714
|
+
scanAndScoreFn,
|
|
715
|
+
loadCoverageFn,
|
|
716
|
+
resolveEscomplexVersionFn,
|
|
717
|
+
resolveTsTranspilerVersionFn,
|
|
718
|
+
writeFn,
|
|
719
|
+
writeFileFn,
|
|
720
|
+
loadPriorFn,
|
|
721
|
+
fsImpl,
|
|
722
|
+
});
|
|
723
|
+
if (crapResult) {
|
|
724
|
+
files.push(crapResult.entry);
|
|
725
|
+
didChange = didChange || crapResult.wrote;
|
|
622
726
|
}
|
|
623
727
|
|
|
624
728
|
return { didChange, files };
|