mandrel 2.24.0 → 2.25.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/agents/acceptance-critic.md +13 -19
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/workflows.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +5 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/scripts/acceptance-eval.js +35 -9
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-test-temp-hygiene.js +83 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +3 -4
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/writer.js +7 -3
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +97 -19
- package/.agents/scripts/lib/crap-utils.js +73 -13
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/label-constants.js +17 -0
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
- package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +50 -13
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
- package/.agents/scripts/lib/workers/crap-worker.js +6 -3
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/quality-preview.js +25 -0
- package/.agents/scripts/run-tests.js +10 -0
- package/.agents/scripts/single-story-close.js +6 -0
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +95 -10
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +81 -95
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/deliver-reference.md +18 -3
- package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +34 -0
- package/.agents/workflows/plan.md +60 -76
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +1 -1
|
@@ -14,10 +14,15 @@ import fs from 'node:fs';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
16
|
import { readBaselineAtRef } from '../../baseline-loader.js';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
COORDINATE_ORIGINAL,
|
|
19
|
+
COORDINATE_TRANSPILED,
|
|
20
|
+
deriveFixGuidance,
|
|
21
|
+
} from '../../crap-engine.js';
|
|
18
22
|
import { getCrapBaseline } from '../../crap-utils.js';
|
|
19
23
|
import { loadBaseline } from '../../gates/baseline-store.js';
|
|
20
24
|
import { Logger } from '../../Logger.js';
|
|
25
|
+
import { resolveTsTranspilerVersion } from '../../transpile.js';
|
|
21
26
|
import {
|
|
22
27
|
kernelDriftAxis,
|
|
23
28
|
missingBaselineAxis,
|
|
@@ -104,19 +109,57 @@ const SCORING_SEMANTICS = 'coverage-join-v2';
|
|
|
104
109
|
* Envelope-level stamps this kind contributes beyond the shared envelope
|
|
105
110
|
* keys. Consumed by `writer.write` via the kind-module protocol.
|
|
106
111
|
*
|
|
107
|
-
*
|
|
112
|
+
* `tsTranspilerVersion` joined the stamp set in Story #4866. A TS row's
|
|
113
|
+
* `startLine` is only an original-source coordinate because a sourcemap said
|
|
114
|
+
* so, and that map is the transpiler's output — so a transpiler change can
|
|
115
|
+
* move every TS row's coordinate, which is half the row identity key. Without
|
|
116
|
+
* the stamp on disk the `ts-transpiler-drift` axis had nothing to compare and
|
|
117
|
+
* passed vacuously.
|
|
118
|
+
*
|
|
119
|
+
* @returns {{scoringSemantics: string, tsTranspilerVersion: string}}
|
|
108
120
|
*/
|
|
109
121
|
export function envelopeExtras() {
|
|
110
|
-
return {
|
|
122
|
+
return {
|
|
123
|
+
scoringSemantics: SCORING_SEMANTICS,
|
|
124
|
+
tsTranspilerVersion: resolveTsTranspilerVersion(),
|
|
125
|
+
};
|
|
111
126
|
}
|
|
112
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Project a scan row onto the persisted baseline row shape.
|
|
130
|
+
*
|
|
131
|
+
* `coordinateSystem` is written **only** when it is not the default
|
|
132
|
+
* `original` (Story #4866). A pure-JavaScript scan therefore emits the exact
|
|
133
|
+
* four-key row it always did — byte-identical baselines, no refresh — while a
|
|
134
|
+
* row that kept transpiled coordinates is distinguishable on disk from one
|
|
135
|
+
* whose sourcemap lookup resolved.
|
|
136
|
+
*/
|
|
113
137
|
export function projectRow(row) {
|
|
114
|
-
|
|
138
|
+
const projected = {
|
|
115
139
|
path: canonicalise(row.path ?? row.file),
|
|
116
140
|
method: row.method,
|
|
117
141
|
startLine: row.startLine,
|
|
118
142
|
crap: row.crap,
|
|
119
143
|
};
|
|
144
|
+
if (row.coordinateSystem === COORDINATE_TRANSPILED) {
|
|
145
|
+
projected.coordinateSystem = COORDINATE_TRANSPILED;
|
|
146
|
+
}
|
|
147
|
+
return projected;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Read a row's coordinate provenance, defaulting to `original`.
|
|
152
|
+
*
|
|
153
|
+
* A baseline written before Story #4866 carries no stamp at all, and every
|
|
154
|
+
* such row IS an original-source coordinate — un-remapped rows could not
|
|
155
|
+
* reach a `requireCoverage: true` baseline, and the pure-JavaScript case was
|
|
156
|
+
* never affected. Defaulting is therefore back-compatible, not a guess.
|
|
157
|
+
*
|
|
158
|
+
* @param {{coordinateSystem?: string}|null|undefined} row
|
|
159
|
+
* @returns {string}
|
|
160
|
+
*/
|
|
161
|
+
function coordinateSystemOf(row) {
|
|
162
|
+
return row?.coordinateSystem ?? COORDINATE_ORIGINAL;
|
|
120
163
|
}
|
|
121
164
|
|
|
122
165
|
export function sortRows(rows) {
|
|
@@ -273,18 +316,47 @@ export function checkCrapRegression(row, baseline, tolerance, kind) {
|
|
|
273
316
|
|
|
274
317
|
/**
|
|
275
318
|
* Pure comparator. Given scanned `currentRows` and committed
|
|
276
|
-
* `baselineRows`, produce a structured verdict covering all
|
|
319
|
+
* `baselineRows`, produce a structured verdict covering all five match
|
|
277
320
|
* paths:
|
|
278
321
|
*
|
|
279
|
-
* 1. **exact**
|
|
280
|
-
*
|
|
281
|
-
* 2. **drifted**
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
* 3. **
|
|
286
|
-
*
|
|
287
|
-
*
|
|
322
|
+
* 1. **exact** — same (file, method, startLine). Regresses if
|
|
323
|
+
* current crap > baseline crap + tolerance.
|
|
324
|
+
* 2. **drifted** — same (file, method) but startLine shifted. Uses
|
|
325
|
+
* the closest line-drifted baseline row under the
|
|
326
|
+
* same no-regression rule. A drift without
|
|
327
|
+
* regression is reported informationally.
|
|
328
|
+
* 3. **incomparable** — same (file, method), but the two rows express
|
|
329
|
+
* their startLine in DIFFERENT coordinate systems
|
|
330
|
+
* (Story #4866). Reported, never scored.
|
|
331
|
+
* 4. **new** — no baseline match. Violates if crap > ceiling.
|
|
332
|
+
* 5. **removed** — baseline rows not seen in the current scan.
|
|
333
|
+
* Surfaced only; never a failure.
|
|
334
|
+
*
|
|
335
|
+
* **Why the incomparable bucket exists.** The drift heuristic assumes both
|
|
336
|
+
* rows measure the same axis and the method simply moved along it. When one
|
|
337
|
+
* row's line is an original-source coordinate and the other's is a transpiled
|
|
338
|
+
* one, the "distance" it minimises over is the gap between two coordinate
|
|
339
|
+
* systems, not a code movement — so it pairs rows arbitrarily and then scores
|
|
340
|
+
* the pairing. That is precisely how a scan that changed nothing reports a
|
|
341
|
+
* regression no edit can satisfy. Refusing is the only sound answer: a row
|
|
342
|
+
* whose provenance differs from every candidate's is counted and surfaced,
|
|
343
|
+
* and it does NOT fall through to the new-method arm (it is not new; it is
|
|
344
|
+
* unmeasurable against this baseline).
|
|
345
|
+
*
|
|
346
|
+
* **`provenanceMismatched` is captured BEFORE the provenance filter runs**
|
|
347
|
+
* (Story #4871). `incomparable` only counts rows where *every* candidate
|
|
348
|
+
* disagreed on coordinates; a row with one agreeing candidate and three
|
|
349
|
+
* disagreeing ones scores normally and leaves no trace. That made the
|
|
350
|
+
* evidence of coordinate mixing strictly narrower than the mixing itself —
|
|
351
|
+
* and the unsound-basis backstop, which needs exactly that evidence, had
|
|
352
|
+
* nothing sound to read. The counter below is incremented for **any** row
|
|
353
|
+
* with at least one provenance-mismatched candidate, whichever arm then
|
|
354
|
+
* resolves it.
|
|
355
|
+
*
|
|
356
|
+
* **Unscorable rows never reach an arm.** A row the scan could not score
|
|
357
|
+
* (`crap: null` / `coverage: null`, or an explicit `unscorable: true`) carries
|
|
358
|
+
* no measurement to compare. It is bucketed and counted, and it is excluded
|
|
359
|
+
* from `comparable` so it cannot dilute any ratio derived from this result.
|
|
288
360
|
*/
|
|
289
361
|
export function compareCrap({
|
|
290
362
|
currentRows,
|
|
@@ -303,15 +375,29 @@ export function compareCrap({
|
|
|
303
375
|
const seenBaselineKeys = new Set();
|
|
304
376
|
|
|
305
377
|
const violations = [];
|
|
378
|
+
const incomparableRows = [];
|
|
379
|
+
const unscorableRows = [];
|
|
306
380
|
let regressions = 0;
|
|
307
381
|
let newViolations = 0;
|
|
308
382
|
let drifted = 0;
|
|
383
|
+
let provenanceMismatched = 0;
|
|
309
384
|
|
|
310
385
|
for (const row of currentRows ?? []) {
|
|
386
|
+
if (isUnscorableRow(row)) {
|
|
387
|
+
unscorableRows.push({ ...row, kind: 'unscorable' });
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
311
390
|
const exactKey = `${row.file}::${row.method}@${row.startLine}`;
|
|
312
391
|
const methodKey = `${row.file}::${row.method}`;
|
|
392
|
+
const rowCoords = coordinateSystemOf(row);
|
|
393
|
+
const candidates = methodIndex.get(methodKey) ?? [];
|
|
394
|
+
// Evidence first, filtering second — see the block comment above.
|
|
395
|
+
if (candidates.some((c) => coordinateSystemOf(c) !== rowCoords)) {
|
|
396
|
+
provenanceMismatched += 1;
|
|
397
|
+
}
|
|
398
|
+
|
|
313
399
|
const exact = exactIndex.get(exactKey);
|
|
314
|
-
if (exact) {
|
|
400
|
+
if (exact && coordinateSystemOf(exact) === rowCoords) {
|
|
315
401
|
seenBaselineKeys.add(exactKey);
|
|
316
402
|
const v = checkCrapRegression(row, exact, tolerance, 'regression');
|
|
317
403
|
if (v) {
|
|
@@ -321,22 +407,26 @@ export function compareCrap({
|
|
|
321
407
|
continue;
|
|
322
408
|
}
|
|
323
409
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
410
|
+
if (candidates.length > 0) {
|
|
411
|
+
// Only rows expressed in the SAME coordinate system are comparable;
|
|
412
|
+
// everything else would be resolved through a line-distance heuristic
|
|
413
|
+
// that cannot mean anything across two coordinate systems.
|
|
414
|
+
const comparable = candidates.filter(
|
|
415
|
+
(c) => coordinateSystemOf(c) === rowCoords,
|
|
416
|
+
);
|
|
417
|
+
if (comparable.length === 0) {
|
|
418
|
+
incomparableRows.push({
|
|
419
|
+
...row,
|
|
420
|
+
kind: 'incomparable',
|
|
421
|
+
coordinateSystem: rowCoords,
|
|
422
|
+
baselineCoordinateSystem: coordinateSystemOf(candidates[0]),
|
|
423
|
+
});
|
|
424
|
+
for (const c of candidates) {
|
|
425
|
+
seenBaselineKeys.add(`${c.file}::${c.method}@${c.startLine}`);
|
|
337
426
|
}
|
|
427
|
+
continue;
|
|
338
428
|
}
|
|
339
|
-
|
|
429
|
+
const pick = pickDriftCandidate(comparable, row, seenBaselineKeys);
|
|
340
430
|
seenBaselineKeys.add(`${pick.file}::${pick.method}@${pick.startLine}`);
|
|
341
431
|
drifted += 1;
|
|
342
432
|
const v = checkCrapRegression(row, pick, tolerance, 'drifted-regression');
|
|
@@ -364,17 +454,170 @@ export function compareCrap({
|
|
|
364
454
|
if (!seenBaselineKeys.has(k)) removedRows.push(b);
|
|
365
455
|
}
|
|
366
456
|
|
|
457
|
+
const total = currentRows?.length ?? 0;
|
|
367
458
|
return {
|
|
368
|
-
total
|
|
459
|
+
total,
|
|
460
|
+
// Rows that carried a measurement and therefore *could* be compared. The
|
|
461
|
+
// denominator of every ratio derived from this result.
|
|
462
|
+
comparable: total - unscorableRows.length,
|
|
369
463
|
regressions,
|
|
370
464
|
newViolations,
|
|
371
465
|
drifted,
|
|
466
|
+
provenanceMismatched,
|
|
467
|
+
incomparable: incomparableRows.length,
|
|
468
|
+
unscorable: unscorableRows.length,
|
|
372
469
|
removed: removedRows.length,
|
|
373
470
|
violations,
|
|
471
|
+
incomparableRows,
|
|
472
|
+
unscorableRows,
|
|
374
473
|
removedRows,
|
|
375
474
|
};
|
|
376
475
|
}
|
|
377
476
|
|
|
477
|
+
/**
|
|
478
|
+
* True when a scanned row carries no measurement to compare.
|
|
479
|
+
*
|
|
480
|
+
* Deliberately strict on `null`: a scan row always sets `crap`, and the
|
|
481
|
+
* scorer's own contract is that an unresolved method yields `crap: null` /
|
|
482
|
+
* `coverage: null` rather than an inferred zero. A caller-built row that
|
|
483
|
+
* simply omits `coverage` is not making that claim and stays scorable.
|
|
484
|
+
*
|
|
485
|
+
* @param {{crap?: number|null, coverage?: number|null, unscorable?: boolean}} row
|
|
486
|
+
* @returns {boolean}
|
|
487
|
+
*/
|
|
488
|
+
function isUnscorableRow(row) {
|
|
489
|
+
return (
|
|
490
|
+
row?.unscorable === true || row?.crap === null || row?.coverage === null
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Pick the baseline row a drifted method should be scored against: the
|
|
496
|
+
* closest un-seen candidate by `startLine` distance, falling back to the
|
|
497
|
+
* first when every candidate has already been claimed (duplicate method
|
|
498
|
+
* names in one file).
|
|
499
|
+
*
|
|
500
|
+
* @param {Array<{file: string, method: string, startLine: number}>} comparable
|
|
501
|
+
* @param {{startLine: number}} row
|
|
502
|
+
* @param {Set<string>} seenBaselineKeys
|
|
503
|
+
* @returns {object}
|
|
504
|
+
*/
|
|
505
|
+
function pickDriftCandidate(comparable, row, seenBaselineKeys) {
|
|
506
|
+
let pick = null;
|
|
507
|
+
let bestDist = Number.POSITIVE_INFINITY;
|
|
508
|
+
for (const c of comparable) {
|
|
509
|
+
const k = `${c.file}::${c.method}@${c.startLine}`;
|
|
510
|
+
if (seenBaselineKeys.has(k)) continue;
|
|
511
|
+
const d = Math.abs(c.startLine - row.startLine);
|
|
512
|
+
if (d < bestDist) {
|
|
513
|
+
bestDist = d;
|
|
514
|
+
pick = c;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return pick ?? comparable[0];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Fraction of a compare's comparable rows that hit a coordinate-provenance
|
|
522
|
+
* mismatch before the comparison basis is judged self-evidently unsound
|
|
523
|
+
* (Story #4866, numerator corrected in Story #4871).
|
|
524
|
+
*
|
|
525
|
+
* **What this ratio must measure.** The backstop exists for one condition: the
|
|
526
|
+
* scan and the baseline expressing `startLine` in different coordinate
|
|
527
|
+
* systems, which makes the nearest-line drift heuristic pair rows arbitrarily
|
|
528
|
+
* and then score the pairing. The only quantity that evidences that condition
|
|
529
|
+
* is a provenance disagreement between a row and its baseline candidates.
|
|
530
|
+
*
|
|
531
|
+
* **Why `drifted` was the wrong numerator.** A row reaches the drift arm only
|
|
532
|
+
* *after* passing the provenance filter, so every drifted row is one whose
|
|
533
|
+
* coordinate system **agreed** with its baseline's. Counting agreements as
|
|
534
|
+
* evidence of disagreement is not a mis-calibration, it is the wrong
|
|
535
|
+
* measurement: on this pure-JavaScript repository — where a coordinate mix is
|
|
536
|
+
* structurally impossible — it read 56–68% across every diff scope, which
|
|
537
|
+
* suppressed the gate's per-method verdicts on every commit while the gate
|
|
538
|
+
* still reported success. `startLine` is half the row identity key, so any
|
|
539
|
+
* insertion re-keys every method below it; ordinary drift is the normal
|
|
540
|
+
* operating state, not an anomaly.
|
|
541
|
+
*
|
|
542
|
+
* Deliberately module-local, like `SCORING_SEMANTICS` above: the three
|
|
543
|
+
* tuning values below are reachable through `assessComparisonBasis` — which
|
|
544
|
+
* takes them as overridable options — so exporting the bare constants would
|
|
545
|
+
* add entry points nothing in production reaches.
|
|
546
|
+
*/
|
|
547
|
+
const UNSOUND_BASIS_MISMATCH_RATIO = 0.5;
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Minimum comparable rows before the unsound-basis check is allowed to fire.
|
|
551
|
+
* A diff-scoped preview can legitimately score three methods, two of which
|
|
552
|
+
* moved; that is a normal edit, not a broken basis. Mirrors the
|
|
553
|
+
* minimum-sample discipline the coverage-join resolution floor already uses.
|
|
554
|
+
*/
|
|
555
|
+
const UNSOUND_BASIS_MIN_SAMPLE = 20;
|
|
556
|
+
|
|
557
|
+
/** Diagnostic name for an unsound comparison basis. */
|
|
558
|
+
const UNSOUND_BASIS_DIAGNOSTIC = 'crap-unsound-comparison-basis';
|
|
559
|
+
|
|
560
|
+
/** Diagnostic name for a baseline the running scorer refuses to compare. */
|
|
561
|
+
export const INCOMPATIBLE_BASELINE_DIAGNOSTIC = 'crap-baseline-incompatible';
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Pure verdict on whether a `compareCrap` result rests on a sound basis.
|
|
565
|
+
*
|
|
566
|
+
* The numerator is `provenanceMismatched` — rows whose baseline candidates
|
|
567
|
+
* included at least one expressed in a different coordinate system, counted
|
|
568
|
+
* by `compareCrap` **before** its provenance filter discards that evidence.
|
|
569
|
+
* The denominator is `comparable`: rows that carried a measurement at all, so
|
|
570
|
+
* a method the scan could not score cannot dilute the ratio into silence.
|
|
571
|
+
*
|
|
572
|
+
* Above the ratio the per-method verdicts are noise derived from a mis-keyed
|
|
573
|
+
* join, and reporting them as regressions asks the operator to fix code that
|
|
574
|
+
* is not broken. Below it — including a scan where every row drifted, which
|
|
575
|
+
* is what an ordinary insertion produces — the verdicts stand.
|
|
576
|
+
*
|
|
577
|
+
* Returns `{ sound: true }` or `{ sound: false, diagnostic: {name, message} }`.
|
|
578
|
+
*
|
|
579
|
+
* @param {{total?: number, comparable?: number, provenanceMismatched?: number,
|
|
580
|
+
* incomparable?: number}} compareResult
|
|
581
|
+
* @param {{ratio?: number, minSample?: number}} [opts]
|
|
582
|
+
*/
|
|
583
|
+
export function assessComparisonBasis(compareResult, opts = {}) {
|
|
584
|
+
const ratio = Number.isFinite(opts.ratio)
|
|
585
|
+
? opts.ratio
|
|
586
|
+
: UNSOUND_BASIS_MISMATCH_RATIO;
|
|
587
|
+
const minSample = Number.isFinite(opts.minSample)
|
|
588
|
+
? opts.minSample
|
|
589
|
+
: UNSOUND_BASIS_MIN_SAMPLE;
|
|
590
|
+
const comparable = compareResult?.comparable ?? compareResult?.total ?? 0;
|
|
591
|
+
if (comparable < minSample) return { sound: true };
|
|
592
|
+
// `incomparable` is the total-mismatch subset of `provenanceMismatched`, so
|
|
593
|
+
// it is already counted; the max guards a caller that supplies only one.
|
|
594
|
+
const mismatched = Math.max(
|
|
595
|
+
compareResult?.provenanceMismatched ?? 0,
|
|
596
|
+
compareResult?.incomparable ?? 0,
|
|
597
|
+
);
|
|
598
|
+
const observed = mismatched / comparable;
|
|
599
|
+
if (observed <= ratio) return { sound: true };
|
|
600
|
+
return {
|
|
601
|
+
sound: false,
|
|
602
|
+
diagnostic: {
|
|
603
|
+
name: UNSOUND_BASIS_DIAGNOSTIC,
|
|
604
|
+
message:
|
|
605
|
+
`[CRAP] ⚠ Comparison basis is unsound: ${mismatched}/${comparable} ` +
|
|
606
|
+
`(${(observed * 100).toFixed(1)}%) of comparable methods matched a ` +
|
|
607
|
+
'baseline row expressed in a DIFFERENT line coordinate system — ' +
|
|
608
|
+
`above the ${(ratio * 100).toFixed(0)}% threshold.\n` +
|
|
609
|
+
' At this ratio the baseline and the scan are not describing ' +
|
|
610
|
+
'the same line coordinates, so every per-method verdict below would ' +
|
|
611
|
+
'be derived from a mis-keyed join rather than from your change. ' +
|
|
612
|
+
'They are suppressed.\n' +
|
|
613
|
+
" Re-seed the baseline: run 'npm run test:coverage' then " +
|
|
614
|
+
"'npm run crap:update -- --full-scope' and commit the result with a " +
|
|
615
|
+
"'baseline-refresh:' subject. The authoritative check-baselines gate " +
|
|
616
|
+
'still gates the merge.',
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
378
621
|
/**
|
|
379
622
|
* Declarative axis table for `evaluateBaselineCompatibility` (Story #2467).
|
|
380
623
|
*
|
|
@@ -426,19 +669,63 @@ export const CRAP_COMPAT_AXES = [
|
|
|
426
669
|
},
|
|
427
670
|
{
|
|
428
671
|
name: 'ts-transpiler-drift',
|
|
429
|
-
severity: '
|
|
672
|
+
severity: 'fatal',
|
|
430
673
|
check: ({ baseline, runningTsTranspilerVersion }) => {
|
|
431
|
-
if (!baseline
|
|
432
|
-
|
|
674
|
+
if (!baseline) return null;
|
|
675
|
+
if (!isKnownVersion(runningTsTranspilerVersion)) return null;
|
|
676
|
+
const baselineTs = baseline.tsTranspilerVersion;
|
|
677
|
+
// An unstamped (or sentinel) baseline has nothing to compare — the
|
|
678
|
+
// stamp landed in Story #4866 and comparing against its absence would
|
|
679
|
+
// fail every pre-existing baseline closed for no evidence at all.
|
|
680
|
+
if (!isKnownVersion(baselineTs)) return null;
|
|
433
681
|
if (baselineTs === runningTsTranspilerVersion) return null;
|
|
682
|
+
// Scoped to baselines that actually contain transpiled sources: a
|
|
683
|
+
// transpiler change moves TS row coordinates and nothing else. A
|
|
684
|
+
// pure-JavaScript tree has no coordinate to move, so a TS bump there is
|
|
685
|
+
// not a coordinate-invalidating event and must not fail its gate.
|
|
686
|
+
if (!hasTranspiledRows(baseline)) return null;
|
|
434
687
|
return (
|
|
435
|
-
`[CRAP]
|
|
436
|
-
"
|
|
688
|
+
`[CRAP] tsTranspilerVersion changed: baseline=${baselineTs} running=${runningTsTranspilerVersion}. ` +
|
|
689
|
+
"A TS row's startLine is an original-source coordinate only because the transpiler's " +
|
|
690
|
+
'sourcemap said so, and that coordinate is half the row identity key — so rows scored ' +
|
|
691
|
+
'under the previous transpiler are not comparable to rows scored under this one. ' +
|
|
692
|
+
"Re-derive the baseline: run 'npm run test:coverage' then " +
|
|
693
|
+
"'npm run crap:update -- --full-scope' and commit the result with a " +
|
|
694
|
+
"'baseline-refresh:' subject."
|
|
437
695
|
);
|
|
438
696
|
},
|
|
439
697
|
},
|
|
440
698
|
];
|
|
441
699
|
|
|
700
|
+
/** Sources whose escomplex coordinates are transpiled, not original. */
|
|
701
|
+
const TRANSPILED_SOURCE_RE = /\.(?:ts|tsx|mts|cts)$/i;
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* `'0.0.0'` is this codebase's established "unknown environment" sentinel for
|
|
705
|
+
* a resolved dependency version, not a real release. Treating it as a
|
|
706
|
+
* comparable value would turn "we could not resolve typescript" into
|
|
707
|
+
* "typescript changed".
|
|
708
|
+
*
|
|
709
|
+
* @param {unknown} version
|
|
710
|
+
* @returns {boolean}
|
|
711
|
+
*/
|
|
712
|
+
function isKnownVersion(version) {
|
|
713
|
+
return typeof version === 'string' && version !== '' && version !== '0.0.0';
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* True when a baseline contains at least one row derived from a transpiled
|
|
718
|
+
* source — the only rows a transpiler-version change can move.
|
|
719
|
+
*
|
|
720
|
+
* @param {{rows?: Array<{path?: string, file?: string}>}|null} baseline
|
|
721
|
+
* @returns {boolean}
|
|
722
|
+
*/
|
|
723
|
+
function hasTranspiledRows(baseline) {
|
|
724
|
+
return (baseline?.rows ?? []).some((row) =>
|
|
725
|
+
TRANSPILED_SOURCE_RE.test(String(row?.path ?? row?.file ?? '')),
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
|
|
442
729
|
/**
|
|
443
730
|
* Pure decision helper for the missing-baseline / kernel-mismatch /
|
|
444
731
|
* escomplex-mismatch / tsTranspiler-mismatch gate paths. Lets tests
|
|
@@ -454,27 +741,45 @@ export function evaluateBaselineCompatibility(ctx) {
|
|
|
454
741
|
}
|
|
455
742
|
|
|
456
743
|
/**
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
744
|
+
* The compat axes a *loaded* envelope can be judged against on its own,
|
|
745
|
+
* without a second baseline to diff. Both are coordinate-invalidating: one
|
|
746
|
+
* names the join that produced the rows, the other names the transpiler whose
|
|
747
|
+
* sourcemap decided what a TS row's `startLine` even means.
|
|
748
|
+
*
|
|
749
|
+
* `escomplex-mismatch` and `kernel-drift` stay out — the v2 envelope carries
|
|
750
|
+
* no `escomplexVersion`, so that axis would compare `undefined` to `undefined`
|
|
751
|
+
* and pass vacuously, which is worse than not running it.
|
|
752
|
+
*/
|
|
753
|
+
const LOADED_ENVELOPE_AXES = ['scoring-semantics-drift', 'ts-transpiler-drift'];
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Kind-module hook (Story #4775, extended by Story #4866): judge a *loaded*
|
|
757
|
+
* v2 envelope against the axes that need no peer baseline. The unified
|
|
758
|
+
* `check-baselines` gate calls it straight after `reader.load` and turns a
|
|
759
|
+
* message into a fail-closed schema-class error, so a baseline written by
|
|
760
|
+
* incompatible scoring semantics — or by a different transpiler, which moves
|
|
761
|
+
* the coordinates that are half the row identity key — can never be silently
|
|
762
|
+
* compared against current scores.
|
|
763
|
+
*
|
|
764
|
+
* This is the *production* door for `ts-transpiler-drift`. Before #4866 the
|
|
765
|
+
* axis was reachable only from its own unit test: nothing in production called
|
|
766
|
+
* `evaluateBaselineCompatibility`, and this function deliberately excluded it.
|
|
468
767
|
*
|
|
469
768
|
* @param {object|null} baseline A loaded v2 baseline envelope.
|
|
769
|
+
* @param {{runningTsTranspilerVersion?: string}} [ctx] Injectable running
|
|
770
|
+
* versions; resolved from the environment when omitted.
|
|
470
771
|
* @returns {string|null} Operator-facing message, or null when compatible.
|
|
471
772
|
*/
|
|
472
|
-
export function assertBaselineCompatible(baseline) {
|
|
773
|
+
export function assertBaselineCompatible(baseline, ctx = {}) {
|
|
473
774
|
if (!baseline) return null;
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
)
|
|
477
|
-
|
|
775
|
+
const runningTsTranspilerVersion =
|
|
776
|
+
ctx.runningTsTranspilerVersion ?? resolveTsTranspilerVersion();
|
|
777
|
+
for (const name of LOADED_ENVELOPE_AXES) {
|
|
778
|
+
const axis = CRAP_COMPAT_AXES.find((a) => a.name === name);
|
|
779
|
+
const message = axis?.check({ baseline, runningTsTranspilerVersion });
|
|
780
|
+
if (message) return message;
|
|
781
|
+
}
|
|
782
|
+
return null;
|
|
478
783
|
}
|
|
479
784
|
|
|
480
785
|
/**
|
|
@@ -571,6 +876,17 @@ export function buildCrapReport({
|
|
|
571
876
|
regressions: compareResult.regressions,
|
|
572
877
|
newViolations: compareResult.newViolations,
|
|
573
878
|
drifted: compareResult.drifted,
|
|
879
|
+
// Story #4866: rows the compare refused to resolve because their
|
|
880
|
+
// coordinate provenance differs from the baseline's.
|
|
881
|
+
incomparable: compareResult.incomparable ?? 0,
|
|
882
|
+
// Story #4871: rows whose baseline candidates included at least one in a
|
|
883
|
+
// different coordinate system — the evidence the unsound-basis backstop
|
|
884
|
+
// reads, captured before the provenance filter discards it.
|
|
885
|
+
provenanceMismatched: compareResult.provenanceMismatched ?? 0,
|
|
886
|
+
// Story #4871: methods carrying no measurement to compare — the scan
|
|
887
|
+
// found no coverage artifact for them. Reported, never scored from an
|
|
888
|
+
// assumed zero, and never part of a ratio's denominator.
|
|
889
|
+
unscorable: (compareResult.unscorable ?? 0) + skippedNoCoverage,
|
|
574
890
|
removed: compareResult.removed,
|
|
575
891
|
skippedNoCoverage,
|
|
576
892
|
scope,
|
|
@@ -580,6 +896,33 @@ export function buildCrapReport({
|
|
|
580
896
|
};
|
|
581
897
|
}
|
|
582
898
|
|
|
899
|
+
/**
|
|
900
|
+
* Rebuild a CRAP envelope with its per-method verdicts suppressed and one
|
|
901
|
+
* named diagnostic in their place (Story #4866).
|
|
902
|
+
*
|
|
903
|
+
* Used by the preview gate on the two conditions under which a per-method
|
|
904
|
+
* verdict cannot mean anything: a baseline the running scorer refuses to
|
|
905
|
+
* compare, and a comparison basis whose drifted-row ratio proves the two
|
|
906
|
+
* sides disagree on coordinates. The counts stay so the operator can see the
|
|
907
|
+
* evidence; only the accusations go.
|
|
908
|
+
*
|
|
909
|
+
* @param {object} envelope
|
|
910
|
+
* @param {{name: string, message: string}} diagnostic
|
|
911
|
+
* @returns {object}
|
|
912
|
+
*/
|
|
913
|
+
export function suppressVerdicts(envelope, diagnostic) {
|
|
914
|
+
return {
|
|
915
|
+
...envelope,
|
|
916
|
+
summary: {
|
|
917
|
+
...envelope.summary,
|
|
918
|
+
regressions: 0,
|
|
919
|
+
newViolations: 0,
|
|
920
|
+
},
|
|
921
|
+
violations: [],
|
|
922
|
+
diagnostics: [diagnostic],
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
583
926
|
/**
|
|
584
927
|
* Logger-only printers hoisted from `check-crap.js`. Kept here so the
|
|
585
928
|
* CLI shell stays thin and the printers can be exercised in unit tests
|
|
@@ -591,6 +934,8 @@ export function printSummaryHeader(result, scanSummary) {
|
|
|
591
934
|
Logger.info(`Regressions: ${result.regressions}`);
|
|
592
935
|
Logger.info(`New-method violations: ${result.newViolations}`);
|
|
593
936
|
Logger.info(`Drifted (matched): ${result.drifted}`);
|
|
937
|
+
Logger.info(`Provenance mismatched: ${result.provenanceMismatched ?? 0}`);
|
|
938
|
+
Logger.info(`Unscorable (no cov): ${result.unscorable ?? 0}`);
|
|
594
939
|
Logger.info(`Removed from baseline: ${result.removed}`);
|
|
595
940
|
if (scanSummary?.skippedFilesNoCoverage) {
|
|
596
941
|
Logger.info(
|
|
@@ -30,10 +30,14 @@ import {
|
|
|
30
30
|
import { calculateAll, scanDirectory } from '../maintainability-utils.js';
|
|
31
31
|
import { resolveCrapEnvOverrides } from './env-overrides.js';
|
|
32
32
|
import {
|
|
33
|
+
assertBaselineCompatible,
|
|
34
|
+
assessComparisonBasis,
|
|
33
35
|
buildCrapReport,
|
|
34
36
|
compareCrap,
|
|
35
37
|
filterRowsByFileScope,
|
|
38
|
+
INCOMPATIBLE_BASELINE_DIAGNOSTIC,
|
|
36
39
|
loadCrapBaseline,
|
|
40
|
+
suppressVerdicts,
|
|
37
41
|
} from './kinds/crap.js';
|
|
38
42
|
import {
|
|
39
43
|
buildMaintainabilityReport,
|
|
@@ -131,6 +135,34 @@ function hasCrapRegressions(result) {
|
|
|
131
135
|
return result.regressions > 0 || result.newViolations > 0;
|
|
132
136
|
}
|
|
133
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Build the zero-row CRAP envelope the preview returns when it has nothing to
|
|
140
|
+
* compare against — no baseline, or the gate disabled.
|
|
141
|
+
*
|
|
142
|
+
* @param {{scope: string, diffRef: string|null}} scopeInfo
|
|
143
|
+
* @returns {object}
|
|
144
|
+
*/
|
|
145
|
+
function emptyCrapEnvelope({ scope, diffRef }) {
|
|
146
|
+
return {
|
|
147
|
+
kernelVersion: KERNEL_VERSION,
|
|
148
|
+
escomplexVersion: resolveEscomplexVersion(),
|
|
149
|
+
summary: {
|
|
150
|
+
total: 0,
|
|
151
|
+
regressions: 0,
|
|
152
|
+
newViolations: 0,
|
|
153
|
+
drifted: 0,
|
|
154
|
+
incomparable: 0,
|
|
155
|
+
provenanceMismatched: 0,
|
|
156
|
+
unscorable: 0,
|
|
157
|
+
removed: 0,
|
|
158
|
+
skippedNoCoverage: 0,
|
|
159
|
+
scope,
|
|
160
|
+
diffRef,
|
|
161
|
+
},
|
|
162
|
+
violations: [],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
134
166
|
function applyDiffScopeMi({ files, baseline, scopeSet, cwd }) {
|
|
135
167
|
if (!scopeSet) {
|
|
136
168
|
return { scopedFiles: files, scopedBaseline: baseline ?? {} };
|
|
@@ -236,23 +268,24 @@ export async function runCrapPreview({
|
|
|
236
268
|
const quality = getQuality(config);
|
|
237
269
|
const crap = quality.crap;
|
|
238
270
|
if (!baseline || crap.enabled === false) {
|
|
271
|
+
return { exitCode: 0, envelope: emptyCrapEnvelope({ scope, diffRef }) };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Story #4866 (AC-6): judge the loaded envelope BEFORE comparing anything
|
|
275
|
+
// against it. A baseline whose scoring semantics or transpiler coordinates
|
|
276
|
+
// predate the running scorer cannot yield a meaningful per-method verdict,
|
|
277
|
+
// and emitting regressions from it asks the operator to fix code that is
|
|
278
|
+
// not broken. Fail open — the authoritative `check-baselines` gate still
|
|
279
|
+
// gates the merge, so a permissive pre-commit hook costs no real coverage
|
|
280
|
+
// while a blocking one costs a provably defect-free commit.
|
|
281
|
+
const incompatible = assertBaselineCompatible(baseline);
|
|
282
|
+
if (incompatible) {
|
|
239
283
|
return {
|
|
240
284
|
exitCode: 0,
|
|
241
|
-
envelope: {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
total: 0,
|
|
246
|
-
regressions: 0,
|
|
247
|
-
newViolations: 0,
|
|
248
|
-
drifted: 0,
|
|
249
|
-
removed: 0,
|
|
250
|
-
skippedNoCoverage: 0,
|
|
251
|
-
scope,
|
|
252
|
-
diffRef,
|
|
253
|
-
},
|
|
254
|
-
violations: [],
|
|
255
|
-
},
|
|
285
|
+
envelope: suppressVerdicts(emptyCrapEnvelope({ scope, diffRef }), {
|
|
286
|
+
name: INCOMPATIBLE_BASELINE_DIAGNOSTIC,
|
|
287
|
+
message: incompatible,
|
|
288
|
+
}),
|
|
256
289
|
};
|
|
257
290
|
}
|
|
258
291
|
|
|
@@ -298,6 +331,16 @@ export async function runCrapPreview({
|
|
|
298
331
|
diffRef,
|
|
299
332
|
},
|
|
300
333
|
});
|
|
334
|
+
// Story #4866 (AC-5): above the drifted-row ratio the basis is self-
|
|
335
|
+
// evidently unsound and every per-method verdict below it is an artefact of
|
|
336
|
+
// a mis-keyed join. Say so once, by name, and fail open.
|
|
337
|
+
const basis = assessComparisonBasis(result);
|
|
338
|
+
if (!basis.sound) {
|
|
339
|
+
return {
|
|
340
|
+
exitCode: 0,
|
|
341
|
+
envelope: suppressVerdicts(envelope, basis.diagnostic),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
301
344
|
const exitCode = hasCrapRegressions(result) ? 1 : 0;
|
|
302
345
|
return { exitCode, envelope };
|
|
303
346
|
}
|