mandrel 2.16.0 → 2.17.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 (57) hide show
  1. package/.agents/docs/configuration.md +1 -0
  2. package/.agents/docs/quality-gates.md +137 -0
  3. package/.agents/schemas/agentrc.schema.json +6 -0
  4. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  5. package/.agents/schemas/baselines/crap.schema.json +4 -0
  6. package/.agents/scripts/acceptance-eval.js +52 -12
  7. package/.agents/scripts/audit-to-stories.js +92 -25
  8. package/.agents/scripts/boot-sweep.js +28 -6
  9. package/.agents/scripts/check-baseline-drift.js +138 -0
  10. package/.agents/scripts/coverage-capture.js +74 -25
  11. package/.agents/scripts/deliver-recover.js +45 -18
  12. package/.agents/scripts/drain-pending-cleanup.js +67 -23
  13. package/.agents/scripts/generate-lens-checklists.js +81 -30
  14. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
  15. package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
  16. package/.agents/scripts/lib/baselines/envelope.js +7 -0
  17. package/.agents/scripts/lib/baselines/kernel.js +31 -0
  18. package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
  19. package/.agents/scripts/lib/baselines/reader.js +12 -1
  20. package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
  21. package/.agents/scripts/lib/baselines/writer.js +10 -0
  22. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
  23. package/.agents/scripts/lib/cli-utils.js +48 -13
  24. package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
  25. package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
  26. package/.agents/scripts/lib/close-validation/runner.js +68 -0
  27. package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
  28. package/.agents/scripts/lib/config/quality.js +40 -0
  29. package/.agents/scripts/lib/coverage-utils.js +92 -9
  30. package/.agents/scripts/lib/crap-engine.js +113 -23
  31. package/.agents/scripts/lib/crap-utils.js +159 -93
  32. package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
  33. package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
  34. package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
  35. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
  36. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
  37. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
  38. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
  39. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +14 -0
  40. package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
  41. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
  42. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
  43. package/.agents/scripts/lib/stdio-flush.js +71 -0
  44. package/.agents/scripts/lib/transpile.js +133 -6
  45. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
  46. package/.agents/scripts/lib/workers/crap-worker.js +49 -76
  47. package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
  48. package/.agents/scripts/nav-registry-diff.js +30 -8
  49. package/.agents/scripts/plan-run-epilogue.js +27 -11
  50. package/.agents/scripts/resolve-doc-tiers.js +18 -8
  51. package/.agents/scripts/single-story-close.js +9 -92
  52. package/.agents/scripts/update-crap-baseline.js +13 -0
  53. package/README.md +14 -6
  54. package/docs/CHANGELOG.md +24 -0
  55. package/lib/cli/version-helpers.js +7 -0
  56. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
  57. package/package.json +5 -1
@@ -83,16 +83,21 @@ export function planChecklists(lenses, workflowExists, readWorkflow) {
83
83
  * content; `missing` lists lenses with no `audit-<lens>.md`; `strays` lists
84
84
  * on-disk checklist basenames that map to no current lens.
85
85
  */
86
- export function buildExpected() {
87
- const workflowPath = (lens) => path.join(WORKFLOWS_DIR, `audit-${lens}.md`);
86
+ export function buildExpected({
87
+ fsImpl = fs,
88
+ lenses = AUDIT_LENSES,
89
+ workflowsDir = WORKFLOWS_DIR,
90
+ checklistsDir = CHECKLISTS_DIR,
91
+ } = {}) {
92
+ const workflowPath = (lens) => path.join(workflowsDir, `audit-${lens}.md`);
88
93
  const { expected, missing } = planChecklists(
89
- AUDIT_LENSES,
90
- (lens) => fs.existsSync(workflowPath(lens)),
91
- (lens) => fs.readFileSync(workflowPath(lens), 'utf8'),
94
+ lenses,
95
+ (lens) => fsImpl.existsSync(workflowPath(lens)),
96
+ (lens) => fsImpl.readFileSync(workflowPath(lens), 'utf8'),
92
97
  );
93
98
 
94
- const onDisk = fs.existsSync(CHECKLISTS_DIR)
95
- ? fs.readdirSync(CHECKLISTS_DIR).filter((name) => name.endsWith('.md'))
99
+ const onDisk = fsImpl.existsSync(checklistsDir)
100
+ ? fsImpl.readdirSync(checklistsDir).filter((name) => name.endsWith('.md'))
96
101
  : [];
97
102
  const strays = onDisk.filter((name) => !expected.has(name));
98
103
 
@@ -101,29 +106,69 @@ export function buildExpected() {
101
106
 
102
107
  /**
103
108
  * @param {string} basename — e.g. `security.md`
109
+ * @param {string} [checklistsDir]
110
+ * @param {string} [projectRoot]
104
111
  * @returns {string} repo-relative POSIX path for messages.
105
112
  */
106
- function relChecklist(basename) {
113
+ function relChecklist(
114
+ basename,
115
+ checklistsDir = CHECKLISTS_DIR,
116
+ projectRoot = PROJECT_ROOT,
117
+ ) {
107
118
  return path
108
- .relative(PROJECT_ROOT, path.join(CHECKLISTS_DIR, basename))
119
+ .relative(projectRoot, path.join(checklistsDir, basename))
109
120
  .split(path.sep)
110
121
  .join('/');
111
122
  }
112
123
 
113
124
  /**
114
- * @param {string[]} argv
125
+ * The generator core, extracted from the CLI shell so both modes — the
126
+ * `--check` drift gate and the write/prune pass — are reachable without
127
+ * touching the real `.agents/audit-checklists` tree.
128
+ *
129
+ * Every seam on the optional final `deps` parameter defaults to the real
130
+ * implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so `main` and
131
+ * `npm run docs:check` are unchanged.
132
+ *
133
+ * @param {string[]} [argv]
134
+ * @param {{
135
+ * fsImpl?: typeof fs,
136
+ * lenses?: ReadonlyArray<string>,
137
+ * workflowsDir?: string,
138
+ * checklistsDir?: string,
139
+ * projectRoot?: string,
140
+ * logger?: { info: Function },
141
+ * }} [deps]
142
+ * @returns {Promise<{ wrote: number, pruned: number, checked?: boolean }>}
115
143
  */
116
- async function main(argv = process.argv.slice(2)) {
144
+ export async function runGenerateLensChecklists(
145
+ argv = process.argv.slice(2),
146
+ deps = {},
147
+ ) {
148
+ const {
149
+ fsImpl = fs,
150
+ lenses = AUDIT_LENSES,
151
+ workflowsDir = WORKFLOWS_DIR,
152
+ checklistsDir = CHECKLISTS_DIR,
153
+ projectRoot = PROJECT_ROOT,
154
+ logger = Logger,
155
+ } = deps;
117
156
  const { values } = parseArgs({
118
157
  args: argv,
119
158
  options: { check: { type: 'boolean', default: false } },
120
159
  allowPositionals: false,
121
160
  });
122
161
 
123
- const { expected, missing, strays } = buildExpected();
162
+ const { expected, missing, strays } = buildExpected({
163
+ fsImpl,
164
+ lenses,
165
+ workflowsDir,
166
+ checklistsDir,
167
+ });
168
+ const rel = (basename) => relChecklist(basename, checklistsDir, projectRoot);
124
169
 
125
170
  if (missing.length > 0) {
126
- Logger.info(
171
+ logger.info(
127
172
  `generate-lens-checklists: no audit-<lens>.md for: ${missing.join(', ')} — no checklist emitted.`,
128
173
  );
129
174
  }
@@ -131,21 +176,21 @@ async function main(argv = process.argv.slice(2)) {
131
176
  if (values.check) {
132
177
  const drifted = [];
133
178
  for (const [basename, content] of expected) {
134
- const target = path.join(CHECKLISTS_DIR, basename);
135
- const original = fs.existsSync(target)
136
- ? fs.readFileSync(target, 'utf8')
179
+ const target = path.join(checklistsDir, basename);
180
+ const original = fsImpl.existsSync(target)
181
+ ? fsImpl.readFileSync(target, 'utf8')
137
182
  : null;
138
- if (original !== content) drifted.push(relChecklist(basename));
183
+ if (original !== content) drifted.push(rel(basename));
139
184
  }
140
185
  if (drifted.length === 0 && strays.length === 0) {
141
- Logger.info(
186
+ logger.info(
142
187
  `generate-lens-checklists: ${expected.size} checklist(s) up to date.`,
143
188
  );
144
- return;
189
+ return { wrote: 0, pruned: 0, checked: true };
145
190
  }
146
191
  const problems = [
147
192
  ...drifted.map((p) => `out of date: ${p}`),
148
- ...strays.map((s) => `stray (no lens): ${relChecklist(s)}`),
193
+ ...strays.map((s) => `stray (no lens): ${rel(s)}`),
149
194
  ];
150
195
  throw new Error(
151
196
  `Lens checklists are out of sync:\n ${problems.join('\n ')}\n` +
@@ -153,26 +198,32 @@ async function main(argv = process.argv.slice(2)) {
153
198
  );
154
199
  }
155
200
 
156
- fs.mkdirSync(CHECKLISTS_DIR, { recursive: true });
201
+ fsImpl.mkdirSync(checklistsDir, { recursive: true });
157
202
  let wrote = 0;
158
203
  for (const [basename, content] of expected) {
159
- const target = path.join(CHECKLISTS_DIR, basename);
160
- const original = fs.existsSync(target)
161
- ? fs.readFileSync(target, 'utf8')
204
+ const target = path.join(checklistsDir, basename);
205
+ const original = fsImpl.existsSync(target)
206
+ ? fsImpl.readFileSync(target, 'utf8')
162
207
  : null;
163
208
  if (original === content) continue;
164
- fs.writeFileSync(target, content, 'utf8');
209
+ fsImpl.writeFileSync(target, content, 'utf8');
165
210
  wrote += 1;
166
211
  }
167
212
  for (const stray of strays) {
168
- fs.rmSync(path.join(CHECKLISTS_DIR, stray));
169
- Logger.info(
170
- `generate-lens-checklists: pruned stray ${relChecklist(stray)}`,
171
- );
213
+ fsImpl.rmSync(path.join(checklistsDir, stray));
214
+ logger.info(`generate-lens-checklists: pruned stray ${rel(stray)}`);
172
215
  }
173
- Logger.info(
216
+ logger.info(
174
217
  `generate-lens-checklists: wrote ${wrote} of ${expected.size} checklist(s) (${strays.length} pruned).`,
175
218
  );
219
+ return { wrote, pruned: strays.length };
220
+ }
221
+
222
+ /**
223
+ * @param {string[]} [argv]
224
+ */
225
+ async function main(argv = process.argv.slice(2)) {
226
+ await runGenerateLensChecklists(argv);
176
227
  }
177
228
 
178
229
  export { CHECKLISTS_DIR, WORKFLOWS_DIR };
@@ -31,6 +31,9 @@ const HEADING_FINDING = /^###\s+(.+?)\s*$/;
31
31
  const HEADING_SECTION = /^##\s+(.+?)\s*$/;
32
32
  const PATH_HINT =
33
33
  /(?<![\w/])([A-Za-z0-9_./\\@-]+\.(?:js|ts|tsx|jsx|mjs|cjs|md|json|yaml|yml|css|scss|html|py|go|rs|java|kt|rb|sh|ps1|tf|env))(?![\w])/g;
34
+ const FILE_EXT =
35
+ /\.(?:js|ts|tsx|jsx|mjs|cjs|md|json|yaml|yml|css|scss|html|py|go|rs|java|kt|rb|sh|ps1|tf|env)$/;
36
+ const TITLE_ANCHOR = /^\s*`([^`]+)`/;
34
37
 
35
38
  function unwrapInlineCode(value) {
36
39
  if (typeof value !== 'string') return '';
@@ -81,17 +84,81 @@ function deriveDimension(fields, fallbackDimension) {
81
84
  * @param {Record<string, string>} fields
82
85
  * @returns {string[]}
83
86
  */
84
- function deriveLocationFiles(fields) {
87
+ /**
88
+ * Normalise a raw path token to a repo-relative path, or `null` when the
89
+ * token is not a usable file reference.
90
+ *
91
+ * Three shapes historically leaked through and poisoned grouping downstream:
92
+ * an **absolute** path (which can never match a repo-relative group key), a
93
+ * **degenerate** token such as a bare `/`, and — most damagingly — a
94
+ * **root-level** file such as `AGENTS.md`, which the old slash-only guard
95
+ * discarded outright even when it was the finding's explicit `Location:`.
96
+ *
97
+ * `requireSeparator` is what keeps that third fix from over-reaching. In the
98
+ * **structured** fields — the title anchor and `Location:` — a bare
99
+ * `AGENTS.md` is unambiguously a file, so the separator is not required. In
100
+ * free **prose**, a bare `description.md` is far more likely to be a word than
101
+ * a path, so the separator stays mandatory there.
102
+ *
103
+ * @param {string} raw — the token as it appeared in the report.
104
+ * @param {string} [repoRoot] — absolute repo root; absolute tokens beneath it
105
+ * are relativised. Omitted (the pure default) leaves absolutes to be dropped.
106
+ * @param {{ requireSeparator?: boolean }} [options]
107
+ * @returns {string|null}
108
+ */
109
+ function normalisePathToken(raw, repoRoot, { requireSeparator = false } = {}) {
110
+ if (typeof raw !== 'string') return null;
111
+ const stripped = raw
112
+ .replace(/^[`'"([]+/, '')
113
+ .replace(/[`'")\].,;]+$/, '')
114
+ .trim();
115
+ // `Location:` anchors appear as `:line`, `:line:col`, and `:start-end`.
116
+ const cleaned = stripped.replace(/:\d+(?:-\d+)?(?::\d+)?$/, '');
117
+ if (!cleaned || cleaned === '.' || cleaned === '..') return null;
118
+
119
+ let out = cleaned;
120
+ if (typeof repoRoot === 'string' && repoRoot.length > 0) {
121
+ for (const sep of ['/', '\\']) {
122
+ const root = repoRoot.endsWith(sep) ? repoRoot : `${repoRoot}${sep}`;
123
+ if (out.startsWith(root)) {
124
+ out = out.slice(root.length);
125
+ break;
126
+ }
127
+ }
128
+ }
129
+ // A path that is still absolute lives outside the repo — it can never be a
130
+ // valid group key, so drop it rather than let it become one.
131
+ if (out.startsWith('/') || out.startsWith('\\') || /^[A-Za-z]:/.test(out)) {
132
+ return null;
133
+ }
134
+ const hasSeparator = out.includes('/') || out.includes('\\');
135
+ if (requireSeparator && !hasSeparator) return null;
136
+ if (!hasSeparator && !FILE_EXT.test(out)) return null;
137
+ return out;
138
+ }
139
+
140
+ /**
141
+ * The finding-block skeleton mandates that a title lead with the primary file
142
+ * the finding lives in (``### `path/to/file.ext` — title``). That anchor is
143
+ * the most reliable primary-file signal a block carries, so it seeds `files[]`
144
+ * ahead of `Location:` and prose scraping — `pickPrimaryFile` takes `files[0]`.
145
+ */
146
+ function deriveTitleFile(title, repoRoot) {
147
+ const match = TITLE_ANCHOR.exec(typeof title === 'string' ? title : '');
148
+ if (!match) return [];
149
+ const normalised = normalisePathToken(match[1], repoRoot);
150
+ return normalised ? [normalised] : [];
151
+ }
152
+
153
+ function deriveLocationFiles(fields, repoRoot) {
85
154
  const raw = fields.location;
86
155
  if (typeof raw !== 'string' || raw.trim().length === 0) return [];
87
156
  const cleaned = raw.replace(/[`[\]]/g, ' ');
88
157
  const out = [];
89
158
  for (const token of cleaned.split(/[\s,]+/)) {
90
159
  if (!token) continue;
91
- const withoutLine = token.replace(/:\d+(?::\d+)?$/, '');
92
- if (withoutLine.includes('/') || withoutLine.includes('\\')) {
93
- out.push(withoutLine);
94
- }
160
+ const normalised = normalisePathToken(token, repoRoot);
161
+ if (normalised) out.push(normalised);
95
162
  }
96
163
  return out;
97
164
  }
@@ -113,14 +180,15 @@ function normaliseTitle(title) {
113
180
  .trim();
114
181
  }
115
182
 
116
- function extractFilePaths(text) {
183
+ function extractFilePaths(text, repoRoot) {
117
184
  if (typeof text !== 'string') return [];
118
185
  const seen = new Set();
119
186
  for (const match of text.matchAll(PATH_HINT)) {
120
- const candidate = match[1].replace(/^[`'"]+|[`'"]+$/g, '');
121
- if (candidate.includes('/') || candidate.includes('\\')) {
122
- seen.add(candidate);
123
- }
187
+ // Prose: a bare `description.md` is more likely a word than a path.
188
+ const normalised = normalisePathToken(match[1], repoRoot, {
189
+ requireSeparator: true,
190
+ });
191
+ if (normalised) seen.add(normalised);
124
192
  }
125
193
  return [...seen];
126
194
  }
@@ -211,7 +279,7 @@ function parseBlockFields(bodyLines) {
211
279
  * sourceReport: string,
212
280
  * }>}
213
281
  */
214
- export function parseAuditReport({ markdown, sourceReport }) {
282
+ export function parseAuditReport({ markdown, sourceReport, repoRoot }) {
215
283
  if (typeof markdown !== 'string') {
216
284
  throw new Error('parseAuditReport: markdown must be a string');
217
285
  }
@@ -231,10 +299,11 @@ export function parseAuditReport({ markdown, sourceReport }) {
231
299
  fields['recommendation & rationale'] ?? fields.recommendation ?? '';
232
300
  const agentPrompt = fields['agent prompt'] ?? '';
233
301
  const fileSet = new Set([
234
- ...deriveLocationFiles(fields),
235
- ...extractFilePaths(currentState),
236
- ...extractFilePaths(recommendation),
237
- ...extractFilePaths(agentPrompt),
302
+ ...deriveTitleFile(block.title, repoRoot),
303
+ ...deriveLocationFiles(fields, repoRoot),
304
+ ...extractFilePaths(currentState, repoRoot),
305
+ ...extractFilePaths(recommendation, repoRoot),
306
+ ...extractFilePaths(agentPrompt, repoRoot),
238
307
  ]);
239
308
 
240
309
  return {
@@ -258,15 +327,17 @@ export function parseAuditReport({ markdown, sourceReport }) {
258
327
  * an audit can legitimately come back empty.
259
328
  *
260
329
  * @param {Array<{ markdown: string, sourceReport: string }>} reports
330
+ * @param {{ repoRoot?: string }} [options] — `repoRoot` relativises absolute
331
+ * paths quoted in a report so they can match repo-relative group keys.
261
332
  * @returns {Array<ReturnType<typeof parseAuditReport>[number]>}
262
333
  */
263
- export function parseAuditReports(reports) {
334
+ export function parseAuditReports(reports, { repoRoot } = {}) {
264
335
  if (!Array.isArray(reports)) {
265
336
  throw new Error('parseAuditReports: reports must be an array');
266
337
  }
267
338
  const out = [];
268
339
  for (const report of reports) {
269
- out.push(...parseAuditReport(report));
340
+ out.push(...parseAuditReport({ ...report, repoRoot }));
270
341
  }
271
342
  return out;
272
343
  }
@@ -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
+ }