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.
- package/.agents/docs/SDLC.md +10 -3
- package/.agents/docs/workflows.md +1 -1
- 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/.agents/workflows/deliver.md +87 -26
- package/.agents/workflows/helpers/deliver-epic.md +12 -5
- package/.agents/workflows/helpers/deliver-stories.md +13 -7
- package/.agents/workflows/plan.md +3 -1
- package/README.md +1 -1
- package/docs/CHANGELOG.md +40 -0
- package/lib/cli/registry.js +1 -1
- package/lib/cli/update.js +114 -8
- package/package.json +1 -1
|
@@ -139,101 +139,82 @@ function resolveGitUserEmail(cwd) {
|
|
|
139
139
|
* checkpointInitializedAt: string,
|
|
140
140
|
* }>}
|
|
141
141
|
*/
|
|
142
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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 };
|