mandrel 2.24.0 → 2.26.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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  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/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -14,10 +14,16 @@ 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';
22
+ import { isAnonymousMethodLabel } from '../../crap-method-identity.js';
18
23
  import { getCrapBaseline } from '../../crap-utils.js';
19
24
  import { loadBaseline } from '../../gates/baseline-store.js';
20
25
  import { Logger } from '../../Logger.js';
26
+ import { resolveTsTranspilerVersion } from '../../transpile.js';
21
27
  import {
22
28
  kernelDriftAxis,
23
29
  missingBaselineAxis,
@@ -90,33 +96,95 @@ export function kernelVersion() {
90
96
  * worse, phantom passes.
91
97
  *
92
98
  * The stamp makes the boundary explicit and fails closed. Bump it whenever
93
- * the coverage join, the line coordinate system, or the unresolved-method
94
- * policy changes.
99
+ * the coverage join, the line coordinate system, the unresolved-method policy,
100
+ * or the **method identity rule** changes.
101
+ *
102
+ * Story #4969 bumped it to `method-identity-v3` for the last of those. An
103
+ * anonymous method used to be keyed by escomplex's `<anon method-N>` ordinal
104
+ * and is now keyed by its enclosing-scope path; 34.6% of rows changed identity
105
+ * in one step. Rows keyed the old way and rows keyed the new way describe the
106
+ * same functions under different names, so pairing them is exactly the
107
+ * mis-keyed join this stamp exists to refuse — hence the bump, which is what
108
+ * makes the migration report nothing rather than a wall of phantom verdicts.
95
109
  *
96
110
  * Deliberately module-local: `envelopeExtras()` is the single production door
97
111
  * to this value, so exporting the bare constant would add a second entry
98
112
  * point that nothing in production reaches. Callers and tests that need the
99
113
  * string read it off `envelopeExtras().scoringSemantics`.
100
114
  */
101
- const SCORING_SEMANTICS = 'coverage-join-v2';
115
+ const SCORING_SEMANTICS = 'method-identity-v3';
102
116
 
103
117
  /**
104
118
  * Envelope-level stamps this kind contributes beyond the shared envelope
105
119
  * keys. Consumed by `writer.write` via the kind-module protocol.
106
120
  *
107
- * @returns {{scoringSemantics: string}}
121
+ * `tsTranspilerVersion` joined the stamp set in Story #4866. A TS row's
122
+ * `startLine` is only an original-source coordinate because a sourcemap said
123
+ * so, and that map is the transpiler's output — so a transpiler change can
124
+ * move every TS row's coordinate, which is half the row identity key. Without
125
+ * the stamp on disk the `ts-transpiler-drift` axis had nothing to compare and
126
+ * passed vacuously.
127
+ *
128
+ * `provenanceStamped` joined in Story #4901 as a **positive** marker: the
129
+ * writer asserting it recorded per-row provenance at all. Absence is the only
130
+ * evidence a pre-#4866 baseline leaves, and no other stamp detects it —
131
+ * `kernelVersion` / `escomplexVersion` track the escomplex package (unmoved
132
+ * by the fix), `scoringSemantics` did not change, and `tsTranspilerVersion`
133
+ * is unreliable in the negative (the `'0.0.0'` sentinel). See the
134
+ * `provenance-unstamped` axis.
135
+ *
136
+ * @returns {{scoringSemantics: string, tsTranspilerVersion: string,
137
+ * provenanceStamped: boolean}}
108
138
  */
109
139
  export function envelopeExtras() {
110
- return { scoringSemantics: SCORING_SEMANTICS };
140
+ return {
141
+ scoringSemantics: SCORING_SEMANTICS,
142
+ tsTranspilerVersion: resolveTsTranspilerVersion(),
143
+ provenanceStamped: true,
144
+ };
111
145
  }
112
146
 
147
+ /**
148
+ * Project a scan row onto the persisted baseline row shape.
149
+ *
150
+ * `coordinateSystem` is written **only** when it is not the default
151
+ * `original` (Story #4866). A pure-JavaScript scan therefore emits the exact
152
+ * four-key row it always did — byte-identical baselines, no refresh — while a
153
+ * row that kept transpiled coordinates is distinguishable on disk from one
154
+ * whose sourcemap lookup resolved.
155
+ */
113
156
  export function projectRow(row) {
114
- return {
157
+ const projected = {
115
158
  path: canonicalise(row.path ?? row.file),
116
159
  method: row.method,
117
160
  startLine: row.startLine,
118
161
  crap: row.crap,
119
162
  };
163
+ if (row.coordinateSystem === COORDINATE_TRANSPILED) {
164
+ projected.coordinateSystem = COORDINATE_TRANSPILED;
165
+ }
166
+ // Story #4969, same write-only-when-non-default idiom: `anonymous` marks a
167
+ // `method` that is a derived scope-path identity rather than a name the
168
+ // source carries. A named row stays the exact four-key row it always was.
169
+ if (row.anonymous === true) {
170
+ projected.anonymous = true;
171
+ }
172
+ return projected;
173
+ }
174
+
175
+ /**
176
+ * Read a row's coordinate provenance, defaulting to `original`.
177
+ *
178
+ * A baseline written before Story #4866 carries no stamp at all, and every
179
+ * such row IS an original-source coordinate — un-remapped rows could not
180
+ * reach a `requireCoverage: true` baseline, and the pure-JavaScript case was
181
+ * never affected. Defaulting is therefore back-compatible, not a guess.
182
+ *
183
+ * @param {{coordinateSystem?: string}|null|undefined} row
184
+ * @returns {string}
185
+ */
186
+ function coordinateSystemOf(row) {
187
+ return row?.coordinateSystem ?? COORDINATE_ORIGINAL;
120
188
  }
121
189
 
122
190
  export function sortRows(rows) {
@@ -273,18 +341,47 @@ export function checkCrapRegression(row, baseline, tolerance, kind) {
273
341
 
274
342
  /**
275
343
  * Pure comparator. Given scanned `currentRows` and committed
276
- * `baselineRows`, produce a structured verdict covering all four match
344
+ * `baselineRows`, produce a structured verdict covering all five match
277
345
  * paths:
278
346
  *
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.
347
+ * 1. **exact** — same (file, method, startLine). Regresses if
348
+ * current crap > baseline crap + tolerance.
349
+ * 2. **drifted** — same (file, method) but startLine shifted. Uses
350
+ * the closest line-drifted baseline row under the
351
+ * same no-regression rule. A drift without
352
+ * regression is reported informationally.
353
+ * 3. **incomparable** same (file, method), but the two rows express
354
+ * their startLine in DIFFERENT coordinate systems
355
+ * (Story #4866). Reported, never scored.
356
+ * 4. **new** — no baseline match. Violates if crap > ceiling.
357
+ * 5. **removed** — baseline rows not seen in the current scan.
358
+ * Surfaced only; never a failure.
359
+ *
360
+ * **Why the incomparable bucket exists.** The drift heuristic assumes both
361
+ * rows measure the same axis and the method simply moved along it. When one
362
+ * row's line is an original-source coordinate and the other's is a transpiled
363
+ * one, the "distance" it minimises over is the gap between two coordinate
364
+ * systems, not a code movement — so it pairs rows arbitrarily and then scores
365
+ * the pairing. That is precisely how a scan that changed nothing reports a
366
+ * regression no edit can satisfy. Refusing is the only sound answer: a row
367
+ * whose provenance differs from every candidate's is counted and surfaced,
368
+ * and it does NOT fall through to the new-method arm (it is not new; it is
369
+ * unmeasurable against this baseline).
370
+ *
371
+ * **`provenanceMismatched` is captured BEFORE the provenance filter runs**
372
+ * (Story #4871). `incomparable` only counts rows where *every* candidate
373
+ * disagreed on coordinates; a row with one agreeing candidate and three
374
+ * disagreeing ones scores normally and leaves no trace. That made the
375
+ * evidence of coordinate mixing strictly narrower than the mixing itself —
376
+ * and the unsound-basis backstop, which needs exactly that evidence, had
377
+ * nothing sound to read. The counter below is incremented for **any** row
378
+ * with at least one provenance-mismatched candidate, whichever arm then
379
+ * resolves it.
380
+ *
381
+ * **Unscorable rows never reach an arm.** A row the scan could not score
382
+ * (`crap: null` / `coverage: null`, or an explicit `unscorable: true`) carries
383
+ * no measurement to compare. It is bucketed and counted, and it is excluded
384
+ * from `comparable` so it cannot dilute any ratio derived from this result.
288
385
  */
289
386
  export function compareCrap({
290
387
  currentRows,
@@ -303,15 +400,29 @@ export function compareCrap({
303
400
  const seenBaselineKeys = new Set();
304
401
 
305
402
  const violations = [];
403
+ const incomparableRows = [];
404
+ const unscorableRows = [];
306
405
  let regressions = 0;
307
406
  let newViolations = 0;
308
407
  let drifted = 0;
408
+ let provenanceMismatched = 0;
309
409
 
310
410
  for (const row of currentRows ?? []) {
411
+ if (isUnscorableRow(row)) {
412
+ unscorableRows.push({ ...row, kind: 'unscorable' });
413
+ continue;
414
+ }
311
415
  const exactKey = `${row.file}::${row.method}@${row.startLine}`;
312
416
  const methodKey = `${row.file}::${row.method}`;
417
+ const rowCoords = coordinateSystemOf(row);
418
+ const candidates = methodIndex.get(methodKey) ?? [];
419
+ // Evidence first, filtering second — see the block comment above.
420
+ if (candidates.some((c) => coordinateSystemOf(c) !== rowCoords)) {
421
+ provenanceMismatched += 1;
422
+ }
423
+
313
424
  const exact = exactIndex.get(exactKey);
314
- if (exact) {
425
+ if (exact && coordinateSystemOf(exact) === rowCoords) {
315
426
  seenBaselineKeys.add(exactKey);
316
427
  const v = checkCrapRegression(row, exact, tolerance, 'regression');
317
428
  if (v) {
@@ -321,22 +432,26 @@ export function compareCrap({
321
432
  continue;
322
433
  }
323
434
 
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;
435
+ if (candidates.length > 0) {
436
+ // Only rows expressed in the SAME coordinate system are comparable;
437
+ // everything else would be resolved through a line-distance heuristic
438
+ // that cannot mean anything across two coordinate systems.
439
+ const comparable = candidates.filter(
440
+ (c) => coordinateSystemOf(c) === rowCoords,
441
+ );
442
+ if (comparable.length === 0) {
443
+ incomparableRows.push({
444
+ ...row,
445
+ kind: 'incomparable',
446
+ coordinateSystem: rowCoords,
447
+ baselineCoordinateSystem: coordinateSystemOf(candidates[0]),
448
+ });
449
+ for (const c of candidates) {
450
+ seenBaselineKeys.add(`${c.file}::${c.method}@${c.startLine}`);
337
451
  }
452
+ continue;
338
453
  }
339
- if (!pick) pick = candidates[0];
454
+ const pick = pickDriftCandidate(comparable, row, seenBaselineKeys);
340
455
  seenBaselineKeys.add(`${pick.file}::${pick.method}@${pick.startLine}`);
341
456
  drifted += 1;
342
457
  const v = checkCrapRegression(row, pick, tolerance, 'drifted-regression');
@@ -364,17 +479,170 @@ export function compareCrap({
364
479
  if (!seenBaselineKeys.has(k)) removedRows.push(b);
365
480
  }
366
481
 
482
+ const total = currentRows?.length ?? 0;
367
483
  return {
368
- total: currentRows?.length ?? 0,
484
+ total,
485
+ // Rows that carried a measurement and therefore *could* be compared. The
486
+ // denominator of every ratio derived from this result.
487
+ comparable: total - unscorableRows.length,
369
488
  regressions,
370
489
  newViolations,
371
490
  drifted,
491
+ provenanceMismatched,
492
+ incomparable: incomparableRows.length,
493
+ unscorable: unscorableRows.length,
372
494
  removed: removedRows.length,
373
495
  violations,
496
+ incomparableRows,
497
+ unscorableRows,
374
498
  removedRows,
375
499
  };
376
500
  }
377
501
 
502
+ /**
503
+ * True when a scanned row carries no measurement to compare.
504
+ *
505
+ * Deliberately strict on `null`: a scan row always sets `crap`, and the
506
+ * scorer's own contract is that an unresolved method yields `crap: null` /
507
+ * `coverage: null` rather than an inferred zero. A caller-built row that
508
+ * simply omits `coverage` is not making that claim and stays scorable.
509
+ *
510
+ * @param {{crap?: number|null, coverage?: number|null, unscorable?: boolean}} row
511
+ * @returns {boolean}
512
+ */
513
+ function isUnscorableRow(row) {
514
+ return (
515
+ row?.unscorable === true || row?.crap === null || row?.coverage === null
516
+ );
517
+ }
518
+
519
+ /**
520
+ * Pick the baseline row a drifted method should be scored against: the
521
+ * closest un-seen candidate by `startLine` distance, falling back to the
522
+ * first when every candidate has already been claimed (duplicate method
523
+ * names in one file).
524
+ *
525
+ * @param {Array<{file: string, method: string, startLine: number}>} comparable
526
+ * @param {{startLine: number}} row
527
+ * @param {Set<string>} seenBaselineKeys
528
+ * @returns {object}
529
+ */
530
+ function pickDriftCandidate(comparable, row, seenBaselineKeys) {
531
+ let pick = null;
532
+ let bestDist = Number.POSITIVE_INFINITY;
533
+ for (const c of comparable) {
534
+ const k = `${c.file}::${c.method}@${c.startLine}`;
535
+ if (seenBaselineKeys.has(k)) continue;
536
+ const d = Math.abs(c.startLine - row.startLine);
537
+ if (d < bestDist) {
538
+ bestDist = d;
539
+ pick = c;
540
+ }
541
+ }
542
+ return pick ?? comparable[0];
543
+ }
544
+
545
+ /**
546
+ * Fraction of a compare's comparable rows that hit a coordinate-provenance
547
+ * mismatch before the comparison basis is judged self-evidently unsound
548
+ * (Story #4866, numerator corrected in Story #4871).
549
+ *
550
+ * **What this ratio must measure.** The backstop exists for one condition: the
551
+ * scan and the baseline expressing `startLine` in different coordinate
552
+ * systems, which makes the nearest-line drift heuristic pair rows arbitrarily
553
+ * and then score the pairing. The only quantity that evidences that condition
554
+ * is a provenance disagreement between a row and its baseline candidates.
555
+ *
556
+ * **Why `drifted` was the wrong numerator.** A row reaches the drift arm only
557
+ * *after* passing the provenance filter, so every drifted row is one whose
558
+ * coordinate system **agreed** with its baseline's. Counting agreements as
559
+ * evidence of disagreement is not a mis-calibration, it is the wrong
560
+ * measurement: on this pure-JavaScript repository — where a coordinate mix is
561
+ * structurally impossible — it read 56–68% across every diff scope, which
562
+ * suppressed the gate's per-method verdicts on every commit while the gate
563
+ * still reported success. `startLine` is half the row identity key, so any
564
+ * insertion re-keys every method below it; ordinary drift is the normal
565
+ * operating state, not an anomaly.
566
+ *
567
+ * Deliberately module-local, like `SCORING_SEMANTICS` above: the three
568
+ * tuning values below are reachable through `assessComparisonBasis` — which
569
+ * takes them as overridable options — so exporting the bare constants would
570
+ * add entry points nothing in production reaches.
571
+ */
572
+ const UNSOUND_BASIS_MISMATCH_RATIO = 0.5;
573
+
574
+ /**
575
+ * Minimum comparable rows before the unsound-basis check is allowed to fire.
576
+ * A diff-scoped preview can legitimately score three methods, two of which
577
+ * moved; that is a normal edit, not a broken basis. Mirrors the
578
+ * minimum-sample discipline the coverage-join resolution floor already uses.
579
+ */
580
+ const UNSOUND_BASIS_MIN_SAMPLE = 20;
581
+
582
+ /** Diagnostic name for an unsound comparison basis. */
583
+ const UNSOUND_BASIS_DIAGNOSTIC = 'crap-unsound-comparison-basis';
584
+
585
+ /** Diagnostic name for a baseline the running scorer refuses to compare. */
586
+ export const INCOMPATIBLE_BASELINE_DIAGNOSTIC = 'crap-baseline-incompatible';
587
+
588
+ /**
589
+ * Pure verdict on whether a `compareCrap` result rests on a sound basis.
590
+ *
591
+ * The numerator is `provenanceMismatched` — rows whose baseline candidates
592
+ * included at least one expressed in a different coordinate system, counted
593
+ * by `compareCrap` **before** its provenance filter discards that evidence.
594
+ * The denominator is `comparable`: rows that carried a measurement at all, so
595
+ * a method the scan could not score cannot dilute the ratio into silence.
596
+ *
597
+ * Above the ratio the per-method verdicts are noise derived from a mis-keyed
598
+ * join, and reporting them as regressions asks the operator to fix code that
599
+ * is not broken. Below it — including a scan where every row drifted, which
600
+ * is what an ordinary insertion produces — the verdicts stand.
601
+ *
602
+ * Returns `{ sound: true }` or `{ sound: false, diagnostic: {name, message} }`.
603
+ *
604
+ * @param {{total?: number, comparable?: number, provenanceMismatched?: number,
605
+ * incomparable?: number}} compareResult
606
+ * @param {{ratio?: number, minSample?: number}} [opts]
607
+ */
608
+ export function assessComparisonBasis(compareResult, opts = {}) {
609
+ const ratio = Number.isFinite(opts.ratio)
610
+ ? opts.ratio
611
+ : UNSOUND_BASIS_MISMATCH_RATIO;
612
+ const minSample = Number.isFinite(opts.minSample)
613
+ ? opts.minSample
614
+ : UNSOUND_BASIS_MIN_SAMPLE;
615
+ const comparable = compareResult?.comparable ?? compareResult?.total ?? 0;
616
+ if (comparable < minSample) return { sound: true };
617
+ // `incomparable` is the total-mismatch subset of `provenanceMismatched`, so
618
+ // it is already counted; the max guards a caller that supplies only one.
619
+ const mismatched = Math.max(
620
+ compareResult?.provenanceMismatched ?? 0,
621
+ compareResult?.incomparable ?? 0,
622
+ );
623
+ const observed = mismatched / comparable;
624
+ if (observed <= ratio) return { sound: true };
625
+ return {
626
+ sound: false,
627
+ diagnostic: {
628
+ name: UNSOUND_BASIS_DIAGNOSTIC,
629
+ message:
630
+ `[CRAP] ⚠ Comparison basis is unsound: ${mismatched}/${comparable} ` +
631
+ `(${(observed * 100).toFixed(1)}%) of comparable methods matched a ` +
632
+ 'baseline row expressed in a DIFFERENT line coordinate system — ' +
633
+ `above the ${(ratio * 100).toFixed(0)}% threshold.\n` +
634
+ ' At this ratio the baseline and the scan are not describing ' +
635
+ 'the same line coordinates, so every per-method verdict below would ' +
636
+ 'be derived from a mis-keyed join rather than from your change. ' +
637
+ 'They are suppressed.\n' +
638
+ " Re-seed the baseline: run 'npm run test:coverage' then " +
639
+ "'npm run crap:update -- --full-scope' and commit the result with a " +
640
+ "'baseline-refresh:' subject. The authoritative check-baselines gate " +
641
+ 'still gates the merge.',
642
+ },
643
+ };
644
+ }
645
+
378
646
  /**
379
647
  * Declarative axis table for `evaluateBaselineCompatibility` (Story #2467).
380
648
  *
@@ -393,6 +661,17 @@ export function compareCrap({
393
661
  * and `tsTranspilerVersion` drift to **warn**, not fail; `escomplexVersion`
394
662
  * mismatch continues to fail closed.
395
663
  */
664
+ /**
665
+ * The one re-seed recipe every coordinate-invalidating axis ends on. Three
666
+ * axes and the unsound-basis diagnostic previously carried their own copy of
667
+ * this sentence; a single constant keeps them from drifting apart on the
668
+ * command an operator is told to run.
669
+ */
670
+ const RESEED_REMEDY =
671
+ "Re-derive the baseline: run 'npm run test:coverage' then " +
672
+ "'npm run crap:update -- --full-scope' and commit the result with a " +
673
+ "'baseline-refresh:' subject.";
674
+
396
675
  export const CRAP_COMPAT_AXES = [
397
676
  // Universal axes hoisted into envelope.js (Story #2467, Task #2492). The
398
677
  // missing-baseline and kernel-drift checks live in exactly one place;
@@ -418,27 +697,124 @@ export const CRAP_COMPAT_AXES = [
418
697
  `[CRAP] scoring semantics changed: baseline=${stamped ?? '<unstamped>'} ` +
419
698
  `running=${SCORING_SEMANTICS}. Rows scored by the previous per-method ` +
420
699
  'coverage join are not comparable to rows scored by the current one, ' +
421
- 'so this baseline cannot be compared — it must be re-derived. Run ' +
422
- "'npm run test:coverage' then 'npm run crap:update -- --full-scope' " +
423
- "and commit the result with a 'baseline-refresh:' subject."
700
+ `so this baseline cannot be compared. ${RESEED_REMEDY}`
424
701
  );
425
702
  },
426
703
  },
427
704
  {
428
705
  name: 'ts-transpiler-drift',
429
- severity: 'warn',
706
+ severity: 'fatal',
430
707
  check: ({ baseline, runningTsTranspilerVersion }) => {
431
- if (!baseline || !runningTsTranspilerVersion) return null;
432
- const baselineTs = baseline.tsTranspilerVersion ?? '0.0.0';
708
+ if (!baseline) return null;
709
+ if (!isKnownVersion(runningTsTranspilerVersion)) return null;
710
+ const baselineTs = baseline.tsTranspilerVersion;
711
+ // An unstamped (or sentinel) baseline has nothing to compare — the
712
+ // stamp landed in Story #4866 and comparing against its absence would
713
+ // fail every pre-existing baseline closed for no evidence at all.
714
+ if (!isKnownVersion(baselineTs)) return null;
433
715
  if (baselineTs === runningTsTranspilerVersion) return null;
716
+ // Scoped to baselines that actually contain transpiled sources: a
717
+ // transpiler change moves TS row coordinates and nothing else. A
718
+ // pure-JavaScript tree has no coordinate to move, so a TS bump there is
719
+ // not a coordinate-invalidating event and must not fail its gate.
720
+ if (!hasTranspiledRows(baseline)) return null;
721
+ return (
722
+ `[CRAP] tsTranspilerVersion changed: baseline=${baselineTs} running=${runningTsTranspilerVersion}. ` +
723
+ "A TS row's startLine is an original-source coordinate only because the transpiler's " +
724
+ 'sourcemap said so, and that coordinate is half the row identity key — so rows scored ' +
725
+ `under the previous transpiler are not comparable to rows scored under this one. ${RESEED_REMEDY}`
726
+ );
727
+ },
728
+ },
729
+ {
730
+ // Story #4901. Closes the exemption directly above: `ts-transpiler-drift`
731
+ // returns null for an unstamped baseline rather than fail every
732
+ // pre-existing one for want of evidence — and a pre-#4866 baseline is
733
+ // exactly that shape, so the one door that could catch it lets it through
734
+ // while it asserts by omission that all its rows are original coordinates.
735
+ // Keyed on a positive marker because absence alone cannot separate
736
+ // "written before provenance existed" from "typescript was unresolvable".
737
+ // Scoped like `ts-transpiler-drift`: a pure-JavaScript baseline's two
738
+ // coordinate systems coincide, so it was never affected and must not fail.
739
+ name: 'provenance-unstamped',
740
+ severity: 'fatal',
741
+ check: ({ baseline }) =>
742
+ baseline &&
743
+ baseline.provenanceStamped !== true &&
744
+ hasTranspiledRows(baseline)
745
+ ? '[CRAP] baseline predates coordinate-provenance stamping: it carries ' +
746
+ 'transpiled-source rows but no `provenanceStamped` marker, so it ' +
747
+ 'asserts by omission that every row is an original-source coordinate. ' +
748
+ '`startLine` is half the row identity key, so the comparator would ' +
749
+ 'key those rows against a coordinate space they are not in and ' +
750
+ `report regressions no edit can satisfy. ${RESEED_REMEDY}`
751
+ : null,
752
+ },
753
+ {
754
+ // Story #4969. The `scoring-semantics-drift` axis above rejects a baseline
755
+ // stamped with the OLD semantics wholesale, which covers a clean migration.
756
+ // It cannot see a HALF-migrated one: a diff-scoped refresh preserves
757
+ // out-of-scope rows verbatim, so a baseline can carry ordinal-keyed rows
758
+ // for the files that were never re-scored while the writer stamps the
759
+ // envelope with the current semantics — the stamp says v3, some rows are
760
+ // still v2, and the one axis that would catch it has already passed.
761
+ //
762
+ // Such a row cannot be paired with anything: its `<anon method-N>` label
763
+ // matches no scope-path identity, so the comparator files the live method
764
+ // as NEW (scored against the ceiling, not its own baseline) and the stale
765
+ // row as removed. Keyed on the positive `anonymous` marker for the same
766
+ // reason `provenance-unstamped` is: absence is the only trace the old
767
+ // writer leaves.
768
+ name: 'anon-identity-unstamped',
769
+ severity: 'fatal',
770
+ check: ({ baseline }) => {
771
+ if (!baseline) return null;
772
+ const stale = (baseline.rows ?? []).filter(
773
+ (row) => isAnonymousMethodLabel(row?.method) && row?.anonymous !== true,
774
+ );
775
+ if (stale.length === 0) return null;
434
776
  return (
435
- `[CRAP] tsTranspilerVersion drift: baseline=${baselineTs} running=${runningTsTranspilerVersion}. ` +
436
- "Run 'npm run crap:update' and commit with a 'baseline-refresh:' subject to refresh."
777
+ `[CRAP] baseline carries ${stale.length} anonymous row(s) keyed by the ` +
778
+ 'superseded `<anon method-N>` ordinal (e.g. ' +
779
+ `${stale[0].path ?? stale[0].file}::${stale[0].method}) while the ` +
780
+ 'envelope claims the current scoring semantics. Those ordinals ' +
781
+ 'renumber whenever any anonymous function is added or removed, so the ' +
782
+ 'comparator would score the live methods against the new-method ' +
783
+ `ceiling instead of their own baseline. ${RESEED_REMEDY}`
437
784
  );
438
785
  },
439
786
  },
440
787
  ];
441
788
 
789
+ /** Sources whose escomplex coordinates are transpiled, not original. */
790
+ const TRANSPILED_SOURCE_RE = /\.(?:ts|tsx|mts|cts)$/i;
791
+
792
+ /**
793
+ * `'0.0.0'` is this codebase's established "unknown environment" sentinel for
794
+ * a resolved dependency version, not a real release. Treating it as a
795
+ * comparable value would turn "we could not resolve typescript" into
796
+ * "typescript changed".
797
+ *
798
+ * @param {unknown} version
799
+ * @returns {boolean}
800
+ */
801
+ function isKnownVersion(version) {
802
+ return typeof version === 'string' && version !== '' && version !== '0.0.0';
803
+ }
804
+
805
+ /**
806
+ * True when a baseline contains at least one row derived from a transpiled
807
+ * source — the only rows a transpiler-version change can move.
808
+ *
809
+ * @param {{rows?: Array<{path?: string, file?: string}>}|null} baseline
810
+ * @returns {boolean}
811
+ */
812
+ function hasTranspiledRows(baseline) {
813
+ return (baseline?.rows ?? []).some((row) =>
814
+ TRANSPILED_SOURCE_RE.test(String(row?.path ?? row?.file ?? '')),
815
+ );
816
+ }
817
+
442
818
  /**
443
819
  * Pure decision helper for the missing-baseline / kernel-mismatch /
444
820
  * escomplex-mismatch / tsTranspiler-mismatch gate paths. Lets tests
@@ -454,27 +830,53 @@ export function evaluateBaselineCompatibility(ctx) {
454
830
  }
455
831
 
456
832
  /**
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.
833
+ * The compat axes a *loaded* envelope can be judged against on its own,
834
+ * without a second baseline to diff. Each invalidates one half of the row
835
+ * identity key. Three are about the `startLine` half: one names the join that
836
+ * produced the rows, one names the transpiler whose sourcemap decided what a
837
+ * TS row's `startLine` even means, and one (Story #4901) catches a baseline
838
+ * predating both questions. The fourth (Story #4969) is about the `method`
839
+ * half — a baseline still carrying ordinal-keyed anonymous rows.
840
+ *
841
+ * `escomplex-mismatch` and `kernel-drift` stay out the v2 envelope carries
842
+ * no `escomplexVersion`, so that axis would compare `undefined` to `undefined`
843
+ * and pass vacuously, which is worse than not running it.
844
+ */
845
+ const LOADED_ENVELOPE_AXES = [
846
+ 'scoring-semantics-drift',
847
+ 'ts-transpiler-drift',
848
+ 'provenance-unstamped',
849
+ 'anon-identity-unstamped',
850
+ ];
851
+
852
+ /**
853
+ * Kind-module hook (Story #4775, extended by Story #4866): judge a *loaded*
854
+ * v2 envelope against the axes that need no peer baseline. The unified
855
+ * `check-baselines` gate calls it straight after `reader.load` and turns a
856
+ * message into a fail-closed schema-class error, so a baseline written by
857
+ * incompatible scoring semantics — or by a different transpiler, which moves
858
+ * the coordinates that are half the row identity key — can never be silently
859
+ * compared against current scores.
860
+ *
861
+ * This is the *production* door for `ts-transpiler-drift`. Before #4866 the
862
+ * axis was reachable only from its own unit test: nothing in production called
863
+ * `evaluateBaselineCompatibility`, and this function deliberately excluded it.
468
864
  *
469
865
  * @param {object|null} baseline A loaded v2 baseline envelope.
866
+ * @param {{runningTsTranspilerVersion?: string}} [ctx] Injectable running
867
+ * versions; resolved from the environment when omitted.
470
868
  * @returns {string|null} Operator-facing message, or null when compatible.
471
869
  */
472
- export function assertBaselineCompatible(baseline) {
870
+ export function assertBaselineCompatible(baseline, ctx = {}) {
473
871
  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;
872
+ const runningTsTranspilerVersion =
873
+ ctx.runningTsTranspilerVersion ?? resolveTsTranspilerVersion();
874
+ for (const name of LOADED_ENVELOPE_AXES) {
875
+ const axis = CRAP_COMPAT_AXES.find((a) => a.name === name);
876
+ const message = axis?.check({ baseline, runningTsTranspilerVersion });
877
+ if (message) return message;
878
+ }
879
+ return null;
478
880
  }
479
881
 
480
882
  /**
@@ -571,6 +973,17 @@ export function buildCrapReport({
571
973
  regressions: compareResult.regressions,
572
974
  newViolations: compareResult.newViolations,
573
975
  drifted: compareResult.drifted,
976
+ // Story #4866: rows the compare refused to resolve because their
977
+ // coordinate provenance differs from the baseline's.
978
+ incomparable: compareResult.incomparable ?? 0,
979
+ // Story #4871: rows whose baseline candidates included at least one in a
980
+ // different coordinate system — the evidence the unsound-basis backstop
981
+ // reads, captured before the provenance filter discards it.
982
+ provenanceMismatched: compareResult.provenanceMismatched ?? 0,
983
+ // Story #4871: methods carrying no measurement to compare — the scan
984
+ // found no coverage artifact for them. Reported, never scored from an
985
+ // assumed zero, and never part of a ratio's denominator.
986
+ unscorable: (compareResult.unscorable ?? 0) + skippedNoCoverage,
574
987
  removed: compareResult.removed,
575
988
  skippedNoCoverage,
576
989
  scope,
@@ -580,6 +993,33 @@ export function buildCrapReport({
580
993
  };
581
994
  }
582
995
 
996
+ /**
997
+ * Rebuild a CRAP envelope with its per-method verdicts suppressed and one
998
+ * named diagnostic in their place (Story #4866).
999
+ *
1000
+ * Used by the preview gate on the two conditions under which a per-method
1001
+ * verdict cannot mean anything: a baseline the running scorer refuses to
1002
+ * compare, and a comparison basis whose drifted-row ratio proves the two
1003
+ * sides disagree on coordinates. The counts stay so the operator can see the
1004
+ * evidence; only the accusations go.
1005
+ *
1006
+ * @param {object} envelope
1007
+ * @param {{name: string, message: string}} diagnostic
1008
+ * @returns {object}
1009
+ */
1010
+ export function suppressVerdicts(envelope, diagnostic) {
1011
+ return {
1012
+ ...envelope,
1013
+ summary: {
1014
+ ...envelope.summary,
1015
+ regressions: 0,
1016
+ newViolations: 0,
1017
+ },
1018
+ violations: [],
1019
+ diagnostics: [diagnostic],
1020
+ };
1021
+ }
1022
+
583
1023
  /**
584
1024
  * Logger-only printers hoisted from `check-crap.js`. Kept here so the
585
1025
  * CLI shell stays thin and the printers can be exercised in unit tests
@@ -591,6 +1031,8 @@ export function printSummaryHeader(result, scanSummary) {
591
1031
  Logger.info(`Regressions: ${result.regressions}`);
592
1032
  Logger.info(`New-method violations: ${result.newViolations}`);
593
1033
  Logger.info(`Drifted (matched): ${result.drifted}`);
1034
+ Logger.info(`Provenance mismatched: ${result.provenanceMismatched ?? 0}`);
1035
+ Logger.info(`Unscorable (no cov): ${result.unscorable ?? 0}`);
594
1036
  Logger.info(`Removed from baseline: ${result.removed}`);
595
1037
  if (scanSummary?.skippedFilesNoCoverage) {
596
1038
  Logger.info(