mandrel 1.61.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 (35) hide show
  1. package/.agents/docs/SDLC.md +10 -3
  2. package/.agents/docs/workflows.md +1 -1
  3. package/.agents/scripts/check-action-pinning.js +260 -0
  4. package/.agents/scripts/check-arch-cycles.js +38 -14
  5. package/.agents/scripts/epic-deliver-prepare.js +149 -104
  6. package/.agents/scripts/lib/baseline-snapshot.js +245 -141
  7. package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
  8. package/.agents/scripts/lib/orchestration/code-review.js +206 -168
  9. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
  10. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
  11. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
  12. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
  13. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
  14. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
  15. package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
  16. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
  17. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
  18. package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
  19. package/.agents/scripts/lib/signals/detectors/common.js +107 -0
  20. package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
  21. package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
  22. package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
  23. package/.agents/scripts/lib/story-body/story-body.js +102 -76
  24. package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
  25. package/.agents/scripts/single-story-init.js +16 -3
  26. package/.agents/workflows/audit-architecture.md +9 -0
  27. package/.agents/workflows/deliver.md +87 -26
  28. package/.agents/workflows/helpers/deliver-epic.md +12 -5
  29. package/.agents/workflows/helpers/deliver-stories.md +13 -7
  30. package/.agents/workflows/plan.md +3 -1
  31. package/README.md +1 -1
  32. package/docs/CHANGELOG.md +40 -0
  33. package/lib/cli/registry.js +1 -1
  34. package/lib/cli/update.js +114 -8
  35. package/package.json +1 -1
@@ -139,101 +139,82 @@ function resolveGitUserEmail(cwd) {
139
139
  * checkpointInitializedAt: string,
140
140
  * }>}
141
141
  */
142
- export async function runEpicDeliverPrepare({
142
+ /**
143
+ * Run the fail-closed preflight guards (Story #3482): refuse on a
144
+ * dirty/foreign-branch checkout and on a live foreign Epic lease, BEFORE any
145
+ * snapshot or git mutation. No-op when guards are suppressed. The guards are
146
+ * skipped when `skipPreflightGuards` is set, OR — implicitly — when a caller
147
+ * injects a provider but no git seam (the signature of the prepare-runner
148
+ * unit tests that drive an in-memory provider and never stand up a tree). The
149
+ * real CLI path injects neither, so the guards always run for an
150
+ * operator-driven invocation. Story #4075 — extracted from
151
+ * `runEpicDeliverPrepare`.
152
+ */
153
+ async function runPreflightGuardsForPrepare({
143
154
  epicId,
144
155
  cwd,
156
+ config,
157
+ provider,
145
158
  injectedProvider,
146
- injectedConfig,
147
- injectedFindings,
148
- ignoreConcurrencyHazards = false,
149
- steal = false,
150
- asOperator,
151
159
  injectedGit,
160
+ asOperator,
161
+ steal,
152
162
  leaseHeartbeatAt,
153
163
  leaseNow,
154
- skipPreflightGuards = false,
155
- } = {}) {
156
- if (!Number.isInteger(epicId) || epicId <= 0) {
157
- throw new TypeError(
158
- 'runEpicDeliverPrepare: --epic must be a positive integer',
159
- );
160
- }
161
-
162
- const config = injectedConfig ?? resolveConfig({ cwd });
163
- if (!config.github) {
164
- throw new Error('runEpicDeliverPrepare: no github block in .agentrc.json');
165
- }
166
- const provider = injectedProvider ?? createProvider(config);
167
- const { deliverRunner } = getRunners(config);
168
- const concurrencyCap = deliverRunner.concurrencyCap;
169
-
170
- // Preflight guards (Story #3482): fail closed on a dirty/foreign-branch
171
- // checkout and on a live foreign Epic lease, BEFORE any snapshot or git
172
- // mutation runs. The guards are injectable so the unit suite exercises them
173
- // without a real repo. They are skipped when an explicit `skipPreflightGuards`
174
- // is set, OR — implicitly — when a caller injects a provider but no git seam:
175
- // that combination is the signature of the pre-existing prepare-runner unit
176
- // tests that assert the DAG/checkpoint behaviour against an in-memory
177
- // provider and never stand up a working tree. The real CLI path passes
178
- // neither `injectedProvider` nor `injectedGit`, so the guards always run for
179
- // an operator-driven invocation.
164
+ skipPreflightGuards,
165
+ }) {
180
166
  const guardsSuppressed =
181
167
  skipPreflightGuards || (Boolean(injectedProvider) && !injectedGit);
182
- if (!guardsSuppressed) {
183
- const guardCwd = cwd ?? process.cwd();
184
- const git = injectedGit ?? createGitShim(guardCwd);
185
- const baseBranch = config.project?.baseBranch ?? 'main';
186
- const expectedBranch = [getEpicBranch(epicId), baseBranch];
187
- const operator =
188
- resolveOperator({
189
- asFlag: asOperator,
190
- config,
191
- gitUserEmail: injectedGit ? undefined : resolveGitUserEmail(guardCwd),
192
- }) ?? null;
193
-
194
- // Liveness seam: a foreign claim is only "live" (and so refuses) when the
195
- // claim *owner* has a recent `story.heartbeat`. Without this the lease
196
- // guard is inert — `heartbeatAt` defaults to null, `isClaimLive(null)` is
197
- // false, and every foreign claim looks stale and gets silently reclaimed
198
- // (audit #3513). Read the Epic's current assignee (the claim owner) and
199
- // resolve that owner's latest heartbeat from the Epic lifecycle ledger
200
- // (`temp/epic-<id>/lifecycle.ndjson`) via the shared resolver, so a LIVE
201
- // foreign claim actually refuses and only a genuinely stale/absent one is
202
- // reclaimed. Tests may inject `leaseHeartbeatAt` directly (any value,
203
- // including null) to bypass the ledger read; the CLI passes nothing.
204
- let heartbeatAt = leaseHeartbeatAt;
205
- if (heartbeatAt === undefined) {
206
- const epicTicket = await provider.getTicket(epicId);
207
- const claimOwner = leaseCurrentOwner(epicTicket?.assignees);
208
- heartbeatAt = claimOwner
209
- ? latestHeartbeatForOwner({ epicId, owner: claimOwner, config })
210
- : null;
211
- }
168
+ if (guardsSuppressed) return;
212
169
 
213
- await runPrepareGuards({
214
- epicId,
215
- expectedBranch,
216
- git,
217
- provider,
218
- operator,
219
- heartbeatAt,
220
- steal,
170
+ const guardCwd = cwd ?? process.cwd();
171
+ const git = injectedGit ?? createGitShim(guardCwd);
172
+ const baseBranch = config.project?.baseBranch ?? 'main';
173
+ const expectedBranch = [getEpicBranch(epicId), baseBranch];
174
+ const operator =
175
+ resolveOperator({
176
+ asFlag: asOperator,
221
177
  config,
222
- now: leaseNow,
223
- logger: Logger,
224
- });
178
+ gitUserEmail: injectedGit ? undefined : resolveGitUserEmail(guardCwd),
179
+ }) ?? null;
180
+
181
+ // Liveness seam: a foreign claim is only "live" (and so refuses) when the
182
+ // claim *owner* has a recent `story.heartbeat`. Without this the lease
183
+ // guard is inert — every foreign claim looks stale and gets silently
184
+ // reclaimed (audit #3513). Read the Epic's current assignee (the claim
185
+ // owner) and resolve that owner's latest heartbeat from the Epic lifecycle
186
+ // ledger via the shared resolver. Tests may inject `leaseHeartbeatAt`
187
+ // directly (any value, including null) to bypass the ledger read.
188
+ let heartbeatAt = leaseHeartbeatAt;
189
+ if (heartbeatAt === undefined) {
190
+ const epicTicket = await provider.getTicket(epicId);
191
+ const claimOwner = leaseCurrentOwner(epicTicket?.assignees);
192
+ heartbeatAt = claimOwner
193
+ ? latestHeartbeatForOwner({ epicId, owner: claimOwner, config })
194
+ : null;
225
195
  }
226
196
 
227
- // Story #3027: try the preflight cache first so we don't re-walk Epic
228
- // → Feature → Story when `epic-deliver-preflight.js` already did. The
229
- // cache key is a deterministic fingerprint of the Epic ticket plus the
230
- // cached Story snapshots (Story #4019): the Epic re-fetch plus one
231
- // getTicket per cached Story is still far cheaper than the full
232
- // hierarchy BFS, and a Story-dependency edit now invalidates the cache.
233
- // Cache miss or baseSha mismatch → fall back to a fresh pass.
234
- const ctx = { epicId, provider };
235
- let state = {};
236
- let cacheStatus = 'miss';
197
+ await runPrepareGuards({
198
+ epicId,
199
+ expectedBranch,
200
+ git,
201
+ provider,
202
+ operator,
203
+ heartbeatAt,
204
+ steal,
205
+ config,
206
+ now: leaseNow,
207
+ logger: Logger,
208
+ });
209
+ }
210
+
211
+ /**
212
+ * Resolve the Epic state, preferring the preflight cache (Story #3027) and
213
+ * falling back to a fresh snapshot + wave-DAG pass on miss or baseSha
214
+ * mismatch. Returns `{ state, cacheStatus }`. Story #4075 — extracted from
215
+ * `runEpicDeliverPrepare`.
216
+ */
217
+ async function resolvePrepareState({ epicId, cwd, provider }) {
237
218
  const cached = await readPreflightCache({ epicId, cwd });
238
219
  if (cached) {
239
220
  const freshEpic = await provider.getTicket(epicId);
@@ -245,35 +226,42 @@ export async function runEpicDeliverPrepare({
245
226
  );
246
227
  const freshBaseSha = computeBaseSha(freshEpic, freshStories);
247
228
  if (freshBaseSha === cached.baseSha) {
248
- state = {
249
- epic: cached.epic,
250
- stories: cached.stories,
251
- waves: cached.waves,
229
+ return {
230
+ state: {
231
+ epic: cached.epic,
232
+ stories: cached.stories,
233
+ waves: cached.waves,
234
+ },
235
+ cacheStatus: 'hit',
252
236
  };
253
- cacheStatus = 'hit';
254
- } else {
255
- cacheStatus = 'stale';
256
237
  }
257
238
  }
258
- if (cacheStatus !== 'hit') {
259
- state = await runSnapshotPhase(ctx, {}, state);
260
- state = await runBuildWaveDagPhase(ctx, {}, state);
261
- }
239
+ const ctx = { epicId, provider };
240
+ let state = await runSnapshotPhase(ctx, {}, {});
241
+ state = await runBuildWaveDagPhase(ctx, {}, state);
242
+ return { state, cacheStatus: cached ? 'stale' : 'miss' };
243
+ }
262
244
 
263
- // Cross-Story concurrency-hazard gate (Story #2297). Findings come in
264
- // via DI; no default loader is wired yet production callers will
265
- // either pass findings derived from the persisted manifest or rely on
266
- // the empty default (gate trivially passes).
245
+ /**
246
+ * Evaluate the cross-Story concurrency-hazard gate (Story #2297). Throws on a
247
+ * tripped, non-bypassed gate; warns (and returns `gate`) on a bypassed trip.
248
+ * Story #4075 extracted from `runEpicDeliverPrepare`.
249
+ */
250
+ function evaluatePrepareConcurrencyGate({
251
+ config,
252
+ waves,
253
+ injectedFindings,
254
+ ignoreConcurrencyHazards,
255
+ }) {
267
256
  const findings = Array.isArray(injectedFindings) ? injectedFindings : [];
268
- const pendingKeys = collectPendingStoryKeys(state.waves);
257
+ const pendingKeys = collectPendingStoryKeys(waves);
269
258
  const pendingFindings = filterFindingsToPending(findings, pendingKeys);
270
- const concurrencyPolicy = {
271
- failOnConcurrencyHazards:
272
- config?.delivery?.failOnConcurrencyHazards === true,
273
- };
274
259
  const gate = evaluateConcurrencyGate({
275
260
  findings: pendingFindings,
276
- policy: concurrencyPolicy,
261
+ policy: {
262
+ failOnConcurrencyHazards:
263
+ config?.delivery?.failOnConcurrencyHazards === true,
264
+ },
277
265
  ignore: ignoreConcurrencyHazards === true,
278
266
  });
279
267
  if (gate.tripped && !gate.bypassed) {
@@ -288,6 +276,63 @@ export async function runEpicDeliverPrepare({
288
276
  `[epic-deliver-prepare] ⚠️ Concurrency-hazard gate bypassed via --ignore-concurrency-hazards (reason=${gate.reason}, count=${gate.findings.length}).`,
289
277
  );
290
278
  }
279
+ return gate;
280
+ }
281
+
282
+ export async function runEpicDeliverPrepare({
283
+ epicId,
284
+ cwd,
285
+ injectedProvider,
286
+ injectedConfig,
287
+ injectedFindings,
288
+ ignoreConcurrencyHazards = false,
289
+ steal = false,
290
+ asOperator,
291
+ injectedGit,
292
+ leaseHeartbeatAt,
293
+ leaseNow,
294
+ skipPreflightGuards = false,
295
+ } = {}) {
296
+ if (!Number.isInteger(epicId) || epicId <= 0) {
297
+ throw new TypeError(
298
+ 'runEpicDeliverPrepare: --epic must be a positive integer',
299
+ );
300
+ }
301
+
302
+ const config = injectedConfig ?? resolveConfig({ cwd });
303
+ if (!config.github) {
304
+ throw new Error('runEpicDeliverPrepare: no github block in .agentrc.json');
305
+ }
306
+ const provider = injectedProvider ?? createProvider(config);
307
+ const { deliverRunner } = getRunners(config);
308
+ const concurrencyCap = deliverRunner.concurrencyCap;
309
+
310
+ await runPreflightGuardsForPrepare({
311
+ epicId,
312
+ cwd,
313
+ config,
314
+ provider,
315
+ injectedProvider,
316
+ injectedGit,
317
+ asOperator,
318
+ steal,
319
+ leaseHeartbeatAt,
320
+ leaseNow,
321
+ skipPreflightGuards,
322
+ });
323
+
324
+ const { state, cacheStatus } = await resolvePrepareState({
325
+ epicId,
326
+ cwd,
327
+ provider,
328
+ });
329
+
330
+ const gate = evaluatePrepareConcurrencyGate({
331
+ config,
332
+ waves: state.waves,
333
+ injectedFindings,
334
+ ignoreConcurrencyHazards,
335
+ });
291
336
 
292
337
  const totalWaves = state.waves.length;
293
338
  const checkpointState = await initializeEpicRunState({
@@ -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 };