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.
Files changed (27) hide show
  1. package/.agents/scripts/check-action-pinning.js +260 -0
  2. package/.agents/scripts/check-arch-cycles.js +38 -14
  3. package/.agents/scripts/epic-deliver-prepare.js +149 -104
  4. package/.agents/scripts/lib/baseline-snapshot.js +245 -141
  5. package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
  6. package/.agents/scripts/lib/orchestration/code-review.js +206 -168
  7. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
  8. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
  9. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
  10. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
  11. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
  12. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
  13. package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
  14. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
  15. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
  16. package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
  17. package/.agents/scripts/lib/signals/detectors/common.js +107 -0
  18. package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
  19. package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
  20. package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
  21. package/.agents/scripts/lib/story-body/story-body.js +102 -76
  22. package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
  23. package/.agents/scripts/single-story-init.js +16 -3
  24. package/.agents/workflows/audit-architecture.md +9 -0
  25. package/README.md +1 -1
  26. package/docs/CHANGELOG.md +28 -0
  27. 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
- // ── maintainability ──────────────────────────────────────────────────────
480
- const miPath = baselines?.maintainability?.path;
481
- const miTargetDirs = quality?.maintainability?.targetDirs ?? [];
482
- const miIgnoreGlobs = quality?.maintainability?.ignoreGlobs ?? [];
483
- // Hoisted so the CRAP pass can reuse it when targetDirs match — avoids a
484
- // second full-tree walk over the same directories (Story #3663).
485
- let miSourceList = null;
486
- if (typeof miPath === 'string' && miPath.length > 0) {
487
- const miAbs = path.isAbsolute(miPath) ? miPath : path.resolve(cwd, miPath);
488
- miSourceList = [];
489
- for (const dir of miTargetDirs) {
490
- const abs = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir);
491
- scanDirectoryFn(abs, miSourceList, { cwd, ignoreGlobs: miIgnoreGlobs });
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
- // ── crap ─────────────────────────────────────────────────────────────────
535
- const crapPath = baselines?.crap?.path;
536
- const crapCfg = quality?.crap ?? {};
537
- const crapTargetDirs = Array.isArray(crapCfg.targetDirs)
538
- ? crapCfg.targetDirs
539
- : [];
540
- const crapIgnoreGlobs = Array.isArray(crapCfg.ignoreGlobs)
541
- ? crapCfg.ignoreGlobs
542
- : [];
543
- const requireCoverage = crapCfg.requireCoverage !== false;
544
- const coveragePath = crapCfg.coveragePath ?? 'coverage/coverage-final.json';
545
- if (typeof crapPath === 'string' && crapPath.length > 0) {
546
- const crapAbs = path.isAbsolute(crapPath)
547
- ? crapPath
548
- : path.resolve(cwd, crapPath);
549
- const coverageAbs = path.isAbsolute(coveragePath)
550
- ? coveragePath
551
- : path.resolve(cwd, coveragePath);
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 };