mandrel 2.16.0 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/docs/agentrc-reference.json +10 -0
- package/.agents/docs/configuration.md +9 -0
- package/.agents/docs/quality-gates.md +137 -0
- package/.agents/schemas/agentrc.schema.json +48 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +4 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/acceptance-eval.js +52 -12
- package/.agents/scripts/audit-to-stories.js +92 -25
- package/.agents/scripts/boot-sweep.js +67 -8
- package/.agents/scripts/check-baseline-drift.js +138 -0
- package/.agents/scripts/coverage-capture.js +74 -25
- package/.agents/scripts/deliver-recover.js +45 -18
- package/.agents/scripts/drain-pending-cleanup.js +67 -23
- package/.agents/scripts/generate-lens-checklists.js +81 -30
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
- package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
- package/.agents/scripts/lib/baselines/envelope.js +7 -0
- package/.agents/scripts/lib/baselines/kernel.js +31 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
- package/.agents/scripts/lib/baselines/reader.js +12 -1
- package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
- package/.agents/scripts/lib/baselines/writer.js +10 -0
- package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
- package/.agents/scripts/lib/cli-utils.js +48 -13
- package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
- package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
- package/.agents/scripts/lib/close-validation/runner.js +68 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
- package/.agents/scripts/lib/config/quality.js +40 -0
- package/.agents/scripts/lib/config/temp-paths.js +27 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +69 -0
- package/.agents/scripts/lib/coverage-utils.js +92 -9
- package/.agents/scripts/lib/crap-engine.js +113 -23
- package/.agents/scripts/lib/crap-utils.js +159 -93
- package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
- package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
- package/.agents/scripts/lib/observability/terse-result.js +7 -3
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +9 -5
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +15 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
- package/.agents/scripts/lib/single-story-sweep.js +11 -0
- package/.agents/scripts/lib/stdio-flush.js +71 -0
- package/.agents/scripts/lib/temp-retention.js +559 -0
- package/.agents/scripts/lib/transpile.js +133 -6
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
- package/.agents/scripts/lib/workers/crap-worker.js +49 -76
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
- package/.agents/scripts/nav-registry-diff.js +30 -8
- package/.agents/scripts/plan-run-epilogue.js +27 -11
- package/.agents/scripts/resolve-doc-tiers.js +18 -8
- package/.agents/scripts/single-story-close.js +9 -92
- package/.agents/scripts/single-story-init.js +1 -1
- package/.agents/scripts/sync-branch-from-base.js +6 -1
- package/.agents/scripts/update-crap-baseline.js +13 -0
- package/README.md +14 -6
- package/docs/CHANGELOG.md +36 -0
- package/lib/cli/version-helpers.js +7 -0
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
- package/package.json +5 -1
|
@@ -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
|
};
|