mandrel 2.15.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/docs/workflows.md +2 -1
- 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-light.js +31 -3
- 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/process.js +61 -15
- 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/complexity-gate.js +307 -89
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/light-suitability.js +31 -9
- package/.agents/scripts/lib/orchestration/plan-context.js +190 -10
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +87 -13
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +38 -15
- 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-context.js +4 -1
- 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/.agents/workflows/helpers/deliver-light.md +34 -8
- package/.agents/workflows/helpers/plan-reference.md +27 -6
- package/.agents/workflows/plan.md +4 -2
- package/.agents/workflows/prototype.md +104 -0
- package/README.md +14 -6
- package/docs/CHANGELOG.md +41 -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,184 @@
|
|
|
1
|
+
// .agents/scripts/lib/close-validation/projections/advisories.js
|
|
2
|
+
/**
|
|
3
|
+
* advisories.js — the projection layer's single call site (Story #4776).
|
|
4
|
+
*
|
|
5
|
+
* `projections/maintainability.js` shipped fully written, fully unit-tested
|
|
6
|
+
* and imported by nothing: the v2 Epic-tier collapse removed its caller and
|
|
7
|
+
* left the module behind. The practical consequence was that the advisory
|
|
8
|
+
* telling an operator to run `npm run maintainability:update` and commit a
|
|
9
|
+
* `baseline-refresh:` subject had never fired in v2 — consumers refreshed
|
|
10
|
+
* their baselines by hand or not at all.
|
|
11
|
+
*
|
|
12
|
+
* This module is that caller, for both projections. It is deliberately the
|
|
13
|
+
* only door: `close-validation/runner.js` invokes `runProjectionAdvisories`
|
|
14
|
+
* once, after the gate chain has passed, and every per-kind concern (gate
|
|
15
|
+
* enablement, baseline path resolution, scorer construction, formatting)
|
|
16
|
+
* lives here rather than being re-derived at the runner boundary.
|
|
17
|
+
*
|
|
18
|
+
* **Advisory, always.** Nothing in here can fail a close. `check-baselines`
|
|
19
|
+
* already fails closed on a real regression; the projections add the refresh
|
|
20
|
+
* half of the loop, not a second gate. Every projection is wrapped so a
|
|
21
|
+
* throw becomes a logged skip.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { getQuality } from '../../config/quality.js';
|
|
26
|
+
import {
|
|
27
|
+
createCrapScorer,
|
|
28
|
+
formatCrapProjection,
|
|
29
|
+
projectCrapBreaches,
|
|
30
|
+
} from './crap.js';
|
|
31
|
+
import {
|
|
32
|
+
formatMaintainabilityProjection,
|
|
33
|
+
projectMaintainabilityRegressions,
|
|
34
|
+
} from './maintainability.js';
|
|
35
|
+
|
|
36
|
+
/** Default on-disk baseline locations, mirroring the per-gate defaults. */
|
|
37
|
+
const DEFAULT_BASELINE_PATHS = Object.freeze({
|
|
38
|
+
maintainability: 'baselines/maintainability.json',
|
|
39
|
+
crap: 'baselines/crap.json',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a gate's baseline file to an absolute path.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} kind
|
|
46
|
+
* @param {object} gate resolved `delivery.quality.gates.<kind>` block
|
|
47
|
+
* @param {string} cwd
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
function resolveBaselinePath(kind, gate, cwd) {
|
|
51
|
+
const rel =
|
|
52
|
+
typeof gate?.baselinePath === 'string' && gate.baselinePath.length > 0
|
|
53
|
+
? gate.baselinePath
|
|
54
|
+
: DEFAULT_BASELINE_PATHS[kind];
|
|
55
|
+
return path.isAbsolute(rel) ? rel : path.resolve(cwd, rel);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A gate is projected unless it is explicitly disabled. An absent gate
|
|
60
|
+
* block means "framework defaults", which enable it — the same reading
|
|
61
|
+
* `buildDefaultGates` applies.
|
|
62
|
+
*
|
|
63
|
+
* @param {object|undefined} gate
|
|
64
|
+
* @returns {boolean}
|
|
65
|
+
*/
|
|
66
|
+
function isEnabled(gate) {
|
|
67
|
+
return gate?.enabled !== false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run one projection with its formatter, swallowing every failure into a
|
|
72
|
+
* logged skip. Returns the projection result (or `null` when it threw) so
|
|
73
|
+
* callers and tests can inspect what happened without parsing log lines.
|
|
74
|
+
*
|
|
75
|
+
* @param {{ kind: string, log: (m: string) => void, run: () => Promise<object>|object, format: (r: object) => string|null }} opts
|
|
76
|
+
* @returns {Promise<object|null>}
|
|
77
|
+
*/
|
|
78
|
+
async function runOne({ kind, log, run, format }) {
|
|
79
|
+
let result;
|
|
80
|
+
try {
|
|
81
|
+
result = await run();
|
|
82
|
+
} catch (err) {
|
|
83
|
+
log(
|
|
84
|
+
`[close-validation] ⚠ ${kind} projection skipped (errored): ${err?.message ?? err}`,
|
|
85
|
+
);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (result?.skipped) {
|
|
89
|
+
log(
|
|
90
|
+
`[close-validation] ⏭ ${kind} projection skipped (${result.skipped}${
|
|
91
|
+
result.detail ? `: ${result.detail}` : ''
|
|
92
|
+
})`,
|
|
93
|
+
);
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
const advisory = format(result);
|
|
97
|
+
if (advisory) log(advisory);
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Run the maintainability and CRAP pre-merge projections and log their
|
|
103
|
+
* advisories. Never throws; never affects the close verdict.
|
|
104
|
+
*
|
|
105
|
+
* @param {{
|
|
106
|
+
* cwd: string,
|
|
107
|
+
* baseBranch: string,
|
|
108
|
+
* storyBranch: string,
|
|
109
|
+
* config?: object,
|
|
110
|
+
* quality?: object,
|
|
111
|
+
* log?: (m: string) => void,
|
|
112
|
+
* projectMaintainability?: typeof projectMaintainabilityRegressions,
|
|
113
|
+
* formatMaintainability?: typeof formatMaintainabilityProjection,
|
|
114
|
+
* projectCrap?: typeof projectCrapBreaches,
|
|
115
|
+
* formatCrap?: typeof formatCrapProjection,
|
|
116
|
+
* }} opts
|
|
117
|
+
* @returns {Promise<{ maintainability: object|null, crap: object|null }>}
|
|
118
|
+
*/
|
|
119
|
+
export async function runProjectionAdvisories({
|
|
120
|
+
cwd,
|
|
121
|
+
baseBranch,
|
|
122
|
+
storyBranch,
|
|
123
|
+
config,
|
|
124
|
+
quality,
|
|
125
|
+
log = () => {},
|
|
126
|
+
projectMaintainability = projectMaintainabilityRegressions,
|
|
127
|
+
formatMaintainability = formatMaintainabilityProjection,
|
|
128
|
+
projectCrap = projectCrapBreaches,
|
|
129
|
+
formatCrap = formatCrapProjection,
|
|
130
|
+
} = {}) {
|
|
131
|
+
const out = { maintainability: null, crap: null };
|
|
132
|
+
let gates;
|
|
133
|
+
try {
|
|
134
|
+
gates = quality ?? getQuality(config) ?? {};
|
|
135
|
+
} catch {
|
|
136
|
+
gates = {};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const miGate = gates.maintainability;
|
|
140
|
+
if (isEnabled(miGate)) {
|
|
141
|
+
out.maintainability = await runOne({
|
|
142
|
+
kind: 'maintainability',
|
|
143
|
+
log,
|
|
144
|
+
format: formatMaintainability,
|
|
145
|
+
run: () =>
|
|
146
|
+
projectMaintainability({
|
|
147
|
+
cwd,
|
|
148
|
+
baseBranch,
|
|
149
|
+
storyBranch,
|
|
150
|
+
baselinePath: resolveBaselinePath('maintainability', miGate, cwd),
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
} else {
|
|
154
|
+
log('[close-validation] ⏭ maintainability projection skipped (disabled)');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const crapGate = gates.crap;
|
|
158
|
+
if (isEnabled(crapGate)) {
|
|
159
|
+
out.crap = await runOne({
|
|
160
|
+
kind: 'crap',
|
|
161
|
+
log,
|
|
162
|
+
format: formatCrap,
|
|
163
|
+
run: () =>
|
|
164
|
+
projectCrap({
|
|
165
|
+
cwd,
|
|
166
|
+
baseBranch,
|
|
167
|
+
storyBranch,
|
|
168
|
+
baselinePath: resolveBaselinePath('crap', crapGate, cwd),
|
|
169
|
+
newMethodCeiling: crapGate?.newMethodCeiling,
|
|
170
|
+
scoreFiles: createCrapScorer({
|
|
171
|
+
cwd,
|
|
172
|
+
targetDirs: crapGate?.targetDirs,
|
|
173
|
+
ignoreGlobs: crapGate?.ignoreGlobs,
|
|
174
|
+
requireCoverage: crapGate?.requireCoverage,
|
|
175
|
+
coveragePath: crapGate?.coveragePath,
|
|
176
|
+
}),
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
} else {
|
|
180
|
+
log('[close-validation] ⏭ crap projection skipped (disabled)');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
@@ -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(
|