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
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// .agents/scripts/lib/close-validation/projections/crap.js
|
|
2
|
+
/**
|
|
3
|
+
* crap.js — pre-merge CRAP ceiling projection helper (Story #4776).
|
|
4
|
+
*
|
|
5
|
+
* The CRAP analogue of `projections/maintainability.js`. Both answer the
|
|
6
|
+
* same operator question before the merge runs: *given what this branch
|
|
7
|
+
* changed, which committed baseline rows would the post-merge tree breach,
|
|
8
|
+
* and what is the exact remedy?*
|
|
9
|
+
*
|
|
10
|
+
* Advisory only. `check-baselines` already fails close-validation closed on
|
|
11
|
+
* a real regression; duplicating that here would double-gate the same
|
|
12
|
+
* defect. What this projection adds is the **refresh** half of the loop —
|
|
13
|
+
* naming the breaching methods and the `npm run crap:update` +
|
|
14
|
+
* `baseline-refresh:` remedy while the operator still has the branch in
|
|
15
|
+
* hand, so a corrected baseline does not rot back into staleness.
|
|
16
|
+
*
|
|
17
|
+
* The helper never throws and never mutates anything. Every failure path
|
|
18
|
+
* resolves to `{ ok: true, breaches: [], skipped: '<reason>' }` so the
|
|
19
|
+
* caller can treat the advisory as best-effort.
|
|
20
|
+
*
|
|
21
|
+
* Post-merge approximation: CRAP needs a coverage join, so — unlike the MI
|
|
22
|
+
* projection, which scores a `git show` blob — the scorer reads the working
|
|
23
|
+
* tree. Close-validation runs inside the Story worktree at the branch tip,
|
|
24
|
+
* which is exactly the content a squash-merge lands, so the approximation is
|
|
25
|
+
* exact whenever the merge applies cleanly.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import {
|
|
30
|
+
compareCrap,
|
|
31
|
+
filterRowsByFileScope,
|
|
32
|
+
} from '../../baselines/kinds/crap.js';
|
|
33
|
+
import { loadFile as loadBaselineFile } from '../../baselines/reader.js';
|
|
34
|
+
import { diffNameOnly } from '../../changed-files.js';
|
|
35
|
+
import { loadCoverage } from '../../coverage-utils.js';
|
|
36
|
+
import { scanAndScore } from '../../crap-utils.js';
|
|
37
|
+
import { cachedGitFetchSync } from '../../git/cached-fetch.js';
|
|
38
|
+
import { gitSpawn as defaultGitSpawn } from '../../git-utils.js';
|
|
39
|
+
import { MISSING_ARG_REASONS, validateProjectionInputs } from './inputs.js';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Default absolute tolerance on a projected CRAP score, shared with
|
|
43
|
+
* `check-crap`'s regression arm: floating-point noise must not register as
|
|
44
|
+
* a breach.
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_CRAP_TOLERANCE = 0.001;
|
|
47
|
+
|
|
48
|
+
/** Framework default for the new-method ceiling (`gates.crap.newMethodCeiling`). */
|
|
49
|
+
export const DEFAULT_NEW_METHOD_CEILING = 30;
|
|
50
|
+
|
|
51
|
+
/** Extensions the CRAP scanner can score. */
|
|
52
|
+
const SCORABLE = /\.(?:js|mjs|cjs|ts|tsx)$/;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Map the shared predicate's fine-grained `missing-*` reason onto the
|
|
56
|
+
* `missing-args` skipped-reason the sibling MI projection reports, so both
|
|
57
|
+
* projections speak one vocabulary at the advisory boundary.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} reason
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function normaliseSkipReason(reason) {
|
|
63
|
+
return MISSING_ARG_REASONS.has(reason) ? 'missing-args' : reason;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read the committed CRAP baseline and re-key its rows onto the `file`
|
|
68
|
+
* field `compareCrap` matches on (the on-disk v2 envelope keys on `path`).
|
|
69
|
+
* Returns `[]` when the baseline is absent or unreadable — the caller maps
|
|
70
|
+
* that to the `no-baseline` skip.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately module-local: `projectCrapBreaches`'s `loadBaseline` default
|
|
73
|
+
* is the single production door to it, and tests inject their own loader.
|
|
74
|
+
* Exporting it would add a second entry point nothing in production reaches
|
|
75
|
+
* — which is precisely the orphaning this Story exists to stop.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} baselinePath absolute path to `baselines/crap.json`
|
|
78
|
+
* @returns {Array<{file: string, method: string, startLine: number, crap: number}>}
|
|
79
|
+
*/
|
|
80
|
+
function loadCrapBaselineRows(baselinePath) {
|
|
81
|
+
let envelope;
|
|
82
|
+
try {
|
|
83
|
+
envelope = loadBaselineFile(baselinePath, { kind: 'crap' });
|
|
84
|
+
} catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
const rows = Array.isArray(envelope?.rows) ? envelope.rows : [];
|
|
88
|
+
return rows.map((row) => ({
|
|
89
|
+
file: row.path,
|
|
90
|
+
method: row.method,
|
|
91
|
+
startLine: row.startLine,
|
|
92
|
+
crap: row.crap,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the default working-tree CRAP scorer. Returns an async callable
|
|
98
|
+
* `(files) => rows | null`; `null` signals "coverage artifact missing under
|
|
99
|
+
* `requireCoverage`", which the projection reports as the `no-coverage`
|
|
100
|
+
* skip rather than as a clean run.
|
|
101
|
+
*
|
|
102
|
+
* @param {{
|
|
103
|
+
* cwd: string,
|
|
104
|
+
* targetDirs?: string[],
|
|
105
|
+
* ignoreGlobs?: string[],
|
|
106
|
+
* requireCoverage?: boolean,
|
|
107
|
+
* coveragePath?: string,
|
|
108
|
+
* }} opts
|
|
109
|
+
* @returns {(files: string[]) => Promise<Array<object>|null>}
|
|
110
|
+
*/
|
|
111
|
+
export function createCrapScorer({
|
|
112
|
+
cwd,
|
|
113
|
+
targetDirs = [],
|
|
114
|
+
ignoreGlobs = [],
|
|
115
|
+
requireCoverage = true,
|
|
116
|
+
coveragePath = 'coverage/coverage-final.json',
|
|
117
|
+
} = {}) {
|
|
118
|
+
return async (files) => {
|
|
119
|
+
const abs = path.isAbsolute(coveragePath)
|
|
120
|
+
? coveragePath
|
|
121
|
+
: path.resolve(cwd, coveragePath);
|
|
122
|
+
const coverage = loadCoverage(abs);
|
|
123
|
+
if (!coverage && requireCoverage) return null;
|
|
124
|
+
const { rows } = await scanAndScore({
|
|
125
|
+
targetDirs,
|
|
126
|
+
coverage,
|
|
127
|
+
requireCoverage,
|
|
128
|
+
cwd,
|
|
129
|
+
ignoreGlobs,
|
|
130
|
+
scopeFiles: files,
|
|
131
|
+
});
|
|
132
|
+
return rows ?? [];
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Refresh `origin/<baseBranch>` so the diff range resolves even when close
|
|
138
|
+
* has not reached its own base-sync step. Routed through the shared fetch
|
|
139
|
+
* cache so a story-init fetch in the same run satisfies it for free.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} cwd
|
|
142
|
+
* @param {string} baseBranch
|
|
143
|
+
* @param {{ gitSpawn: typeof defaultGitSpawn }} git
|
|
144
|
+
* @returns {{ ok: true } | { ok: false, detail: string }}
|
|
145
|
+
*/
|
|
146
|
+
function refreshBaseRef(cwd, baseBranch, git) {
|
|
147
|
+
const res = cachedGitFetchSync(cwd, baseBranch, { gitSpawn: git.gitSpawn });
|
|
148
|
+
if (res.status !== 0) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
detail: res.stderr || res.stdout || `exit ${res.status}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return { ok: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Enumerate the Story branch's changed files, narrowed to the extensions
|
|
159
|
+
* the CRAP scanner can score.
|
|
160
|
+
*
|
|
161
|
+
* @param {{ cwd: string, baseBranch: string, storyBranch: string, git: { gitSpawn: typeof defaultGitSpawn } }} opts
|
|
162
|
+
* @returns {{ ok: true, files: string[] } | { ok: false, detail: string }}
|
|
163
|
+
*/
|
|
164
|
+
function diffScorableFiles({ cwd, baseBranch, storyBranch, git }) {
|
|
165
|
+
try {
|
|
166
|
+
const files = diffNameOnly({
|
|
167
|
+
range: `origin/${baseBranch}...${storyBranch}`,
|
|
168
|
+
cwd,
|
|
169
|
+
gitSpawn: git.gitSpawn,
|
|
170
|
+
});
|
|
171
|
+
return { ok: true, files: files.filter((f) => SCORABLE.test(f)) };
|
|
172
|
+
} catch (err) {
|
|
173
|
+
return { ok: false, detail: err.message };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Project the post-merge CRAP scores for every file changed on the Story
|
|
179
|
+
* branch and return the subset of methods that would breach either their
|
|
180
|
+
* committed baseline row or, for methods with no baseline row, the
|
|
181
|
+
* configured `newMethodCeiling`.
|
|
182
|
+
*
|
|
183
|
+
* Baseline rows are narrowed to the changed-file set before the compare so
|
|
184
|
+
* every untouched file's rows are not spuriously reported as removed.
|
|
185
|
+
*
|
|
186
|
+
* @param {{
|
|
187
|
+
* cwd: string,
|
|
188
|
+
* baseBranch: string,
|
|
189
|
+
* storyBranch: string,
|
|
190
|
+
* baselinePath: string,
|
|
191
|
+
* newMethodCeiling?: number,
|
|
192
|
+
* tolerance?: number,
|
|
193
|
+
* git?: { gitSpawn: typeof defaultGitSpawn },
|
|
194
|
+
* loadBaseline?: (path: string) => Array<object>,
|
|
195
|
+
* scoreFiles?: (files: string[]) => Promise<Array<object>|null>|Array<object>|null,
|
|
196
|
+
* }} opts
|
|
197
|
+
* @returns {Promise<{
|
|
198
|
+
* ok: boolean,
|
|
199
|
+
* breaches: Array<object>,
|
|
200
|
+
* skipped?: string,
|
|
201
|
+
* detail?: string,
|
|
202
|
+
* }>}
|
|
203
|
+
*/
|
|
204
|
+
export async function projectCrapBreaches({
|
|
205
|
+
cwd,
|
|
206
|
+
baseBranch,
|
|
207
|
+
storyBranch,
|
|
208
|
+
baselinePath,
|
|
209
|
+
newMethodCeiling = DEFAULT_NEW_METHOD_CEILING,
|
|
210
|
+
tolerance = DEFAULT_CRAP_TOLERANCE,
|
|
211
|
+
git = { gitSpawn: defaultGitSpawn },
|
|
212
|
+
loadBaseline = loadCrapBaselineRows,
|
|
213
|
+
scoreFiles,
|
|
214
|
+
} = {}) {
|
|
215
|
+
const skip = (reason, detail) => ({
|
|
216
|
+
ok: true,
|
|
217
|
+
breaches: [],
|
|
218
|
+
skipped: reason,
|
|
219
|
+
...(detail === undefined ? {} : { detail }),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const validation = validateProjectionInputs({
|
|
223
|
+
cwd,
|
|
224
|
+
baseBranch,
|
|
225
|
+
storyBranch,
|
|
226
|
+
baselinePath,
|
|
227
|
+
});
|
|
228
|
+
if (!validation.ok) return skip(normaliseSkipReason(validation.reason));
|
|
229
|
+
|
|
230
|
+
const baselineRows = loadBaseline(baselinePath);
|
|
231
|
+
if (!Array.isArray(baselineRows) || baselineRows.length === 0) {
|
|
232
|
+
return skip('no-baseline');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const fetched = refreshBaseRef(cwd, baseBranch, git);
|
|
236
|
+
if (!fetched.ok) return skip('fetch-failed', fetched.detail);
|
|
237
|
+
|
|
238
|
+
const diffed = diffScorableFiles({ cwd, baseBranch, storyBranch, git });
|
|
239
|
+
if (!diffed.ok) return skip('diff-failed', diffed.detail);
|
|
240
|
+
if (diffed.files.length === 0) return skip('no-scorable-files');
|
|
241
|
+
|
|
242
|
+
const scorer =
|
|
243
|
+
typeof scoreFiles === 'function' ? scoreFiles : createCrapScorer({ cwd });
|
|
244
|
+
let currentRows;
|
|
245
|
+
try {
|
|
246
|
+
currentRows = await scorer(diffed.files);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
return skip('score-failed', err?.message ?? String(err));
|
|
249
|
+
}
|
|
250
|
+
if (currentRows === null || currentRows === undefined) {
|
|
251
|
+
return skip('no-coverage');
|
|
252
|
+
}
|
|
253
|
+
if (currentRows.length === 0) return skip('no-scored-methods');
|
|
254
|
+
|
|
255
|
+
const scopeSet = new Set(diffed.files);
|
|
256
|
+
const result = compareCrap({
|
|
257
|
+
currentRows,
|
|
258
|
+
baselineRows: filterRowsByFileScope(baselineRows, scopeSet),
|
|
259
|
+
newMethodCeiling,
|
|
260
|
+
tolerance,
|
|
261
|
+
});
|
|
262
|
+
const breaches = result.violations ?? [];
|
|
263
|
+
return { ok: breaches.length === 0, breaches };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Render one breach as an advisory bullet. New-method breaches name the
|
|
268
|
+
* ceiling they cleared; regressions name the baseline row they exceeded.
|
|
269
|
+
*
|
|
270
|
+
* @param {object} b
|
|
271
|
+
* @returns {string}
|
|
272
|
+
*/
|
|
273
|
+
function formatBreach(b) {
|
|
274
|
+
const where = `${b.file}::${b.method} (line ${b.startLine})`;
|
|
275
|
+
const projected = Number(b.crap ?? 0).toFixed(2);
|
|
276
|
+
if (b.kind === 'new') {
|
|
277
|
+
return ` • ${where} projected=${projected} ceiling=${b.ceiling} [new method]`;
|
|
278
|
+
}
|
|
279
|
+
const baseline = Number(b.baseline ?? 0).toFixed(2);
|
|
280
|
+
return ` • ${where} projected=${projected} baseline=${baseline} [${b.kind}]`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Render the pre-merge CRAP advisory as a human-readable multi-line log
|
|
285
|
+
* block, naming every breaching method and the exact refresh remedy.
|
|
286
|
+
* Returns `null` when there is nothing to surface so callers can `if` past
|
|
287
|
+
* the log call without a string-empty check.
|
|
288
|
+
*
|
|
289
|
+
* @param {Awaited<ReturnType<typeof projectCrapBreaches>>} result
|
|
290
|
+
* @returns {string | null}
|
|
291
|
+
*/
|
|
292
|
+
export function formatCrapProjection(result) {
|
|
293
|
+
if (!result || !Array.isArray(result.breaches)) return null;
|
|
294
|
+
if (result.breaches.length === 0) return null;
|
|
295
|
+
const lines = [
|
|
296
|
+
`[close-validation] ⚠ Pre-merge CRAP projection: ${result.breaches.length} method(s) would breach post-merge:`,
|
|
297
|
+
];
|
|
298
|
+
for (const b of result.breaches) lines.push(formatBreach(b));
|
|
299
|
+
lines.push(
|
|
300
|
+
'[close-validation] To land cleanly, run `npm run crap:update` and commit the refreshed baseline with a `baseline-refresh:` tagged subject (non-empty body) on the story branch before re-running close.',
|
|
301
|
+
);
|
|
302
|
+
return lines.join('\n');
|
|
303
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from './commands.js';
|
|
20
20
|
import { DEFAULT_GATES, partitionGates } from './gates.js';
|
|
21
21
|
import { defaultGateRunner } from './process.js';
|
|
22
|
+
import { runProjectionAdvisories as defaultRunProjections } from './projections/advisories.js';
|
|
22
23
|
import { defaultGetHeadSha } from './projections/head-sha.js';
|
|
23
24
|
|
|
24
25
|
/** @typedef {import('./gates.js').Gate} Gate */
|
|
@@ -90,10 +91,23 @@ function applyChangedFileScope({ gate, spawnCwd, log }) {
|
|
|
90
91
|
* story-close uses it to drive `phaseTimer.mark(...)` for per-gate
|
|
91
92
|
* wall-clock telemetry. Errors thrown from the hook propagate.
|
|
92
93
|
*
|
|
94
|
+
* Projection advisories (Story #4776): when `baseBranch` and `storyBranch`
|
|
95
|
+
* are both supplied and every gate passed, the maintainability and CRAP
|
|
96
|
+
* pre-merge projections run through `projections/advisories.js` and log
|
|
97
|
+
* their advisories to the same `log` sink the gates use. They are advisory
|
|
98
|
+
* by construction — the returned `ok` is decided entirely by the gates, so
|
|
99
|
+
* a projected breach never fails a close. They are skipped after a gate
|
|
100
|
+
* failure, where the operator needs the failing gate's evidence, not a
|
|
101
|
+
* baseline-refresh nudge.
|
|
102
|
+
*
|
|
93
103
|
* @param {{
|
|
94
104
|
* cwd: string,
|
|
95
105
|
* worktreePath?: string,
|
|
96
106
|
* gates?: Gate[],
|
|
107
|
+
* baseBranch?: string|null,
|
|
108
|
+
* storyBranch?: string|null,
|
|
109
|
+
* config?: object|null,
|
|
110
|
+
* runProjections?: typeof defaultRunProjections,
|
|
97
111
|
* runner?: (cmd: string, args: string[], opts: { cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void }) => Promise<{ status: number }> | { status: number },
|
|
98
112
|
* log?: (m: string) => void,
|
|
99
113
|
* onGateStart?: (gate: Gate) => void,
|
|
@@ -114,6 +128,10 @@ export async function runCloseValidation({
|
|
|
114
128
|
runner = defaultGateRunner,
|
|
115
129
|
log = () => {},
|
|
116
130
|
onGateStart,
|
|
131
|
+
baseBranch = null,
|
|
132
|
+
storyBranch = null,
|
|
133
|
+
config = null,
|
|
134
|
+
runProjections = defaultRunProjections,
|
|
117
135
|
storyId = null,
|
|
118
136
|
standalone = false,
|
|
119
137
|
useEvidence = true,
|
|
@@ -346,5 +364,55 @@ export async function runCloseValidation({
|
|
|
346
364
|
);
|
|
347
365
|
}
|
|
348
366
|
|
|
367
|
+
// ── Phase 3: advisory projections ───────────────────────────────────
|
|
368
|
+
// Story #4776 — the projection layer's live call site. Deliberately
|
|
369
|
+
// outside the `ok` computation: a projected breach informs, it never
|
|
370
|
+
// fails a close.
|
|
371
|
+
if (failed.length === 0) {
|
|
372
|
+
await runAdvisoryProjections({
|
|
373
|
+
runProjections,
|
|
374
|
+
cwd: spawnCwd,
|
|
375
|
+
baseBranch,
|
|
376
|
+
storyBranch,
|
|
377
|
+
config,
|
|
378
|
+
log,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
349
382
|
return { ok: failed.length === 0, failed, skipped };
|
|
350
383
|
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Phase 3 helper — run the advisory projections, absorbing every failure.
|
|
387
|
+
*
|
|
388
|
+
* No-ops without a branch pair to diff (resume / legacy callers), and can
|
|
389
|
+
* never influence the close verdict: the caller has already decided `ok`
|
|
390
|
+
* before this runs, and a throw here is logged, not propagated.
|
|
391
|
+
*
|
|
392
|
+
* @param {{
|
|
393
|
+
* runProjections: typeof defaultRunProjections,
|
|
394
|
+
* cwd: string,
|
|
395
|
+
* baseBranch: string|null,
|
|
396
|
+
* storyBranch: string|null,
|
|
397
|
+
* config: object|null,
|
|
398
|
+
* log: (m: string) => void,
|
|
399
|
+
* }} opts
|
|
400
|
+
* @returns {Promise<void>}
|
|
401
|
+
*/
|
|
402
|
+
async function runAdvisoryProjections({
|
|
403
|
+
runProjections,
|
|
404
|
+
cwd,
|
|
405
|
+
baseBranch,
|
|
406
|
+
storyBranch,
|
|
407
|
+
config,
|
|
408
|
+
log,
|
|
409
|
+
}) {
|
|
410
|
+
if (!(baseBranch && storyBranch)) return;
|
|
411
|
+
try {
|
|
412
|
+
await runProjections({ cwd, baseBranch, storyBranch, config, log });
|
|
413
|
+
} catch (err) {
|
|
414
|
+
log(
|
|
415
|
+
`[close-validation] ⚠ projection advisories skipped: ${err?.message ?? err}`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -13,6 +13,13 @@ export const CRAP_GATE = {
|
|
|
13
13
|
targetDirs: LIST_OR_EXTENDER_OF_STRINGS,
|
|
14
14
|
newMethodCeiling: { type: 'integer', minimum: 1 },
|
|
15
15
|
requireCoverage: { type: 'boolean' },
|
|
16
|
+
// Story #4775 — fail-closed floor on the per-method coverage JOIN: the
|
|
17
|
+
// fraction of methods that must resolve a coverage entry, counted only
|
|
18
|
+
// over files that HAVE one, before `update-crap-baseline.js` will
|
|
19
|
+
// persist. A broken join is silent by construction (unresolved methods
|
|
20
|
+
// are simply absent), so the updater refuses rather than writing a thin
|
|
21
|
+
// baseline and logging it as success. Default 0.75.
|
|
22
|
+
minMethodResolutionRate: { type: 'number', minimum: 0, maximum: 1 },
|
|
16
23
|
friction: {
|
|
17
24
|
type: 'object',
|
|
18
25
|
properties: { markerKey: { type: 'string', minLength: 1 } },
|
|
@@ -88,6 +88,15 @@ export const CRAP_GATE_DEFAULTS = Object.freeze({
|
|
|
88
88
|
// semantics.
|
|
89
89
|
refreshTimeoutMs: 60_000,
|
|
90
90
|
ignoreGlobs: Object.freeze([]),
|
|
91
|
+
// Story #4775 — fail-closed floor on the per-method coverage JOIN. The
|
|
92
|
+
// fraction of methods that must resolve a coverage entry, counted only over
|
|
93
|
+
// files that HAVE one, before `update-crap-baseline.js` will persist. A
|
|
94
|
+
// broken join is silent by construction (unresolved methods are simply
|
|
95
|
+
// absent from the baseline), so the updater refuses rather than writing a
|
|
96
|
+
// thin baseline and logging it as success. 0.75 sits far above a healthy
|
|
97
|
+
// run (a repo with fresh coverage resolves ~98%) and far below the 4–6%
|
|
98
|
+
// signature of a coordinate-system mismatch.
|
|
99
|
+
minMethodResolutionRate: 0.75,
|
|
91
100
|
});
|
|
92
101
|
|
|
93
102
|
/** Framework defaults for the coverage gate. */
|
|
@@ -121,6 +130,15 @@ export const MAINTAINABILITY_GATE_DEFAULTS = Object.freeze({
|
|
|
121
130
|
// spawned by the baseline-attribution refresh path. Defaults to 60 s.
|
|
122
131
|
refreshTimeoutMs: 60_000,
|
|
123
132
|
ignoreGlobs: Object.freeze([]),
|
|
133
|
+
// Story #4775 — fail-closed floor on the per-method coverage JOIN. The
|
|
134
|
+
// fraction of methods that must resolve a coverage entry, counted only over
|
|
135
|
+
// files that HAVE one, before `update-crap-baseline.js` will persist. A
|
|
136
|
+
// broken join is silent by construction (unresolved methods are simply
|
|
137
|
+
// absent from the baseline), so the updater refuses rather than writing a
|
|
138
|
+
// thin baseline and logging it as success. 0.75 sits far above a healthy
|
|
139
|
+
// run (a repo with fresh coverage resolves ~98%) and far below the 4–6%
|
|
140
|
+
// signature of a coordinate-system mismatch.
|
|
141
|
+
minMethodResolutionRate: 0.75,
|
|
124
142
|
});
|
|
125
143
|
|
|
126
144
|
/**
|
|
@@ -144,6 +162,7 @@ const CRAP_GATE_KEYS = new Set([
|
|
|
144
162
|
'refreshTag',
|
|
145
163
|
'refreshTimeoutMs',
|
|
146
164
|
'ignoreGlobs',
|
|
165
|
+
'minMethodResolutionRate',
|
|
147
166
|
]);
|
|
148
167
|
|
|
149
168
|
const COVERAGE_GATE_KEYS = new Set([
|
|
@@ -205,6 +224,22 @@ function warnUnknownKeys(userBlock, knownKeys, blockLabel) {
|
|
|
205
224
|
* @param {{ coveragePath: string }} coverageGate resolved coverage gate
|
|
206
225
|
* @returns {object} flattened legacy-bag view that existing callers read
|
|
207
226
|
*/
|
|
227
|
+
/**
|
|
228
|
+
* Clamp a user-supplied method-resolution floor into `[0, 1]`. A
|
|
229
|
+
* non-numeric, non-finite, or out-of-range value falls back to the framework
|
|
230
|
+
* default rather than silently disabling the guard (a floor of `NaN` would
|
|
231
|
+
* compare false against every rate and never fire).
|
|
232
|
+
*
|
|
233
|
+
* @param {unknown} value
|
|
234
|
+
* @param {number} fallback
|
|
235
|
+
* @returns {number}
|
|
236
|
+
*/
|
|
237
|
+
function resolveResolutionRate(value, fallback) {
|
|
238
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
|
239
|
+
if (value < 0 || value > 1) return fallback;
|
|
240
|
+
return value;
|
|
241
|
+
}
|
|
242
|
+
|
|
208
243
|
export function resolveMaintainabilityCrap(
|
|
209
244
|
userCrap,
|
|
210
245
|
gateScoping,
|
|
@@ -227,6 +262,7 @@ export function resolveMaintainabilityCrap(
|
|
|
227
262
|
DEFAULT_CRAP_TOLERANCE.value,
|
|
228
263
|
),
|
|
229
264
|
requireCoverage: defaults.requireCoverage,
|
|
265
|
+
minMethodResolutionRate: defaults.minMethodResolutionRate,
|
|
230
266
|
friction: { ...defaults.friction },
|
|
231
267
|
refreshTag: defaults.refreshTag,
|
|
232
268
|
refreshTimeoutMs: defaults.refreshTimeoutMs,
|
|
@@ -248,6 +284,10 @@ export function resolveMaintainabilityCrap(
|
|
|
248
284
|
toleranceScalar(defaults.tolerance, DEFAULT_CRAP_TOLERANCE.value),
|
|
249
285
|
),
|
|
250
286
|
requireCoverage: userCrap.requireCoverage ?? defaults.requireCoverage,
|
|
287
|
+
minMethodResolutionRate: resolveResolutionRate(
|
|
288
|
+
userCrap.minMethodResolutionRate,
|
|
289
|
+
defaults.minMethodResolutionRate,
|
|
290
|
+
),
|
|
251
291
|
friction: { ...defaults.friction, ...(userCrap.friction ?? {}) },
|
|
252
292
|
refreshTag: userCrap.refreshTag ?? defaults.refreshTag,
|
|
253
293
|
refreshTimeoutMs: resolvePositiveIntegerMs(
|
|
@@ -121,6 +121,8 @@ export function hasCoverageFor(map, relPath) {
|
|
|
121
121
|
* raw `fnMap` entry — so callers may key by the escomplex `lineStart`
|
|
122
122
|
* (which can match either, depending on producer).
|
|
123
123
|
* - `fnLocByStartLine`: same keying, value is `{fnStart, fnEnd}` derived once.
|
|
124
|
+
* - `fnRanges`: every function's `{fnStart, fnEnd, declLine}` triple, used by
|
|
125
|
+
* the containment / nearest-decl fallbacks when exact-line keying misses.
|
|
124
126
|
* - `statementsByLine`: `Map<line, {total, covered}>` so range scans don't
|
|
125
127
|
* re-walk the full statement map.
|
|
126
128
|
*
|
|
@@ -129,9 +131,10 @@ export function hasCoverageFor(map, relPath) {
|
|
|
129
131
|
export function buildEntryIndex(entry) {
|
|
130
132
|
const fnByStartLine = new Map();
|
|
131
133
|
const fnLocByStartLine = new Map();
|
|
134
|
+
const fnRanges = [];
|
|
132
135
|
const statementsByLine = new Map();
|
|
133
136
|
if (!entry || typeof entry !== 'object') {
|
|
134
|
-
return { fnByStartLine, fnLocByStartLine, statementsByLine };
|
|
137
|
+
return { fnByStartLine, fnLocByStartLine, fnRanges, statementsByLine };
|
|
135
138
|
}
|
|
136
139
|
const fnMap = entry.fnMap ?? {};
|
|
137
140
|
const statementMap = entry.statementMap ?? {};
|
|
@@ -152,6 +155,13 @@ export function buildEntryIndex(entry) {
|
|
|
152
155
|
fnByStartLine.set(locLine, f);
|
|
153
156
|
fnLocByStartLine.set(locLine, loc);
|
|
154
157
|
}
|
|
158
|
+
if (fnStart !== null && fnEnd !== null) {
|
|
159
|
+
fnRanges.push({
|
|
160
|
+
fnStart,
|
|
161
|
+
fnEnd,
|
|
162
|
+
declLine: typeof declLine === 'number' ? declLine : fnStart,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
155
165
|
}
|
|
156
166
|
|
|
157
167
|
for (const stmtId of Object.keys(statementMap)) {
|
|
@@ -167,7 +177,76 @@ export function buildEntryIndex(entry) {
|
|
|
167
177
|
if ((statementHits[stmtId] ?? 0) > 0) bucket.covered += 1;
|
|
168
178
|
}
|
|
169
179
|
|
|
170
|
-
return { fnByStartLine, fnLocByStartLine, statementsByLine };
|
|
180
|
+
return { fnByStartLine, fnLocByStartLine, fnRanges, statementsByLine };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* How far from a `fnMap` declaration line a method start may sit and still
|
|
185
|
+
* be considered the same function.
|
|
186
|
+
*
|
|
187
|
+
* Even after remapping to original-source coordinates (Story #4775), a
|
|
188
|
+
* method's start and istanbul's `decl.start.line` do not always agree on the
|
|
189
|
+
* token: escomplex anchors on the function node, istanbul on the declaration
|
|
190
|
+
* it instruments, and a decorator, a leading `export`, or a multi-line
|
|
191
|
+
* parameter list puts them one line apart. One line of slack absorbs that
|
|
192
|
+
* without letting an unrelated neighbouring function be claimed.
|
|
193
|
+
*/
|
|
194
|
+
const DECL_MATCH_WINDOW = 1;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Resolve the `{fnStart, fnEnd}` range of the function a method start line
|
|
198
|
+
* belongs to, in the coordinate system of the coverage entry.
|
|
199
|
+
*
|
|
200
|
+
* Three strategies, most precise first:
|
|
201
|
+
*
|
|
202
|
+
* 1. **Exact** — the line keys a `fnMap` `decl.start.line` or
|
|
203
|
+
* `loc.start.line`. This is the pre-#4775 behaviour and still wins, so
|
|
204
|
+
* every already-resolving row keeps its exact prior value.
|
|
205
|
+
* 2. **Containment** — the innermost function whose `loc` range contains
|
|
206
|
+
* the line. Smallest span wins, so a nested callback is preferred over
|
|
207
|
+
* the enclosing function that also contains the line.
|
|
208
|
+
* 3. **Nearest declaration** — the closest `decl` line within
|
|
209
|
+
* `DECL_MATCH_WINDOW`, which absorbs the ±1 token disagreement between
|
|
210
|
+
* escomplex's method start and istanbul's declaration line.
|
|
211
|
+
*
|
|
212
|
+
* Returns `null` when none of the three finds a function — the caller
|
|
213
|
+
* surfaces that as "no data" rather than "tested zero times."
|
|
214
|
+
*
|
|
215
|
+
* @param {{fnByStartLine: Map, fnLocByStartLine: Map, fnRanges: Array}} idx
|
|
216
|
+
* @param {number} startLine
|
|
217
|
+
* @returns {{fnStart: number, fnEnd: number}|null}
|
|
218
|
+
*/
|
|
219
|
+
function resolveFnRangeForLine(idx, startLine) {
|
|
220
|
+
if (typeof startLine !== 'number') return null;
|
|
221
|
+
if (idx.fnByStartLine.has(startLine)) {
|
|
222
|
+
const loc = idx.fnLocByStartLine.get(startLine);
|
|
223
|
+
if (loc && loc.fnStart !== null && loc.fnEnd !== null) return loc;
|
|
224
|
+
}
|
|
225
|
+
const ranges = idx.fnRanges ?? [];
|
|
226
|
+
let innermost = null;
|
|
227
|
+
let innermostSpan = Number.POSITIVE_INFINITY;
|
|
228
|
+
for (const range of ranges) {
|
|
229
|
+
if (startLine < range.fnStart || startLine > range.fnEnd) continue;
|
|
230
|
+
const span = range.fnEnd - range.fnStart;
|
|
231
|
+
if (span < innermostSpan) {
|
|
232
|
+
innermostSpan = span;
|
|
233
|
+
innermost = range;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (innermost) return { fnStart: innermost.fnStart, fnEnd: innermost.fnEnd };
|
|
237
|
+
|
|
238
|
+
let nearest = null;
|
|
239
|
+
let nearestDist = Number.POSITIVE_INFINITY;
|
|
240
|
+
for (const range of ranges) {
|
|
241
|
+
const dist = Math.abs(range.declLine - startLine);
|
|
242
|
+
if (dist > DECL_MATCH_WINDOW) continue;
|
|
243
|
+
if (dist < nearestDist) {
|
|
244
|
+
nearestDist = dist;
|
|
245
|
+
nearest = range;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (nearest) return { fnStart: nearest.fnStart, fnEnd: nearest.fnEnd };
|
|
249
|
+
return null;
|
|
171
250
|
}
|
|
172
251
|
|
|
173
252
|
function getEntryIndex(entry) {
|
|
@@ -193,22 +272,26 @@ function getEntryIndex(entry) {
|
|
|
193
272
|
* returns 0. A missing / malformed entry or no matching function returns
|
|
194
273
|
* `null` so the caller can distinguish "no data" from "tested zero times."
|
|
195
274
|
*
|
|
275
|
+
* `startLine` MUST be in the coverage entry's own (original-source)
|
|
276
|
+
* coordinate system. Callers scoring transpiled TypeScript remap escomplex's
|
|
277
|
+
* transpiled `lineStart` first — see `transpileIfNeeded`'s `withLineMap`
|
|
278
|
+
* option (Story #4775). Matching is exact-then-containment-then-nearest-decl;
|
|
279
|
+
* see `resolveFnRangeForLine`.
|
|
280
|
+
*
|
|
196
281
|
* The first call on a given entry builds and caches a per-entry index via a
|
|
197
282
|
* non-enumerable Symbol property; consecutive method lookups in the same
|
|
198
283
|
* file pay the build cost exactly once.
|
|
199
284
|
*
|
|
200
285
|
* @param {object|null} entry One inner value from a `coverage-final.json` map.
|
|
201
|
-
* @param {number} startLine The
|
|
286
|
+
* @param {number} startLine The method's start line, in entry coordinates.
|
|
202
287
|
* @returns {number|null}
|
|
203
288
|
*/
|
|
204
289
|
export function coverageForMethodInEntry(entry, startLine) {
|
|
205
290
|
if (!entry || typeof entry !== 'object') return null;
|
|
206
291
|
const idx = getEntryIndex(entry);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const { fnStart, fnEnd } = loc;
|
|
211
|
-
if (fnStart === null || fnEnd === null) return null;
|
|
292
|
+
const range = resolveFnRangeForLine(idx, startLine);
|
|
293
|
+
if (!range) return null;
|
|
294
|
+
const { fnStart, fnEnd } = range;
|
|
212
295
|
|
|
213
296
|
let total = 0;
|
|
214
297
|
let covered = 0;
|
|
@@ -228,7 +311,7 @@ export function coverageForMethodInEntry(entry, startLine) {
|
|
|
228
311
|
*
|
|
229
312
|
* @param {object|null} map Parsed `coverage-final.json`.
|
|
230
313
|
* @param {string} relPath Repo-relative path of the source file.
|
|
231
|
-
* @param {number} startLine The
|
|
314
|
+
* @param {number} startLine The method's start line, in entry coordinates.
|
|
232
315
|
* @returns {number|null} Coverage in [0, 1], or null when the file or method
|
|
233
316
|
* is absent.
|
|
234
317
|
*/
|