mandrel 2.16.0 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.agents/docs/agentrc-reference.json +10 -0
  2. package/.agents/docs/configuration.md +9 -0
  3. package/.agents/docs/quality-gates.md +137 -0
  4. package/.agents/schemas/agentrc.schema.json +48 -0
  5. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  6. package/.agents/schemas/baselines/crap.schema.json +4 -0
  7. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  8. package/.agents/scripts/acceptance-eval.js +52 -12
  9. package/.agents/scripts/audit-to-stories.js +92 -25
  10. package/.agents/scripts/boot-sweep.js +67 -8
  11. package/.agents/scripts/check-baseline-drift.js +138 -0
  12. package/.agents/scripts/coverage-capture.js +74 -25
  13. package/.agents/scripts/deliver-recover.js +45 -18
  14. package/.agents/scripts/drain-pending-cleanup.js +67 -23
  15. package/.agents/scripts/generate-lens-checklists.js +81 -30
  16. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
  17. package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
  18. package/.agents/scripts/lib/baselines/envelope.js +7 -0
  19. package/.agents/scripts/lib/baselines/kernel.js +31 -0
  20. package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
  21. package/.agents/scripts/lib/baselines/reader.js +12 -1
  22. package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
  23. package/.agents/scripts/lib/baselines/writer.js +10 -0
  24. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
  25. package/.agents/scripts/lib/cli-utils.js +48 -13
  26. package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
  27. package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
  28. package/.agents/scripts/lib/close-validation/runner.js +68 -0
  29. package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
  30. package/.agents/scripts/lib/config/quality.js +40 -0
  31. package/.agents/scripts/lib/config/temp-paths.js +27 -0
  32. package/.agents/scripts/lib/config-settings-schema-delivery.js +69 -0
  33. package/.agents/scripts/lib/coverage-utils.js +92 -9
  34. package/.agents/scripts/lib/crap-engine.js +113 -23
  35. package/.agents/scripts/lib/crap-utils.js +159 -93
  36. package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
  37. package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
  38. package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
  39. package/.agents/scripts/lib/observability/terse-result.js +7 -3
  40. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
  41. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
  42. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
  43. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
  44. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
  45. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +9 -5
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +15 -1
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
  48. package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
  49. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
  50. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
  51. package/.agents/scripts/lib/single-story-sweep.js +11 -0
  52. package/.agents/scripts/lib/stdio-flush.js +71 -0
  53. package/.agents/scripts/lib/temp-retention.js +559 -0
  54. package/.agents/scripts/lib/transpile.js +133 -6
  55. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
  56. package/.agents/scripts/lib/workers/crap-worker.js +49 -76
  57. package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
  58. package/.agents/scripts/nav-registry-diff.js +30 -8
  59. package/.agents/scripts/plan-run-epilogue.js +27 -11
  60. package/.agents/scripts/resolve-doc-tiers.js +18 -8
  61. package/.agents/scripts/single-story-close.js +9 -92
  62. package/.agents/scripts/single-story-init.js +1 -1
  63. package/.agents/scripts/sync-branch-from-base.js +6 -1
  64. package/.agents/scripts/update-crap-baseline.js +13 -0
  65. package/README.md +14 -6
  66. package/docs/CHANGELOG.md +36 -0
  67. package/lib/cli/version-helpers.js +7 -0
  68. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
  69. package/package.json +5 -1
@@ -0,0 +1,351 @@
1
+ // .agents/scripts/lib/baselines/drift-detector.js
2
+ /**
3
+ * drift-detector.js — full-scope baseline drift detection (Story #4776).
4
+ *
5
+ * Every enforcement site for the maintainability and CRAP baselines is
6
+ * **diff-scoped**: close-validation, the pre-push hook and CI all compare
7
+ * the files a branch touched against their committed rows. That is the
8
+ * right per-PR trade — full-scope scoring on every push would be far too
9
+ * expensive — but it has a structural blind spot. A file that is never
10
+ * modified after its baseline row is written is never re-scored, so a
11
+ * regression introduced *indirectly* (a dependency getting more complex, a
12
+ * test deletion moving coverage underneath it) is invisible for as long as
13
+ * nobody happens to touch that file.
14
+ *
15
+ * This module closes that hole with the check that is too expensive to run
16
+ * per-PR and cheap enough to run on a schedule: re-score every target
17
+ * directory in full, and report every row whose current score has moved
18
+ * away from its baseline by more than the gate's tolerance — **in either
19
+ * direction**. Drift, not regression: a row that silently improved is
20
+ * equally strong evidence that the committed baseline no longer describes
21
+ * the tree, and leaving it stale means the ratchet is anchored to a number
22
+ * that no longer exists.
23
+ *
24
+ * Re-scoring routes through `refresh-service.resolveDefaultScorer` — the
25
+ * same scorer that writes the baseline — so the detector cannot report two
26
+ * implementations disagreeing with each other as drift in the tree.
27
+ *
28
+ * Pure-ish: the scorer and the baseline loader are both injectable, and the
29
+ * module never writes anything, exits, or emits friction.
30
+ *
31
+ * The public surface is deliberately the three symbols the CLI actually
32
+ * uses — `DRIFT_KINDS`, `detectBaselineDrift`, `formatDriftReport`. Every
33
+ * internal helper below stays module-local and is exercised through them
34
+ * (`detectBaselineDrift` forwards its `loadBaselineRows` / `scoreFullScope`
35
+ * seams straight down). Exporting the helpers so tests could reach them
36
+ * directly would ship five entry points nothing in production reaches —
37
+ * exactly the orphaning this Story exists to stop.
38
+ */
39
+
40
+ import { getQuality } from '../config/quality.js';
41
+ import { resolveConfig } from '../config-resolver.js';
42
+ import { getKindModule } from './kernel.js';
43
+ import { load as loadBaseline } from './reader.js';
44
+ import { resolveDefaultScorer } from './refresh-service.js';
45
+
46
+ /** The kinds whose rows carry a per-file/per-method score worth re-scoring. */
47
+ export const DRIFT_KINDS = Object.freeze(['maintainability', 'crap']);
48
+
49
+ /**
50
+ * Per-kind identity, metric axis, and refresh remedy. `identity` must match
51
+ * the granularity the kind's baseline rows are keyed at, or unchanged rows
52
+ * masquerade as added/removed pairs.
53
+ */
54
+ const KIND_SPECS = Object.freeze({
55
+ maintainability: Object.freeze({
56
+ metric: 'mi',
57
+ identity: (row) => row.path,
58
+ label: (row) => row.path,
59
+ refreshCommand: 'npm run maintainability:update -- --full-scope',
60
+ defaultTolerance: 0.5,
61
+ }),
62
+ crap: Object.freeze({
63
+ metric: 'crap',
64
+ identity: (row) => `${row.path}::${row.method}@${row.startLine}`,
65
+ label: (row) => `${row.path}::${row.method} (line ${row.startLine})`,
66
+ refreshCommand: 'npm run crap:update -- --full-scope',
67
+ defaultTolerance: 0.001,
68
+ }),
69
+ });
70
+
71
+ /**
72
+ * Resolve the absolute drift tolerance for a kind: an explicit override
73
+ * wins, then the gate's configured `tolerance.value`, then the per-kind
74
+ * default.
75
+ *
76
+ * @param {string} kind
77
+ * @param {object|undefined} gate resolved `delivery.quality.gates.<kind>`
78
+ * @param {number|null|undefined} override
79
+ * @returns {number}
80
+ */
81
+ function resolveTolerance(kind, gate, override) {
82
+ if (typeof override === 'number' && Number.isFinite(override)) {
83
+ return Math.abs(override);
84
+ }
85
+ const configured = gate?.tolerance;
86
+ if (configured?.kind === 'absolute') {
87
+ const value = Number(configured.value);
88
+ if (Number.isFinite(value)) return Math.abs(value);
89
+ }
90
+ return KIND_SPECS[kind].defaultTolerance;
91
+ }
92
+
93
+ /**
94
+ * Normalise a scorer's raw rows into the kind's canonical on-disk row
95
+ * shape, so scored rows and baseline rows are directly comparable. The
96
+ * per-kind `projectRow` is the same projection the writer applies, which is
97
+ * what makes the two sides comparable at all (it also reconciles the CRAP
98
+ * scorer's `file` key with the envelope's `path`).
99
+ *
100
+ * @param {string} kind
101
+ * @param {Array<object>} rows
102
+ * @returns {Array<object>}
103
+ */
104
+ function projectScoredRows(kind, rows) {
105
+ const mod = getKindModule(kind);
106
+ const out = [];
107
+ for (const row of rows ?? []) {
108
+ try {
109
+ out.push(mod.projectRow(row));
110
+ } catch {
111
+ // A row the writer itself would refuse is not evidence of drift.
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * Diff two row sets by the kind's identity, classifying each key as
119
+ * drifted / added / removed. Pure.
120
+ *
121
+ * @param {{ kind: string, baselineRows: Array<object>, currentRows: Array<object>, tolerance: number }} opts
122
+ * @returns {{ drifted: Array<object>, added: Array<object>, removed: Array<object> }}
123
+ */
124
+ function diffRows({ kind, baselineRows, currentRows, tolerance }) {
125
+ const spec = KIND_SPECS[kind];
126
+ const baseByKey = new Map();
127
+ for (const row of baselineRows ?? []) baseByKey.set(spec.identity(row), row);
128
+
129
+ const drifted = [];
130
+ const added = [];
131
+ const seen = new Set();
132
+
133
+ for (const row of currentRows ?? []) {
134
+ const key = spec.identity(row);
135
+ seen.add(key);
136
+ const base = baseByKey.get(key);
137
+ if (!base) {
138
+ added.push({ key, label: spec.label(row), current: row[spec.metric] });
139
+ continue;
140
+ }
141
+ const before = Number(base[spec.metric] ?? 0);
142
+ const after = Number(row[spec.metric] ?? 0);
143
+ const delta = after - before;
144
+ if (Math.abs(delta) <= tolerance) continue;
145
+ drifted.push({
146
+ key,
147
+ label: spec.label(row),
148
+ baseline: before,
149
+ current: after,
150
+ delta,
151
+ });
152
+ }
153
+
154
+ const removed = [];
155
+ for (const [key, row] of baseByKey) {
156
+ if (seen.has(key)) continue;
157
+ removed.push({ key, label: spec.label(row), baseline: row[spec.metric] });
158
+ }
159
+
160
+ return { drifted, added, removed };
161
+ }
162
+
163
+ /**
164
+ * Re-score one kind full-scope and diff the result against its committed
165
+ * baseline.
166
+ *
167
+ * Returns `{ ok: true, skipped: '<reason>' }` when the kind cannot be
168
+ * checked (gate disabled, no baseline on disk, no scorer registered, the
169
+ * scorer produced nothing) — an unscorable kind is not drift, and a
170
+ * scheduled job must not go red because coverage happened to be absent.
171
+ *
172
+ * @param {{
173
+ * kind: string,
174
+ * cwd?: string,
175
+ * quality?: object,
176
+ * tolerance?: number|null,
177
+ * loadBaselineRows?: (kind: string, cwd: string) => Array<object>|null,
178
+ * scoreFullScope?: (kind: string, cwd: string) => Promise<Array<object>|null>|Array<object>|null,
179
+ * }} opts
180
+ * @returns {Promise<object>}
181
+ */
182
+ async function detectKindDrift({
183
+ kind,
184
+ cwd = process.cwd(),
185
+ quality,
186
+ tolerance = null,
187
+ loadBaselineRows = defaultLoadBaselineRows,
188
+ scoreFullScope = defaultScoreFullScope,
189
+ }) {
190
+ if (!Object.hasOwn(KIND_SPECS, kind)) {
191
+ throw new Error(
192
+ `[drift] unknown kind "${kind}"; expected one of ${DRIFT_KINDS.join(', ')}`,
193
+ );
194
+ }
195
+ const gate = quality?.[kind];
196
+ const spec = KIND_SPECS[kind];
197
+ const base = { kind, refreshCommand: spec.refreshCommand };
198
+ if (gate?.enabled === false) {
199
+ return { ...base, ok: true, skipped: 'gate-disabled' };
200
+ }
201
+
202
+ const baselineRows = loadBaselineRows(kind, cwd);
203
+ if (!Array.isArray(baselineRows) || baselineRows.length === 0) {
204
+ return { ...base, ok: true, skipped: 'no-baseline' };
205
+ }
206
+
207
+ const raw = await scoreFullScope(kind, cwd);
208
+ if (raw === null || raw === undefined) {
209
+ return { ...base, ok: true, skipped: 'no-scorer' };
210
+ }
211
+ const currentRows = projectScoredRows(kind, raw);
212
+ if (currentRows.length === 0) {
213
+ return { ...base, ok: true, skipped: 'no-scored-rows' };
214
+ }
215
+
216
+ const resolvedTolerance = resolveTolerance(kind, gate, tolerance);
217
+ const { drifted, added, removed } = diffRows({
218
+ kind,
219
+ baselineRows,
220
+ currentRows,
221
+ tolerance: resolvedTolerance,
222
+ });
223
+
224
+ return {
225
+ ...base,
226
+ ok: drifted.length === 0,
227
+ tolerance: resolvedTolerance,
228
+ scanned: currentRows.length,
229
+ baselineRows: baselineRows.length,
230
+ drifted,
231
+ added,
232
+ removed,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Run the drift check across several kinds.
238
+ *
239
+ * @param {{ kinds?: string[], cwd?: string, tolerance?: number|null, quality?: object }} opts
240
+ * @returns {Promise<{ ok: boolean, results: Array<object> }>}
241
+ */
242
+ export async function detectBaselineDrift({
243
+ kinds = DRIFT_KINDS,
244
+ cwd = process.cwd(),
245
+ tolerance = null,
246
+ quality,
247
+ ...seams
248
+ } = {}) {
249
+ let resolvedQuality = quality;
250
+ if (!resolvedQuality) {
251
+ try {
252
+ resolvedQuality = getQuality(resolveConfig({ cwd })) ?? {};
253
+ } catch {
254
+ resolvedQuality = {};
255
+ }
256
+ }
257
+ const results = [];
258
+ for (const kind of kinds) {
259
+ results.push(
260
+ await detectKindDrift({
261
+ kind,
262
+ cwd,
263
+ tolerance,
264
+ quality: resolvedQuality,
265
+ ...seams,
266
+ }),
267
+ );
268
+ }
269
+ return { ok: results.every((r) => r.ok), results };
270
+ }
271
+
272
+ /**
273
+ * Default baseline loader — reads and schema-validates the committed
274
+ * envelope. Returns `null` when it cannot be read, which the caller maps to
275
+ * the `no-baseline` skip.
276
+ */
277
+ function defaultLoadBaselineRows(kind, cwd) {
278
+ try {
279
+ return loadBaseline(kind, { cwd }).rows;
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Default full-scope scorer — the same scorer `refreshBaseline` uses, run
287
+ * with `fullScope: true` so it walks every configured target directory
288
+ * rather than a diff-derived file list.
289
+ */
290
+ async function defaultScoreFullScope(kind, cwd) {
291
+ const scorer = resolveDefaultScorer(kind, { cwd });
292
+ if (typeof scorer !== 'function') return null;
293
+ return await scorer(null, { fullScope: true, cwd });
294
+ }
295
+
296
+ /**
297
+ * Render one kind's result as an operator-facing block: a per-row
298
+ * before/after table plus the refresh remedy. Returns a string always —
299
+ * a clean kind reports one line so a scheduled job's log shows it ran.
300
+ *
301
+ * @param {object} result
302
+ * @returns {string}
303
+ */
304
+ function formatKindDrift(result) {
305
+ if (result.skipped) {
306
+ return `[drift] ⏭ ${result.kind}: skipped (${result.skipped})`;
307
+ }
308
+ if (result.ok) {
309
+ return `[drift] ✓ ${result.kind}: ${result.scanned} row(s) re-scored full-scope, no drift beyond ±${result.tolerance}`;
310
+ }
311
+ const width = Math.max(
312
+ 12,
313
+ ...result.drifted.map((d) => String(d.label).length),
314
+ );
315
+ const lines = [
316
+ `[drift] ✖ ${result.kind}: ${result.drifted.length} row(s) drifted beyond ±${result.tolerance} (${result.scanned} re-scored, ${result.baselineRows} in baseline)`,
317
+ ` ${'ROW'.padEnd(width)} ${'BASELINE'.padStart(10)} ${'CURRENT'.padStart(10)} ${'DELTA'.padStart(10)}`,
318
+ ];
319
+ for (const d of result.drifted) {
320
+ lines.push(
321
+ ` ${String(d.label).padEnd(width)} ${d.baseline.toFixed(2).padStart(10)} ${d.current
322
+ .toFixed(2)
323
+ .padStart(
324
+ 10,
325
+ )} ${(d.delta > 0 ? `+${d.delta.toFixed(2)}` : d.delta.toFixed(2)).padStart(10)}`,
326
+ );
327
+ }
328
+ if (result.added.length > 0 || result.removed.length > 0) {
329
+ lines.push(
330
+ ` (${result.added.length} row(s) absent from baseline, ${result.removed.length} baseline row(s) absent from the tree)`,
331
+ );
332
+ }
333
+ lines.push(
334
+ ` Remedy: run \`${result.refreshCommand}\` and commit the refreshed baseline with a \`baseline-refresh:\` tagged subject (non-empty body).`,
335
+ );
336
+ return lines.join('\n');
337
+ }
338
+
339
+ /**
340
+ * Render the whole run.
341
+ *
342
+ * @param {{ ok: boolean, results: Array<object> }} run
343
+ * @returns {string}
344
+ */
345
+ export function formatDriftReport(run) {
346
+ const body = run.results.map(formatKindDrift).join('\n');
347
+ const tail = run.ok
348
+ ? '[drift] ✅ No baseline drift detected.'
349
+ : '[drift] ❌ Baseline drift detected — the committed baselines no longer describe the tree.';
350
+ return `${body}\n${tail}`;
351
+ }
@@ -133,6 +133,7 @@ function resolveGeneratedAt(explicit) {
133
133
  * rows: Array<object>,
134
134
  * kernelVersion: string,
135
135
  * generatedAt?: string,
136
+ * extras?: Record<string, unknown>,
136
137
  * }} params
137
138
  * @returns {{
138
139
  * $schema: string,
@@ -148,6 +149,7 @@ export function buildEnvelope({
148
149
  rows,
149
150
  kernelVersion,
150
151
  generatedAt,
152
+ extras,
151
153
  } = {}) {
152
154
  if (typeof kind !== 'string' || !KNOWN_KINDS.includes(kind)) {
153
155
  throw new TypeError(
@@ -176,10 +178,15 @@ export function buildEnvelope({
176
178
  throw new TypeError('envelope.buildEnvelope: rows must be an array');
177
179
  }
178
180
 
181
+ // Per-kind envelope-level stamps (Story #4775). A kind whose SCORING
182
+ // SEMANTICS can change independently of its kernel version contributes them
183
+ // here; `assertEnvelope` still validates the result against the kind's
184
+ // schema, so an unrecognised extra fails closed rather than being persisted.
179
185
  return {
180
186
  $schema: schemaRefFor(kind),
181
187
  kernelVersion,
182
188
  generatedAt: resolveGeneratedAt(generatedAt),
189
+ ...(extras && typeof extras === 'object' ? extras : {}),
183
190
  rollup,
184
191
  rows,
185
192
  };
@@ -50,7 +50,9 @@ import {
50
50
  } from './kinds/coverage.js';
51
51
  import {
52
52
  applyEpsilon as crapApplyEpsilon,
53
+ assertBaselineCompatible as crapAssertBaselineCompatible,
53
54
  compare as crapCompare,
55
+ envelopeExtras as crapEnvelopeExtras,
54
56
  kernelVersion as crapKernelVersion,
55
57
  keyField as crapKeyField,
56
58
  mergeRows as crapMergeRows,
@@ -139,6 +141,12 @@ function bindKindModule(members) {
139
141
  compare: members.compare,
140
142
  applyEpsilon: members.applyEpsilon,
141
143
  mergeRows: members.mergeRows,
144
+ // Optional per-kind hooks (Story #4775). `envelopeExtras` contributes
145
+ // envelope-level stamps the shared writer would not otherwise know about;
146
+ // `assertBaselineCompatible` lets a kind refuse a loaded baseline whose
147
+ // scoring semantics predate the running scorer.
148
+ envelopeExtras: members.envelopeExtras,
149
+ assertBaselineCompatible: members.assertBaselineCompatible,
142
150
  });
143
151
  }
144
152
 
@@ -179,6 +187,8 @@ const KIND_MODULES = Object.freeze({
179
187
  compare: crapCompare,
180
188
  applyEpsilon: crapApplyEpsilon,
181
189
  mergeRows: crapMergeRows,
190
+ envelopeExtras: crapEnvelopeExtras,
191
+ assertBaselineCompatible: crapAssertBaselineCompatible,
182
192
  }),
183
193
  maintainability: bindKindModule({
184
194
  name: maintainabilityName,
@@ -270,6 +280,27 @@ export function currentKernelVersion(kind) {
270
280
  return getKindModule(kind).kernelVersion();
271
281
  }
272
282
 
283
+ /**
284
+ * Ask a kind whether a loaded baseline is compatible with the running
285
+ * scorer's SEMANTICS — a dimension `kernelVersion` cannot express, because a
286
+ * kind's scoring can change while the upstream package it stamps does not
287
+ * (Story #4775). Kinds without the hook always answer "compatible".
288
+ *
289
+ * @param {string} kind
290
+ * @param {object|null} baseline
291
+ * @returns {string|null} Operator-facing message, or null when compatible.
292
+ */
293
+ export function checkBaselineSemantics(kind, baseline) {
294
+ let mod;
295
+ try {
296
+ mod = getKindModule(kind);
297
+ } catch {
298
+ return null;
299
+ }
300
+ if (typeof mod.assertBaselineCompatible !== 'function') return null;
301
+ return mod.assertBaselineCompatible(baseline);
302
+ }
303
+
273
304
  /**
274
305
  * Compare a baseline's stamped version against the currently running
275
306
  * kernel for the same kind. Returns `{ match, current }` so callers can
@@ -75,6 +75,41 @@ export function kernelVersion() {
75
75
  return '0.0.0';
76
76
  }
77
77
 
78
+ /**
79
+ * Scoring-semantics stamp (Story #4775, fix part 5).
80
+ *
81
+ * `kernelVersion()` above tracks the `typhonjs-escomplex` package and
82
+ * `escomplexVersion` tracks the same dependency — so a change in how THIS
83
+ * repo joins escomplex methods to istanbul coverage moves neither. Rows
84
+ * scored by the pre-#4775 join (exact transpiled-line equality, methods
85
+ * dropped when unresolved) are not comparable to rows scored by the join
86
+ * that replaced it (original-source coordinates, containment matching,
87
+ * honest `requireCoverage: false`): the same method can carry a different
88
+ * `crap`, a different `startLine`, or exist in one baseline and not the
89
+ * other. Comparing across that boundary produces phantom regressions and,
90
+ * worse, phantom passes.
91
+ *
92
+ * 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.
95
+ *
96
+ * Deliberately module-local: `envelopeExtras()` is the single production door
97
+ * to this value, so exporting the bare constant would add a second entry
98
+ * point that nothing in production reaches. Callers and tests that need the
99
+ * string read it off `envelopeExtras().scoringSemantics`.
100
+ */
101
+ const SCORING_SEMANTICS = 'coverage-join-v2';
102
+
103
+ /**
104
+ * Envelope-level stamps this kind contributes beyond the shared envelope
105
+ * keys. Consumed by `writer.write` via the kind-module protocol.
106
+ *
107
+ * @returns {{scoringSemantics: string}}
108
+ */
109
+ export function envelopeExtras() {
110
+ return { scoringSemantics: SCORING_SEMANTICS };
111
+ }
112
+
78
113
  export function projectRow(row) {
79
114
  return {
80
115
  path: canonicalise(row.path ?? row.file),
@@ -372,6 +407,23 @@ export const CRAP_COMPAT_AXES = [
372
407
  : null,
373
408
  },
374
409
  kernelDriftAxis('CRAP'),
410
+ {
411
+ name: 'scoring-semantics-drift',
412
+ severity: 'fatal',
413
+ check: ({ baseline }) => {
414
+ if (!baseline) return null;
415
+ const stamped = baseline.scoringSemantics ?? null;
416
+ if (stamped === SCORING_SEMANTICS) return null;
417
+ return (
418
+ `[CRAP] scoring semantics changed: baseline=${stamped ?? '<unstamped>'} ` +
419
+ `running=${SCORING_SEMANTICS}. Rows scored by the previous per-method ` +
420
+ '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."
424
+ );
425
+ },
426
+ },
375
427
  {
376
428
  name: 'ts-transpiler-drift',
377
429
  severity: 'warn',
@@ -401,6 +453,30 @@ export function evaluateBaselineCompatibility(ctx) {
401
453
  return reduceCompatAxes(CRAP_COMPAT_AXES, ctx);
402
454
  }
403
455
 
456
+ /**
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.
468
+ *
469
+ * @param {object|null} baseline A loaded v2 baseline envelope.
470
+ * @returns {string|null} Operator-facing message, or null when compatible.
471
+ */
472
+ export function assertBaselineCompatible(baseline) {
473
+ 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;
478
+ }
479
+
404
480
  /**
405
481
  * Pure helper: resolve the CRAP baseline either from the working tree
406
482
  * (via `getCrapBaseline`) or, when `epicRef` is supplied, from
@@ -18,7 +18,8 @@
18
18
  // hand-edited while inside a story worktree — so downstream
19
19
  // consumers see canonical repo-relative paths.
20
20
  // 5. Returns the envelope's headline fields plus rows/rollup as a
21
- // narrow contract: `{ rollup, rows, kernelVersion, generatedAt }`.
21
+ // narrow contract: `{ rollup, rows, kernelVersion, generatedAt,
22
+ // scoringSemantics }`.
22
23
  //
23
24
  // Reader-only: the writer side lives in a sibling module (Story #1891).
24
25
  // No I/O happens here beyond reading the JSON file itself.
@@ -215,6 +216,11 @@ function readAndShape(kind, absolutePath) {
215
216
  rows,
216
217
  kernelVersion: parsed.kernelVersion,
217
218
  generatedAt: parsed.generatedAt,
219
+ // Story #4775 — carry the per-kind scoring-semantics stamp through the
220
+ // narrowing. The gate's compat check reads it off the LOADED envelope, so
221
+ // dropping it here would make every baseline look unstamped and fail the
222
+ // whole repo closed on a stamp that is actually present on disk.
223
+ scoringSemantics: parsed.scoringSemantics,
218
224
  };
219
225
  }
220
226
 
@@ -303,6 +309,11 @@ export function loadFile(absolutePath, opts = {}) {
303
309
  rows,
304
310
  kernelVersion: parsed.kernelVersion,
305
311
  generatedAt: parsed.generatedAt,
312
+ // Story #4775 — carry the per-kind scoring-semantics stamp through the
313
+ // narrowing. The gate's compat check reads it off the LOADED envelope, so
314
+ // dropping it here would make every baseline look unstamped and fail the
315
+ // whole repo closed on a stamp that is actually present on disk.
316
+ scoringSemantics: parsed.scoringSemantics,
306
317
  };
307
318
  }
308
319
 
@@ -355,11 +355,17 @@ const KIND_SCORER_BUILDERS = Object.freeze({
355
355
  * rather than crashing the refresh. The production crap/maintainability paths
356
356
  * never rely on this fallback — they inject an explicit, configured scorer.
357
357
  *
358
+ * Exported since Story #4776 so the full-scope drift detector
359
+ * (`check-baseline-drift.js`) re-scores through the *same* scorer that
360
+ * writes the baseline. A drift check scoring by a second, parallel
361
+ * implementation would report the two implementations' disagreement as
362
+ * drift, which is exactly the false signal it exists to rule out.
363
+ *
358
364
  * @param {string} kind
359
365
  * @param {{ cwd: string }} opts
360
366
  * @returns {((files: string[], opts: object) => Promise<object[]> | object[]) | undefined}
361
367
  */
362
- function resolveDefaultScorer(kind, { cwd } = {}) {
368
+ export function resolveDefaultScorer(kind, { cwd } = {}) {
363
369
  const builder = KIND_SCORER_BUILDERS[kind];
364
370
  if (typeof builder !== 'function') return undefined;
365
371
  const effectiveCwd = cwd ?? process.cwd();
@@ -184,6 +184,8 @@ export function write({
184
184
  rollup,
185
185
  kernelVersion: kernelVersion ?? currentKernelVersion(kind),
186
186
  generatedAt,
187
+ extras:
188
+ typeof mod.envelopeExtras === 'function' ? mod.envelopeExtras() : null,
187
189
  });
188
190
  assertEnvelope(envelope);
189
191
  return envelope;
@@ -258,10 +260,18 @@ export function writeFile(absPath, envelope, opts = {}) {
258
260
  // Canonical key order on the top-level envelope keeps diffs stable
259
261
  // across runs and platforms. Per-kind row keys retain their natural
260
262
  // declaration order; the row sort is done by `sortRows()`.
263
+ //
264
+ // Story #4775: the projection is deliberately explicit, so any per-kind
265
+ // envelope stamp (`scoringSemantics`) must be carried through by name or it
266
+ // is silently dropped on the way to disk — the stamp would then be present
267
+ // in memory, validated, and absent from the file it exists to protect.
261
268
  const canonical = {
262
269
  $schema: envelope.$schema,
263
270
  kernelVersion: envelope.kernelVersion,
264
271
  generatedAt: envelope.generatedAt,
272
+ ...(envelope.scoringSemantics === undefined
273
+ ? {}
274
+ : { scoringSemantics: envelope.scoringSemantics }),
265
275
  rollup: envelope.rollup,
266
276
  rows: envelope.rows,
267
277
  };