mandrel 2.16.0 → 2.18.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/agentrc-reference.json +10 -0
- package/.agents/docs/configuration.md +9 -0
- package/.agents/docs/quality-gates.md +137 -0
- package/.agents/schemas/agentrc.schema.json +48 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +4 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/acceptance-eval.js +52 -12
- package/.agents/scripts/audit-to-stories.js +92 -25
- package/.agents/scripts/boot-sweep.js +67 -8
- package/.agents/scripts/check-baseline-drift.js +138 -0
- package/.agents/scripts/coverage-capture.js +74 -25
- package/.agents/scripts/deliver-recover.js +45 -18
- package/.agents/scripts/drain-pending-cleanup.js +67 -23
- package/.agents/scripts/generate-lens-checklists.js +81 -30
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
- package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
- package/.agents/scripts/lib/baselines/envelope.js +7 -0
- package/.agents/scripts/lib/baselines/kernel.js +31 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
- package/.agents/scripts/lib/baselines/reader.js +12 -1
- package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
- package/.agents/scripts/lib/baselines/writer.js +10 -0
- package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
- package/.agents/scripts/lib/cli-utils.js +48 -13
- package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
- package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
- package/.agents/scripts/lib/close-validation/runner.js +68 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
- package/.agents/scripts/lib/config/quality.js +40 -0
- package/.agents/scripts/lib/config/temp-paths.js +27 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +69 -0
- package/.agents/scripts/lib/coverage-utils.js +92 -9
- package/.agents/scripts/lib/crap-engine.js +113 -23
- package/.agents/scripts/lib/crap-utils.js +159 -93
- package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
- package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
- package/.agents/scripts/lib/observability/terse-result.js +7 -3
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +9 -5
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +15 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
- package/.agents/scripts/lib/single-story-sweep.js +11 -0
- package/.agents/scripts/lib/stdio-flush.js +71 -0
- package/.agents/scripts/lib/temp-retention.js +559 -0
- package/.agents/scripts/lib/transpile.js +133 -6
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
- package/.agents/scripts/lib/workers/crap-worker.js +49 -76
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
- package/.agents/scripts/nav-registry-diff.js +30 -8
- package/.agents/scripts/plan-run-epilogue.js +27 -11
- package/.agents/scripts/resolve-doc-tiers.js +18 -8
- package/.agents/scripts/single-story-close.js +9 -92
- package/.agents/scripts/single-story-init.js +1 -1
- package/.agents/scripts/sync-branch-from-base.js +6 -1
- package/.agents/scripts/update-crap-baseline.js +13 -0
- package/README.md +14 -6
- package/docs/CHANGELOG.md +36 -0
- package/lib/cli/version-helpers.js +7 -0
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
- package/package.json +5 -1
|
@@ -2,15 +2,15 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import escomplex from 'typhonjs-escomplex';
|
|
4
4
|
import { canonicalise as canonicalisePath } from './baselines/path-canon.js';
|
|
5
|
-
import {
|
|
6
|
-
coverageForMethodInEntry,
|
|
7
|
-
findCoverageEntry,
|
|
8
|
-
} from './coverage-utils.js';
|
|
5
|
+
import { findCoverageEntry } from './coverage-utils.js';
|
|
9
6
|
import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
|
|
10
|
-
import {
|
|
7
|
+
import { finalizeMethodRows, methodRowsFromReport } from './crap-engine.js';
|
|
11
8
|
import { Logger } from './Logger.js';
|
|
12
9
|
import { scanDirectory } from './maintainability-utils.js';
|
|
13
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
prepareSourceForScoring,
|
|
12
|
+
resolveTsTranspilerVersion,
|
|
13
|
+
} from './transpile.js';
|
|
14
14
|
|
|
15
15
|
const CRAP_WORKER_URL = new URL('./workers/crap-worker.js', import.meta.url);
|
|
16
16
|
const COMBINED_MI_CRAP_WORKER_URL = new URL(
|
|
@@ -201,6 +201,103 @@ export function buildBaselineEnvelope({
|
|
|
201
201
|
};
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/**
|
|
205
|
+
* How many files to name when reporting the worst unresolved offenders. Long
|
|
206
|
+
* enough to point at a pattern, short enough to stay a readable CLI message.
|
|
207
|
+
*/
|
|
208
|
+
const WORST_OFFENDER_LIMIT = 5;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Method-resolution telemetry (Story #4775, fix part 4).
|
|
212
|
+
*
|
|
213
|
+
* The updater used to persist a 100-row baseline built from 5023 dropped
|
|
214
|
+
* methods and log it as success — the rot that let a broken coverage join
|
|
215
|
+
* sit undetected for five weeks across three repos. These three helpers
|
|
216
|
+
* carry the counters that make a thin result *visible* and therefore
|
|
217
|
+
* refusable.
|
|
218
|
+
*
|
|
219
|
+
* The rate is deliberately measured over files that **do** have a coverage
|
|
220
|
+
* entry: a file the test run never touched has no join to fail, so counting
|
|
221
|
+
* it would dilute the signal the floor is meant to catch.
|
|
222
|
+
*/
|
|
223
|
+
function newResolutionAccumulator() {
|
|
224
|
+
return { resolved: 0, total: 0, byFile: [] };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function accumulateResolution(acc, relPath, result) {
|
|
228
|
+
if (result?.hasCoverageEntry !== true) return;
|
|
229
|
+
const total = result.totalMethods ?? 0;
|
|
230
|
+
if (total === 0) return;
|
|
231
|
+
const resolved = result.resolvedMethods ?? 0;
|
|
232
|
+
acc.resolved += resolved;
|
|
233
|
+
acc.total += total;
|
|
234
|
+
if (resolved < total) {
|
|
235
|
+
acc.byFile.push({ file: relPath, unresolved: total - resolved, total });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function summarizeResolution(acc) {
|
|
240
|
+
const worstFiles = [...acc.byFile]
|
|
241
|
+
.sort((a, b) => b.unresolved - a.unresolved || a.file.localeCompare(b.file))
|
|
242
|
+
.slice(0, WORST_OFFENDER_LIMIT);
|
|
243
|
+
return {
|
|
244
|
+
resolvedMethods: acc.resolved,
|
|
245
|
+
joinableMethods: acc.total,
|
|
246
|
+
rate: acc.total === 0 ? 1 : acc.resolved / acc.total,
|
|
247
|
+
worstFiles,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Minimum number of joinable methods before the resolution-rate floor is
|
|
253
|
+
* enforced. A diff-scoped run can legitimately touch a handful of methods,
|
|
254
|
+
* where one unresolved method is a 50% rate and says nothing about the health
|
|
255
|
+
* of the join. Below this sample the rate is reported, never enforced.
|
|
256
|
+
*/
|
|
257
|
+
const MIN_RESOLUTION_SAMPLE = 25;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Fail-closed guard on the per-method coverage join (Story #4775, fix part 4).
|
|
261
|
+
*
|
|
262
|
+
* The updater used to persist a 100-row baseline distilled from 5023 dropped
|
|
263
|
+
* methods and log it as a success — which is exactly how a broken join stayed
|
|
264
|
+
* invisible for five weeks across three repositories. A thin result is now a
|
|
265
|
+
* refusal: the caller throws before anything is written, and the message names
|
|
266
|
+
* the rate, the counts, and the files carrying the most unresolved methods so
|
|
267
|
+
* the operator can tell "my tests do not cover that" apart from "the join is
|
|
268
|
+
* broken".
|
|
269
|
+
*
|
|
270
|
+
* Returns `null` when the run may proceed, or the operator-facing message when
|
|
271
|
+
* it must not.
|
|
272
|
+
*
|
|
273
|
+
* @param {{resolvedMethods: number, joinableMethods: number, rate: number,
|
|
274
|
+
* worstFiles: Array<{file: string, unresolved: number, total: number}>}
|
|
275
|
+
* | undefined} resolution
|
|
276
|
+
* @param {number} floor
|
|
277
|
+
* @returns {string|null}
|
|
278
|
+
*/
|
|
279
|
+
export function checkResolutionFloor(resolution, floor) {
|
|
280
|
+
if (!resolution) return null;
|
|
281
|
+
const { joinableMethods = 0, resolvedMethods = 0, rate = 1 } = resolution;
|
|
282
|
+
if (joinableMethods < MIN_RESOLUTION_SAMPLE) return null;
|
|
283
|
+
if (rate >= floor) return null;
|
|
284
|
+
const worst = (resolution.worstFiles ?? [])
|
|
285
|
+
.map((w) => ` - ${w.file} (${w.unresolved}/${w.total} unresolved)`)
|
|
286
|
+
.join('\n');
|
|
287
|
+
return (
|
|
288
|
+
`[CRAP] Refusing to persist: only ${resolvedMethods}/${joinableMethods} ` +
|
|
289
|
+
`method(s) (${(rate * 100).toFixed(1)}%) resolved a coverage entry in files ` +
|
|
290
|
+
`that HAVE coverage — below the ${(floor * 100).toFixed(1)}% floor ` +
|
|
291
|
+
'(delivery.quality.gates.crap.minMethodResolutionRate).\n' +
|
|
292
|
+
' A baseline built from a broken join is not sparse, it is wrong: ' +
|
|
293
|
+
'unresolved methods are absent and coincidental line collisions are ' +
|
|
294
|
+
'mis-attributed.\n' +
|
|
295
|
+
(worst ? ` Worst unresolved files:\n${worst}\n` : '') +
|
|
296
|
+
" Regenerate coverage ('npm run test:coverage') and re-run; if the " +
|
|
297
|
+
'rate stays low the coverage artifact and the scanned tree disagree.'
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
204
301
|
/**
|
|
205
302
|
* Parse `source` exactly once with escomplex and derive both the
|
|
206
303
|
* maintainability score and the raw CRAP method rows from that single report.
|
|
@@ -216,6 +313,10 @@ export function buildBaselineEnvelope({
|
|
|
216
313
|
*
|
|
217
314
|
* @param {string} source Prepared (possibly transpiled) JavaScript source text.
|
|
218
315
|
* @param {object|null} coverageForFile Istanbul coverage entry for this file.
|
|
316
|
+
* @param {((line: number) => number|null)|null} [mapLine] Transpiled →
|
|
317
|
+
* original-source line resolver from `transpileIfNeeded(…, {withLineMap:
|
|
318
|
+
* true})`; `null` for JavaScript, whose coordinates already match the
|
|
319
|
+
* coverage entry's.
|
|
219
320
|
* @returns {{
|
|
220
321
|
* report: object,
|
|
221
322
|
* miScore: number,
|
|
@@ -229,7 +330,7 @@ export function buildBaselineEnvelope({
|
|
|
229
330
|
* parseError: boolean,
|
|
230
331
|
* }}
|
|
231
332
|
*/
|
|
232
|
-
export function analyzeOnce(source, coverageForFile) {
|
|
333
|
+
export function analyzeOnce(source, coverageForFile, mapLine = null) {
|
|
233
334
|
let report;
|
|
234
335
|
try {
|
|
235
336
|
report = escomplex.analyzeModule(source);
|
|
@@ -238,18 +339,7 @@ export function analyzeOnce(source, coverageForFile) {
|
|
|
238
339
|
}
|
|
239
340
|
const miScore =
|
|
240
341
|
typeof report.maintainability === 'number' ? report.maintainability : 0;
|
|
241
|
-
const
|
|
242
|
-
const crapRows = [];
|
|
243
|
-
for (const m of methods) {
|
|
244
|
-
const startLine = m?.lineStart;
|
|
245
|
-
if (typeof startLine !== 'number') continue;
|
|
246
|
-
const cyclomatic = m?.cyclomatic ?? 0;
|
|
247
|
-
const coverage = coverageForFile
|
|
248
|
-
? coverageForMethodInEntry(coverageForFile, startLine)
|
|
249
|
-
: null;
|
|
250
|
-
const crap = coverage === null ? null : crapFormula(cyclomatic, coverage);
|
|
251
|
-
crapRows.push({ method: m.name, startLine, cyclomatic, coverage, crap });
|
|
252
|
-
}
|
|
342
|
+
const crapRows = methodRowsFromReport(report, coverageForFile, mapLine);
|
|
253
343
|
return { report, miScore, crapRows, parseError: false };
|
|
254
344
|
}
|
|
255
345
|
|
|
@@ -348,6 +438,7 @@ export async function scanAndScore({
|
|
|
348
438
|
const rows = [];
|
|
349
439
|
let skippedFilesNoCoverage = 0;
|
|
350
440
|
let skippedMethodsNoCoverage = 0;
|
|
441
|
+
const resolution = newResolutionAccumulator();
|
|
351
442
|
for (const { item, result } of perFile) {
|
|
352
443
|
if (!result) continue; // unrecoverable per-file failure: drop silently to match pre-pool semantics
|
|
353
444
|
if (result.skippedFileNoCoverage) {
|
|
@@ -366,6 +457,7 @@ export async function scanAndScore({
|
|
|
366
457
|
continue;
|
|
367
458
|
}
|
|
368
459
|
skippedMethodsNoCoverage += result.skippedMethodsNoCoverage ?? 0;
|
|
460
|
+
accumulateResolution(resolution, item.relPath, result);
|
|
369
461
|
for (const mr of result.rows) {
|
|
370
462
|
rows.push({
|
|
371
463
|
file: item.relPath,
|
|
@@ -390,6 +482,7 @@ export async function scanAndScore({
|
|
|
390
482
|
scannedFiles,
|
|
391
483
|
skippedFilesNoCoverage,
|
|
392
484
|
skippedMethodsNoCoverage,
|
|
485
|
+
resolution: summarizeResolution(resolution),
|
|
393
486
|
};
|
|
394
487
|
}
|
|
395
488
|
|
|
@@ -408,50 +501,33 @@ function scoreFileSerial({ abs, relPath, requireCoverage }, coverage) {
|
|
|
408
501
|
skippedFileNoCoverage: true,
|
|
409
502
|
rows: [],
|
|
410
503
|
skippedMethodsNoCoverage: 0,
|
|
504
|
+
hasCoverageEntry: false,
|
|
505
|
+
resolvedMethods: 0,
|
|
506
|
+
totalMethods: 0,
|
|
411
507
|
};
|
|
412
508
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
rows: null,
|
|
436
|
-
skippedMethodsNoCoverage: 0,
|
|
437
|
-
};
|
|
438
|
-
}
|
|
439
|
-
const rows = [];
|
|
440
|
-
let skippedMethodsNoCoverage = 0;
|
|
441
|
-
for (const mr of crapRows) {
|
|
442
|
-
if (mr.crap === null || mr.coverage === null) {
|
|
443
|
-
skippedMethodsNoCoverage += 1;
|
|
444
|
-
continue;
|
|
445
|
-
}
|
|
446
|
-
rows.push({
|
|
447
|
-
method: mr.method,
|
|
448
|
-
startLine: mr.startLine,
|
|
449
|
-
cyclomatic: mr.cyclomatic,
|
|
450
|
-
coverage: mr.coverage,
|
|
451
|
-
crap: mr.crap,
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
|
-
return { skippedFileNoCoverage: false, rows, skippedMethodsNoCoverage };
|
|
509
|
+
const dropped = {
|
|
510
|
+
skippedFileNoCoverage: false,
|
|
511
|
+
rows: null,
|
|
512
|
+
skippedMethodsNoCoverage: 0,
|
|
513
|
+
hasCoverageEntry: entry !== null,
|
|
514
|
+
resolvedMethods: 0,
|
|
515
|
+
totalMethods: 0,
|
|
516
|
+
};
|
|
517
|
+
const prepared = prepareSourceForScoring(abs);
|
|
518
|
+
if (prepared.error) return dropped;
|
|
519
|
+
const { crapRows, parseError } = analyzeOnce(
|
|
520
|
+
prepared.code,
|
|
521
|
+
entry,
|
|
522
|
+
prepared.mapLine,
|
|
523
|
+
);
|
|
524
|
+
if (parseError) return dropped;
|
|
525
|
+
const finalized = finalizeMethodRows(crapRows, { requireCoverage });
|
|
526
|
+
return {
|
|
527
|
+
skippedFileNoCoverage: false,
|
|
528
|
+
hasCoverageEntry: entry !== null,
|
|
529
|
+
...finalized,
|
|
530
|
+
};
|
|
455
531
|
}
|
|
456
532
|
|
|
457
533
|
async function scoreFilesViaPool(queue, coverage) {
|
|
@@ -494,33 +570,24 @@ async function scoreFilesViaPool(queue, coverage) {
|
|
|
494
570
|
*/
|
|
495
571
|
function scoreFileCombinedSerial({ abs, relPath, requireCoverage }, coverage) {
|
|
496
572
|
const entry = findCoverageEntry(coverage, relPath);
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
source = fs.readFileSync(abs, 'utf-8');
|
|
500
|
-
} catch {
|
|
501
|
-
return {
|
|
502
|
-
relPath,
|
|
503
|
-
miScore: null,
|
|
504
|
-
skippedFileNoCoverage: false,
|
|
505
|
-
crapRows: null,
|
|
506
|
-
skippedMethodsNoCoverage: 0,
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
|
-
const prepared = transpileIfNeeded(abs, source);
|
|
510
|
-
if (prepared === null) {
|
|
573
|
+
const prepared = prepareSourceForScoring(abs);
|
|
574
|
+
if (prepared.error) {
|
|
511
575
|
return {
|
|
512
576
|
relPath,
|
|
513
|
-
miScore: 0,
|
|
577
|
+
miScore: prepared.error === 'read' ? null : 0,
|
|
514
578
|
skippedFileNoCoverage: false,
|
|
515
579
|
crapRows: null,
|
|
516
580
|
skippedMethodsNoCoverage: 0,
|
|
581
|
+
hasCoverageEntry: entry !== null,
|
|
582
|
+
resolvedMethods: 0,
|
|
583
|
+
totalMethods: 0,
|
|
517
584
|
};
|
|
518
585
|
}
|
|
519
586
|
const {
|
|
520
587
|
miScore,
|
|
521
588
|
crapRows: rawCrapRows,
|
|
522
589
|
parseError,
|
|
523
|
-
} = analyzeOnce(prepared, entry);
|
|
590
|
+
} = analyzeOnce(prepared.code, entry, prepared.mapLine);
|
|
524
591
|
if (parseError) {
|
|
525
592
|
return {
|
|
526
593
|
relPath,
|
|
@@ -528,6 +595,9 @@ function scoreFileCombinedSerial({ abs, relPath, requireCoverage }, coverage) {
|
|
|
528
595
|
skippedFileNoCoverage: false,
|
|
529
596
|
crapRows: null,
|
|
530
597
|
skippedMethodsNoCoverage: 0,
|
|
598
|
+
hasCoverageEntry: entry !== null,
|
|
599
|
+
resolvedMethods: 0,
|
|
600
|
+
totalMethods: 0,
|
|
531
601
|
};
|
|
532
602
|
}
|
|
533
603
|
if (requireCoverage && entry === null) {
|
|
@@ -537,29 +607,22 @@ function scoreFileCombinedSerial({ abs, relPath, requireCoverage }, coverage) {
|
|
|
537
607
|
skippedFileNoCoverage: true,
|
|
538
608
|
crapRows: [],
|
|
539
609
|
skippedMethodsNoCoverage: 0,
|
|
610
|
+
hasCoverageEntry: false,
|
|
611
|
+
resolvedMethods: 0,
|
|
612
|
+
totalMethods: 0,
|
|
540
613
|
};
|
|
541
614
|
}
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
for (const mr of rawCrapRows) {
|
|
545
|
-
if (mr.crap === null || mr.coverage === null) {
|
|
546
|
-
skippedMethodsNoCoverage += 1;
|
|
547
|
-
continue;
|
|
548
|
-
}
|
|
549
|
-
crapRows.push({
|
|
550
|
-
method: mr.method,
|
|
551
|
-
startLine: mr.startLine,
|
|
552
|
-
cyclomatic: mr.cyclomatic,
|
|
553
|
-
coverage: mr.coverage,
|
|
554
|
-
crap: mr.crap,
|
|
555
|
-
});
|
|
556
|
-
}
|
|
615
|
+
const { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods } =
|
|
616
|
+
finalizeMethodRows(rawCrapRows, { requireCoverage });
|
|
557
617
|
return {
|
|
558
618
|
relPath,
|
|
559
619
|
miScore,
|
|
560
620
|
skippedFileNoCoverage: false,
|
|
561
|
-
crapRows,
|
|
621
|
+
crapRows: rows,
|
|
562
622
|
skippedMethodsNoCoverage,
|
|
623
|
+
hasCoverageEntry: entry !== null,
|
|
624
|
+
resolvedMethods,
|
|
625
|
+
totalMethods,
|
|
563
626
|
};
|
|
564
627
|
}
|
|
565
628
|
|
|
@@ -693,6 +756,7 @@ export async function scanAndScoreCombined({
|
|
|
693
756
|
const crapRows = [];
|
|
694
757
|
let skippedFilesNoCoverage = 0;
|
|
695
758
|
let skippedMethodsNoCoverage = 0;
|
|
759
|
+
const resolution = newResolutionAccumulator();
|
|
696
760
|
|
|
697
761
|
for (const { item, result } of perFile) {
|
|
698
762
|
if (!result) continue; // unrecoverable per-file failure: drop silently
|
|
@@ -716,6 +780,7 @@ export async function scanAndScoreCombined({
|
|
|
716
780
|
continue;
|
|
717
781
|
}
|
|
718
782
|
skippedMethodsNoCoverage += result.skippedMethodsNoCoverage ?? 0;
|
|
783
|
+
accumulateResolution(resolution, item.relPath, result);
|
|
719
784
|
for (const mr of result.crapRows) {
|
|
720
785
|
crapRows.push({
|
|
721
786
|
file: item.relPath,
|
|
@@ -750,6 +815,7 @@ export async function scanAndScoreCombined({
|
|
|
750
815
|
scannedFiles,
|
|
751
816
|
skippedFilesNoCoverage,
|
|
752
817
|
skippedMethodsNoCoverage,
|
|
818
|
+
resolution: summarizeResolution(resolution),
|
|
753
819
|
},
|
|
754
820
|
};
|
|
755
821
|
}
|
|
@@ -26,6 +26,18 @@
|
|
|
26
26
|
* single write in the run is the report artifact, performed by the synthesis
|
|
27
27
|
* agent, which is granted the read-only allowlist plus `Write`.
|
|
28
28
|
*
|
|
29
|
+
* ## Partial-failure posture
|
|
30
|
+
*
|
|
31
|
+
* The per-dimension fan-out is settled, not all-or-nothing (Story #4783). One
|
|
32
|
+
* rejected dimension used to discard every sibling's completed work; the
|
|
33
|
+
* engine now partitions the settled results, flows the fulfilled dimensions on
|
|
34
|
+
* to the next phase, and records the rejected ones as an explicit
|
|
35
|
+
* degraded-coverage note in the report's Executive Summary. This mirrors the
|
|
36
|
+
* posture `close-validation/runner.js` already takes with per-gate errors:
|
|
37
|
+
* capture the failure into the result rather than rejecting the whole run.
|
|
38
|
+
* Only a *total* loss — every dimension rejected — throws, because there is
|
|
39
|
+
* then nothing to synthesise.
|
|
40
|
+
*
|
|
29
41
|
* ## Report-contract self-check
|
|
30
42
|
*
|
|
31
43
|
* After synthesis the engine calls the caller-supplied `assertReportContract`
|
|
@@ -43,6 +55,14 @@
|
|
|
43
55
|
* @module dynamic-workflow/audit-orchestrator
|
|
44
56
|
*/
|
|
45
57
|
|
|
58
|
+
import { withDegradedCoverageNote } from './degraded-coverage.js';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A dimension that did not complete, as recorded by the settled fan-out.
|
|
62
|
+
*
|
|
63
|
+
* @typedef {import('./degraded-coverage.js').DimensionFailure} DimensionFailure
|
|
64
|
+
*/
|
|
65
|
+
|
|
46
66
|
/**
|
|
47
67
|
* The live dynamic-workflow runtime context the host passes to a saved
|
|
48
68
|
* `.claude/workflows/*.workflow.js` entry point. Re-exported as a typedef so
|
|
@@ -82,8 +102,11 @@
|
|
|
82
102
|
* Compose the analysis prompt for one dimension (lens-specific).
|
|
83
103
|
* @property {(dimension: string, findings: string) => string} buildCrossCheckPrompt
|
|
84
104
|
* Compose the adversarial cross-check prompt for one dimension's findings.
|
|
85
|
-
* @property {(crossCheckedBlocks: string[]) => string} buildSynthesisPrompt
|
|
86
|
-
* Compose the synthesis prompt that assembles the report and writes it.
|
|
105
|
+
* @property {(crossCheckedBlocks: string[], degraded: DimensionFailure[]) => string} buildSynthesisPrompt
|
|
106
|
+
* Compose the synthesis prompt that assembles the report and writes it. The
|
|
107
|
+
* second argument lists the dimensions that did not complete (empty on a
|
|
108
|
+
* full-coverage run); lenses may ignore it — the engine annotates the report
|
|
109
|
+
* with the degraded-coverage note either way.
|
|
87
110
|
* @property {(report: string) => { conformant: boolean, missingSections: string[], hasTitle: boolean }} assertReportContract
|
|
88
111
|
* Self-check the synthesised report against the lens's report contract.
|
|
89
112
|
* @property {(check: { conformant: boolean, missingSections: string[], hasTitle: boolean }) => string} [formatContractError]
|
|
@@ -122,6 +145,45 @@ export function defaultContractError(check) {
|
|
|
122
145
|
return `report failed contract check: missing ${titlePart}sections=[${sections}]`;
|
|
123
146
|
}
|
|
124
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Reduce a rejection reason to a single-line message.
|
|
150
|
+
*
|
|
151
|
+
* @param {unknown} reason
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
function describeRejection(reason) {
|
|
155
|
+
if (reason instanceof Error) return reason.message;
|
|
156
|
+
return String(reason ?? 'unknown error');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Partition settled fan-out results into the values that completed and the
|
|
161
|
+
* dimensions that did not. `dimensions[i]` names the dimension behind
|
|
162
|
+
* `settled[i]`, so a rejection is always attributable.
|
|
163
|
+
*
|
|
164
|
+
* @template T
|
|
165
|
+
* @param {readonly string[]} dimensions
|
|
166
|
+
* @param {readonly PromiseSettledResult<T>[]} settled
|
|
167
|
+
* @param {string} phaseName Phase recorded on each failure.
|
|
168
|
+
* @returns {{ fulfilled: T[], failures: DimensionFailure[] }}
|
|
169
|
+
*/
|
|
170
|
+
function partitionSettled(dimensions, settled, phaseName) {
|
|
171
|
+
const fulfilled = [];
|
|
172
|
+
const failures = [];
|
|
173
|
+
settled.forEach((result, index) => {
|
|
174
|
+
if (result.status === 'fulfilled') {
|
|
175
|
+
fulfilled.push(result.value);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
failures.push({
|
|
179
|
+
dimension: dimensions[index],
|
|
180
|
+
phase: phaseName,
|
|
181
|
+
reason: describeRejection(result.reason),
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
return { fulfilled, failures };
|
|
185
|
+
}
|
|
186
|
+
|
|
125
187
|
/**
|
|
126
188
|
* Run the shared three-phase audit-lens orchestration: parallel per-dimension
|
|
127
189
|
* analysis → adversarial cross-check → synthesis + report-contract self-check.
|
|
@@ -149,9 +211,10 @@ export async function runAuditOrchestration(spec) {
|
|
|
149
211
|
|
|
150
212
|
const { agent, phase } = ctx;
|
|
151
213
|
|
|
152
|
-
// Phase 1 — parallel per-dimension analysis (read-only agents).
|
|
153
|
-
|
|
154
|
-
|
|
214
|
+
// Phase 1 — parallel per-dimension analysis (read-only agents). Settled, not
|
|
215
|
+
// all-or-nothing: one dimension's rejection must not discard its siblings.
|
|
216
|
+
const analyzed = await phase(ORCHESTRATION_PHASES.ANALYZE, async () =>
|
|
217
|
+
Promise.allSettled(
|
|
155
218
|
dimensions.map(async (dimension) => {
|
|
156
219
|
const { output } = await agent({
|
|
157
220
|
prompt: buildDimensionPrompt(dimension),
|
|
@@ -161,11 +224,15 @@ export async function runAuditOrchestration(spec) {
|
|
|
161
224
|
}),
|
|
162
225
|
),
|
|
163
226
|
);
|
|
227
|
+
const { fulfilled: rawFindings, failures: analyzeFailures } =
|
|
228
|
+
partitionSettled(dimensions, analyzed, 'analyze');
|
|
164
229
|
|
|
165
230
|
// Phase 2 — adversarial cross-check: an independent agent re-verifies each
|
|
166
|
-
// dimension's findings and filters false positives before
|
|
167
|
-
|
|
168
|
-
|
|
231
|
+
// surviving dimension's findings and filters false positives before
|
|
232
|
+
// inclusion. Settled for the same reason as phase 1.
|
|
233
|
+
const checkedDimensions = rawFindings.map((entry) => entry.dimension);
|
|
234
|
+
const checked = await phase(ORCHESTRATION_PHASES.CROSS_CHECK, async () =>
|
|
235
|
+
Promise.allSettled(
|
|
169
236
|
rawFindings.map(async ({ dimension, findings }) => {
|
|
170
237
|
const { output } = await agent({
|
|
171
238
|
prompt: buildCrossCheckPrompt(dimension, findings),
|
|
@@ -175,18 +242,38 @@ export async function runAuditOrchestration(spec) {
|
|
|
175
242
|
}),
|
|
176
243
|
),
|
|
177
244
|
);
|
|
245
|
+
const { fulfilled: crossChecked, failures: crossCheckFailures } =
|
|
246
|
+
partitionSettled(checkedDimensions, checked, 'cross-check');
|
|
247
|
+
|
|
248
|
+
const degraded = [...analyzeFailures, ...crossCheckFailures];
|
|
249
|
+
if (dimensions.length > 0 && crossChecked.length === 0) {
|
|
250
|
+
// Nothing survived — there is no partial report to salvage.
|
|
251
|
+
throw new Error(
|
|
252
|
+
`every audit dimension failed: ${degraded
|
|
253
|
+
.map((f) => `${f.dimension} (${f.phase}: ${f.reason})`)
|
|
254
|
+
.join('; ')}`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
178
257
|
|
|
179
258
|
// Phase 3 — synthesis: assemble the report contract and write the artifact.
|
|
180
|
-
const { output:
|
|
259
|
+
const { output: synthesised } = await phase(
|
|
181
260
|
ORCHESTRATION_PHASES.SYNTHESIZE,
|
|
182
261
|
async () =>
|
|
183
262
|
agent({
|
|
184
|
-
prompt: buildSynthesisPrompt(crossChecked),
|
|
263
|
+
prompt: buildSynthesisPrompt(crossChecked, degraded),
|
|
185
264
|
// Synthesis is the one stage permitted to write the report artifact.
|
|
186
265
|
allowedTools: [...readOnlyTools, SYNTHESIS_WRITE_TOOL],
|
|
187
266
|
}),
|
|
188
267
|
);
|
|
189
268
|
|
|
269
|
+
// The coverage gap is annotated by the engine, never left to the synthesis
|
|
270
|
+
// agent's discretion.
|
|
271
|
+
const report = withDegradedCoverageNote(
|
|
272
|
+
synthesised,
|
|
273
|
+
degraded,
|
|
274
|
+
dimensions.length,
|
|
275
|
+
);
|
|
276
|
+
|
|
190
277
|
// Self-verify report-contract conformance before returning.
|
|
191
278
|
const check = assertReportContract(report);
|
|
192
279
|
if (!check.conformant) {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// .agents/scripts/lib/dynamic-workflow/degraded-coverage.js
|
|
2
|
+
/**
|
|
3
|
+
* Degraded-coverage annotation for audit-lens reports (Story #4783).
|
|
4
|
+
*
|
|
5
|
+
* An audit lens fans out one sub-agent per analysis dimension. When one of
|
|
6
|
+
* those dimensions rejects — a sub-agent that ran out of context, a
|
|
7
|
+
* measurement command that failed, a transient runtime error — the run used to
|
|
8
|
+
* discard every sibling dimension's completed work along with it.
|
|
9
|
+
*
|
|
10
|
+
* The engine now partitions instead: the fulfilled dimensions flow on to
|
|
11
|
+
* synthesis, and the rejected ones are recorded here as an explicit note in
|
|
12
|
+
* the report's Executive Summary. A lens that covers four of five dimensions
|
|
13
|
+
* *and says which one is missing* is strictly more useful than one that yields
|
|
14
|
+
* nothing — but only if the gap is visible. An unannotated partial report is
|
|
15
|
+
* worse than no report, because it reads as complete coverage.
|
|
16
|
+
*
|
|
17
|
+
* The annotation is applied by the engine, not requested of the synthesis
|
|
18
|
+
* agent: a coverage disclaimer that depends on an LLM remembering to write it
|
|
19
|
+
* is not a disclaimer.
|
|
20
|
+
*
|
|
21
|
+
* @module dynamic-workflow/degraded-coverage
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A dimension that did not complete.
|
|
26
|
+
*
|
|
27
|
+
* @typedef {object} DimensionFailure
|
|
28
|
+
* @property {string} dimension The analysis dimension that failed.
|
|
29
|
+
* @property {string} phase The phase it failed in (`analyze` / `cross-check`).
|
|
30
|
+
* @property {string} reason The rejection's message.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Matches the Executive Summary heading at any heading level. */
|
|
34
|
+
const EXECUTIVE_SUMMARY_HEADING = /^#{1,6}\s+Executive Summary\b/i;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Render the degraded-coverage note. Names every failed dimension, the phase
|
|
38
|
+
* it failed in, and the reason, so a reader can tell coverage loss apart from
|
|
39
|
+
* an absence of findings.
|
|
40
|
+
*
|
|
41
|
+
* @param {readonly DimensionFailure[]} failures
|
|
42
|
+
* @param {number} totalDimensions Dimensions the run set out to cover.
|
|
43
|
+
* @returns {string} A markdown blockquote.
|
|
44
|
+
*/
|
|
45
|
+
function formatDegradedCoverageNote(failures, totalDimensions) {
|
|
46
|
+
const detail = failures
|
|
47
|
+
.map((f) => `**${f.dimension}** (${f.phase}: ${f.reason})`)
|
|
48
|
+
.join('; ');
|
|
49
|
+
const noun = failures.length === 1 ? 'dimension' : 'dimensions';
|
|
50
|
+
return [
|
|
51
|
+
`> ⚠️ **Degraded coverage** — ${failures.length} of ${totalDimensions} analysis ${noun} did not complete`,
|
|
52
|
+
`> and ${failures.length === 1 ? 'is' : 'are'} unrepresented in this report: ${detail}.`,
|
|
53
|
+
'> Findings for the remaining dimensions are complete; the gap above is not evidence of their absence.',
|
|
54
|
+
].join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Annotate a synthesised report with the degraded-coverage note, inserted
|
|
59
|
+
* directly beneath the `## Executive Summary` heading (every lens report
|
|
60
|
+
* contract requires that section). A report without the heading is prefixed
|
|
61
|
+
* instead, so the note can never be silently dropped.
|
|
62
|
+
*
|
|
63
|
+
* Returns the report unchanged when nothing failed — a full-coverage run must
|
|
64
|
+
* not carry a coverage caveat.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} report
|
|
67
|
+
* @param {readonly DimensionFailure[]} failures
|
|
68
|
+
* @param {number} totalDimensions
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
export function withDegradedCoverageNote(report, failures, totalDimensions) {
|
|
72
|
+
if (!Array.isArray(failures) || failures.length === 0) return report;
|
|
73
|
+
const note = formatDegradedCoverageNote(failures, totalDimensions);
|
|
74
|
+
const lines = String(report).split('\n');
|
|
75
|
+
const headingIndex = lines.findIndex((line) =>
|
|
76
|
+
EXECUTIVE_SUMMARY_HEADING.test(line.trim()),
|
|
77
|
+
);
|
|
78
|
+
if (headingIndex === -1) return `${note}\n\n${report}`;
|
|
79
|
+
lines.splice(headingIndex + 1, 0, '', note);
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
@@ -134,16 +134,22 @@ export function classifyBranchSeed({ localHas, remoteHas }) {
|
|
|
134
134
|
*
|
|
135
135
|
* Caller-specific log lines and error text are passed in as the `messages`
|
|
136
136
|
* data bag so behaviour stays byte-identical to the pre-extraction switches.
|
|
137
|
-
* The git seams (`spawn`, `existsLocally`, `existsRemotely`) are
|
|
138
|
-
* each caller can bind its own cwd (and tests can
|
|
137
|
+
* The git seams (`spawn`, `existsLocally`, `existsRemotely`) are injectable so
|
|
138
|
+
* each caller can bind its own cwd (and tests can substitute stubs through the
|
|
139
|
+
* parameter rather than by module mocking). Per
|
|
140
|
+
* `.agents/rules/test-seams.md` rule 1 each seam **defaults to the real
|
|
141
|
+
* implementation** bound to `cwd`, so a caller that only knows its checkout
|
|
142
|
+
* passes `cwd` and nothing else; `single-story-init.js` keeps passing its own
|
|
143
|
+
* pre-bound seams and is unaffected.
|
|
139
144
|
*
|
|
140
145
|
* @param {object} opts
|
|
141
146
|
* @param {string} opts.storyBranch
|
|
142
147
|
* @param {string} opts.baseRef Ref to branch from on `create`.
|
|
148
|
+
* @param {string} [opts.cwd] Checkout the default seams bind to.
|
|
143
149
|
* @param {boolean} [opts.swallowCreateRace=false]
|
|
144
|
-
* @param {(args: string[]) => { status: number, stdout?: string, stderr?: string }} opts.spawn
|
|
145
|
-
* @param {(branch: string) => boolean} opts.existsLocally
|
|
146
|
-
* @param {(branch: string) => boolean} opts.existsRemotely
|
|
150
|
+
* @param {(args: string[]) => { status: number, stdout?: string, stderr?: string }} [opts.spawn]
|
|
151
|
+
* @param {(branch: string) => boolean} [opts.existsLocally]
|
|
152
|
+
* @param {(branch: string) => boolean} [opts.existsRemotely]
|
|
147
153
|
* @param {(level: string, message: string) => void} [opts.progress]
|
|
148
154
|
* @param {object} opts.messages
|
|
149
155
|
* @param {(b: string) => string} opts.messages.reuse
|
|
@@ -158,10 +164,11 @@ export function classifyBranchSeed({ localHas, remoteHas }) {
|
|
|
158
164
|
export function seedStoryBranchRef({
|
|
159
165
|
storyBranch,
|
|
160
166
|
baseRef,
|
|
167
|
+
cwd,
|
|
161
168
|
swallowCreateRace = false,
|
|
162
|
-
spawn,
|
|
163
|
-
existsLocally,
|
|
164
|
-
existsRemotely,
|
|
169
|
+
spawn = (args) => gitSpawn(cwd, ...args),
|
|
170
|
+
existsLocally = (branch) => branchExistsLocally(branch, cwd),
|
|
171
|
+
existsRemotely = (branch) => branchExistsViaTrackingRef(branch, cwd),
|
|
165
172
|
progress = () => {},
|
|
166
173
|
messages,
|
|
167
174
|
}) {
|