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.
Files changed (88) hide show
  1. package/.agents/agents/acceptance-critic.md +13 -19
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +2 -1
  7. package/.agents/docs/configuration.md +2 -1
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/rules/ci-remediation.md +68 -3
  10. package/.agents/schemas/agentrc.schema.json +6 -1
  11. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  12. package/.agents/schemas/baselines/crap.schema.json +5 -1
  13. package/.agents/schemas/crap-report.schema.json +37 -0
  14. package/.agents/scripts/acceptance-eval.js +35 -9
  15. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  16. package/.agents/scripts/audit-to-stories.js +33 -3
  17. package/.agents/scripts/check-context-budget.js +95 -14
  18. package/.agents/scripts/check-test-temp-hygiene.js +83 -0
  19. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  20. package/.agents/scripts/deliver-light.js +3 -4
  21. package/.agents/scripts/deliver-recover.js +13 -0
  22. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  23. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  24. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  25. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  26. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  27. package/.agents/scripts/lib/baseline-loader.js +0 -0
  28. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  29. package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
  30. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  31. package/.agents/scripts/lib/baselines/reader.js +10 -0
  32. package/.agents/scripts/lib/baselines/writer.js +7 -3
  33. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  34. package/.agents/scripts/lib/crap-engine.js +97 -19
  35. package/.agents/scripts/lib/crap-utils.js +73 -13
  36. package/.agents/scripts/lib/env-loader.js +46 -16
  37. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  38. package/.agents/scripts/lib/findings/severity.js +80 -2
  39. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  40. package/.agents/scripts/lib/label-constants.js +17 -0
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  42. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  43. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  44. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  45. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  46. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  47. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  48. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
  49. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
  50. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  51. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  52. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  53. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  55. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +50 -13
  56. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  57. package/.agents/scripts/lib/story-plan.js +137 -42
  58. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  59. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  60. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  61. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  62. package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
  63. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
  64. package/.agents/scripts/lib/workers/crap-worker.js +6 -3
  65. package/.agents/scripts/pr-watch-with-update.js +568 -213
  66. package/.agents/scripts/quality-preview.js +25 -0
  67. package/.agents/scripts/run-tests.js +10 -0
  68. package/.agents/scripts/single-story-close.js +6 -0
  69. package/.agents/scripts/single-story-init.js +7 -0
  70. package/.agents/scripts/stories-wave-tick.js +95 -10
  71. package/.agents/scripts/story-plan.js +65 -9
  72. package/.agents/scripts/update-crap-baseline.js +42 -4
  73. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  74. package/.agents/templates/single-story-body.md +16 -8
  75. package/.agents/workflows/audit-architecture.md +69 -13
  76. package/.agents/workflows/audit-quality.md +26 -0
  77. package/.agents/workflows/audit-to-stories.md +30 -4
  78. package/.agents/workflows/deliver.md +81 -95
  79. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  80. package/.agents/workflows/helpers/deliver-reference.md +18 -3
  81. package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
  82. package/.agents/workflows/helpers/deliver-story.md +104 -119
  83. package/.agents/workflows/helpers/plan-reference.md +34 -0
  84. package/.agents/workflows/plan.md +60 -76
  85. package/docs/CHANGELOG.md +29 -0
  86. package/lib/cli/update.js +14 -11
  87. package/lib/cli/version-check.js +9 -1
  88. 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 { deriveFixGuidance } from '../../crap-engine.js';
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
- * @returns {{scoringSemantics: string}}
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 { scoringSemantics: SCORING_SEMANTICS };
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
- return {
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 four match
319
+ * `baselineRows`, produce a structured verdict covering all five match
277
320
  * paths:
278
321
  *
279
- * 1. **exact** — same (file, method, startLine). Regresses if
280
- * current crap > baseline crap + tolerance.
281
- * 2. **drifted** — same (file, method) but startLine shifted. Uses
282
- * the closest line-drifted baseline row under the
283
- * same no-regression rule. A drift without
284
- * regression is reported informationally.
285
- * 3. **new** no baseline match. Violates if crap > ceiling.
286
- * 4. **removed** — baseline rows not seen in the current scan.
287
- * Surfaced only; never a failure.
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
- const candidates = methodIndex.get(methodKey);
325
- if (Array.isArray(candidates) && candidates.length > 0) {
326
- // Pick the closest un-seen candidate by startLine distance; fall back
327
- // to the first one if all have been seen (duplicate method names).
328
- let pick = null;
329
- let bestDist = Number.POSITIVE_INFINITY;
330
- for (const c of candidates) {
331
- const k = `${c.file}::${c.method}@${c.startLine}`;
332
- if (seenBaselineKeys.has(k)) continue;
333
- const d = Math.abs(c.startLine - row.startLine);
334
- if (d < bestDist) {
335
- bestDist = d;
336
- pick = c;
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
- if (!pick) pick = candidates[0];
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: currentRows?.length ?? 0,
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: 'warn',
672
+ severity: 'fatal',
430
673
  check: ({ baseline, runningTsTranspilerVersion }) => {
431
- if (!baseline || !runningTsTranspilerVersion) return null;
432
- const baselineTs = baseline.tsTranspilerVersion ?? '0.0.0';
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] tsTranspilerVersion drift: baseline=${baselineTs} running=${runningTsTranspilerVersion}. ` +
436
- "Run 'npm run crap:update' and commit with a 'baseline-refresh:' subject to refresh."
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
- * Kind-module hook (Story #4775): the subset of the compat table that a
458
- * *loaded* v2 envelope can be judged against on its own, with no running
459
- * dependency versions to compare. The unified `check-baselines` gate calls it
460
- * straight after `reader.load` and turns a message into a fail-closed
461
- * schema-class error, so a baseline written by the previous scoring semantics
462
- * can never be silently compared against new-semantics scores.
463
- *
464
- * The version-drift axes stay out: the v2 envelope does not carry
465
- * `escomplexVersion` / `tsTranspilerVersion`, and running those checks against
466
- * an absent field would compare `undefined` to `undefined` and pass
467
- * vacuously — worse than not running them.
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 axis = CRAP_COMPAT_AXES.find(
475
- (a) => a.name === 'scoring-semantics-drift',
476
- );
477
- return axis ? axis.check({ baseline }) : null;
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
- kernelVersion: KERNEL_VERSION,
243
- escomplexVersion: resolveEscomplexVersion(),
244
- summary: {
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
  }