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.
- package/.agents/docs/configuration.md +1 -0
- package/.agents/docs/quality-gates.md +137 -0
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +52 -12
- package/.agents/scripts/audit-to-stories.js +92 -25
- package/.agents/scripts/boot-sweep.js +28 -6
- 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/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/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/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +14 -0
- 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/stdio-flush.js +71 -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/update-crap-baseline.js +13 -0
- package/README.md +14 -6
- package/docs/CHANGELOG.md +24 -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
|
@@ -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
|
-
|
|
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
|
-
|
|
90
|
-
(lens) =>
|
|
91
|
-
(lens) =>
|
|
94
|
+
lenses,
|
|
95
|
+
(lens) => fsImpl.existsSync(workflowPath(lens)),
|
|
96
|
+
(lens) => fsImpl.readFileSync(workflowPath(lens), 'utf8'),
|
|
92
97
|
);
|
|
93
98
|
|
|
94
|
-
const onDisk =
|
|
95
|
-
?
|
|
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(
|
|
113
|
+
function relChecklist(
|
|
114
|
+
basename,
|
|
115
|
+
checklistsDir = CHECKLISTS_DIR,
|
|
116
|
+
projectRoot = PROJECT_ROOT,
|
|
117
|
+
) {
|
|
107
118
|
return path
|
|
108
|
-
.relative(
|
|
119
|
+
.relative(projectRoot, path.join(checklistsDir, basename))
|
|
109
120
|
.split(path.sep)
|
|
110
121
|
.join('/');
|
|
111
122
|
}
|
|
112
123
|
|
|
113
124
|
/**
|
|
114
|
-
*
|
|
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
|
|
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
|
-
|
|
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(
|
|
135
|
-
const original =
|
|
136
|
-
?
|
|
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(
|
|
183
|
+
if (original !== content) drifted.push(rel(basename));
|
|
139
184
|
}
|
|
140
185
|
if (drifted.length === 0 && strays.length === 0) {
|
|
141
|
-
|
|
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): ${
|
|
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
|
-
|
|
201
|
+
fsImpl.mkdirSync(checklistsDir, { recursive: true });
|
|
157
202
|
let wrote = 0;
|
|
158
203
|
for (const [basename, content] of expected) {
|
|
159
|
-
const target = path.join(
|
|
160
|
-
const original =
|
|
161
|
-
?
|
|
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
|
-
|
|
209
|
+
fsImpl.writeFileSync(target, content, 'utf8');
|
|
165
210
|
wrote += 1;
|
|
166
211
|
}
|
|
167
212
|
for (const stray of strays) {
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
92
|
-
if (
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
...
|
|
235
|
-
...
|
|
236
|
-
...extractFilePaths(
|
|
237
|
-
...extractFilePaths(
|
|
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
|
+
}
|