mandrel 2.16.0 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/docs/agentrc-reference.json +10 -0
- package/.agents/docs/configuration.md +9 -0
- package/.agents/docs/quality-gates.md +137 -0
- package/.agents/schemas/agentrc.schema.json +48 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +4 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/acceptance-eval.js +52 -12
- package/.agents/scripts/audit-to-stories.js +92 -25
- package/.agents/scripts/boot-sweep.js +67 -8
- package/.agents/scripts/check-baseline-drift.js +138 -0
- package/.agents/scripts/coverage-capture.js +74 -25
- package/.agents/scripts/deliver-recover.js +45 -18
- package/.agents/scripts/drain-pending-cleanup.js +67 -23
- package/.agents/scripts/generate-lens-checklists.js +81 -30
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
- package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
- package/.agents/scripts/lib/baselines/envelope.js +7 -0
- package/.agents/scripts/lib/baselines/kernel.js +31 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
- package/.agents/scripts/lib/baselines/reader.js +12 -1
- package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
- package/.agents/scripts/lib/baselines/writer.js +10 -0
- package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
- package/.agents/scripts/lib/cli-utils.js +48 -13
- package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
- package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
- package/.agents/scripts/lib/close-validation/runner.js +68 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
- package/.agents/scripts/lib/config/quality.js +40 -0
- package/.agents/scripts/lib/config/temp-paths.js +27 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +69 -0
- package/.agents/scripts/lib/coverage-utils.js +92 -9
- package/.agents/scripts/lib/crap-engine.js +113 -23
- package/.agents/scripts/lib/crap-utils.js +159 -93
- package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
- package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
- package/.agents/scripts/lib/observability/terse-result.js +7 -3
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +9 -5
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +15 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
- package/.agents/scripts/lib/single-story-sweep.js +11 -0
- package/.agents/scripts/lib/stdio-flush.js +71 -0
- package/.agents/scripts/lib/temp-retention.js +559 -0
- package/.agents/scripts/lib/transpile.js +133 -6
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
- package/.agents/scripts/lib/workers/crap-worker.js +49 -76
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
- package/.agents/scripts/nav-registry-diff.js +30 -8
- package/.agents/scripts/plan-run-epilogue.js +27 -11
- package/.agents/scripts/resolve-doc-tiers.js +18 -8
- package/.agents/scripts/single-story-close.js +9 -92
- package/.agents/scripts/single-story-init.js +1 -1
- package/.agents/scripts/sync-branch-from-base.js +6 -1
- package/.agents/scripts/update-crap-baseline.js +13 -0
- package/README.md +14 -6
- package/docs/CHANGELOG.md +36 -0
- package/lib/cli/version-helpers.js +7 -0
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
- package/package.json +5 -1
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* fan-out target, or `&` shell backgrounding) within a small line window.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import
|
|
29
|
+
import nodeFs from 'node:fs';
|
|
30
30
|
import path from 'node:path';
|
|
31
31
|
|
|
32
32
|
const SCAN_ROOT_DEFAULT = '.agents';
|
|
@@ -54,14 +54,20 @@ const BACKGROUND_TOKENS = [
|
|
|
54
54
|
* and `.md` sources. Skips `node_modules`, `.worktrees`, and directories
|
|
55
55
|
* starting with `.git`.
|
|
56
56
|
*
|
|
57
|
+
* The optional final `fsImpl` parameter defaults to the real `node:fs`
|
|
58
|
+
* (`.agents/rules/test-seams.md` rule 1) and is forwarded to the recursive
|
|
59
|
+
* call rather than re-acquired there (rule 4), so a test drives the whole walk
|
|
60
|
+
* through a plain stub object instead of module mocking (rule 5).
|
|
61
|
+
*
|
|
57
62
|
* @param {string} dir
|
|
63
|
+
* @param {typeof nodeFs} [fsImpl]
|
|
58
64
|
* @returns {string[]}
|
|
59
65
|
*/
|
|
60
|
-
function walkSources(dir) {
|
|
66
|
+
export function walkSources(dir, fsImpl = nodeFs) {
|
|
61
67
|
const out = [];
|
|
62
68
|
let entries;
|
|
63
69
|
try {
|
|
64
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
70
|
+
entries = fsImpl.readdirSync(dir, { withFileTypes: true });
|
|
65
71
|
} catch {
|
|
66
72
|
return out;
|
|
67
73
|
}
|
|
@@ -75,7 +81,7 @@ function walkSources(dir) {
|
|
|
75
81
|
) {
|
|
76
82
|
continue;
|
|
77
83
|
}
|
|
78
|
-
out.push(...walkSources(full));
|
|
84
|
+
out.push(...walkSources(full, fsImpl));
|
|
79
85
|
continue;
|
|
80
86
|
}
|
|
81
87
|
if (!entry.isFile()) continue;
|
|
@@ -94,11 +100,14 @@ function walkSources(dir) {
|
|
|
94
100
|
* any dedicated check module (this file): the source-of-truth
|
|
95
101
|
* implementation legitimately mentions itself.
|
|
96
102
|
*
|
|
103
|
+
* Pure: it is handed the already-read source, so it needs no filesystem seam
|
|
104
|
+
* of its own.
|
|
105
|
+
*
|
|
97
106
|
* @param {string} file
|
|
98
107
|
* @param {string} src
|
|
99
108
|
* @returns {Array<{ line: number, kind: string }>}
|
|
100
109
|
*/
|
|
101
|
-
function scanFile(file, src) {
|
|
110
|
+
export function scanFile(file, src) {
|
|
102
111
|
const offences = [];
|
|
103
112
|
// Don't flag the actual story-init script, self-references, or the
|
|
104
113
|
// parallel-tooling helper — the helper documents both Rule 2
|
|
@@ -149,15 +158,21 @@ export default {
|
|
|
149
158
|
scope: ['story-close', 'retro'],
|
|
150
159
|
autoCorrect: 'refuse-and-print',
|
|
151
160
|
|
|
152
|
-
|
|
161
|
+
/**
|
|
162
|
+
* @param {{ cwd?: string, scanRoot?: string, scope?: string }} [state]
|
|
163
|
+
* @param {typeof nodeFs} [fsImpl] Optional final filesystem seam; defaults
|
|
164
|
+
* to the real `node:fs` (`.agents/rules/test-seams.md` rule 1) and is
|
|
165
|
+
* forwarded to {@link walkSources} rather than re-acquired (rule 4).
|
|
166
|
+
*/
|
|
167
|
+
detect(state, fsImpl = nodeFs) {
|
|
153
168
|
const cwd = state?.cwd ?? process.cwd();
|
|
154
169
|
const root = state?.scanRoot ?? path.join(cwd, SCAN_ROOT_DEFAULT);
|
|
155
|
-
const files = walkSources(root);
|
|
170
|
+
const files = walkSources(root, fsImpl);
|
|
156
171
|
const offences = [];
|
|
157
172
|
for (const file of files) {
|
|
158
173
|
let src;
|
|
159
174
|
try {
|
|
160
|
-
src = readFileSync(file, 'utf8');
|
|
175
|
+
src = fsImpl.readFileSync(file, 'utf8');
|
|
161
176
|
} catch {
|
|
162
177
|
continue;
|
|
163
178
|
}
|
|
@@ -18,6 +18,7 @@ import path from 'node:path';
|
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
19
|
import { respondToHelp } from './cli-usage.js';
|
|
20
20
|
import { formatCliError } from './error-redactor.js';
|
|
21
|
+
import { flushStdio } from './stdio-flush.js';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Is the current module being executed directly as a CLI (as opposed to
|
|
@@ -32,11 +33,48 @@ export function isDirectInvocation(importMetaUrl) {
|
|
|
32
33
|
return fileURLToPath(importMetaUrl) === path.resolve(entry);
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Settle one CLI run: await `main`, translate its outcome into an exit code,
|
|
38
|
+
* then flush stdio.
|
|
39
|
+
*
|
|
40
|
+
* **The exit is never eager** (Story #4783). This helper assigns
|
|
41
|
+
* `process.exitCode` and returns, letting Node terminate once the event loop
|
|
42
|
+
* is empty — which is *after* the pending stdout writes drain. The previous
|
|
43
|
+
* `process.exit()` call terminated first, discarding anything still queued
|
|
44
|
+
* behind a full pipe buffer, so any CLI emitting more than 64 KiB into a pipe
|
|
45
|
+
* truncated silently while still reporting success. The resulting exit code is
|
|
46
|
+
* identical for every caller: `code ?? 0` on the `propagateExitCode` path,
|
|
47
|
+
* `options.exitCode` (default 1) on the fatal-error path, and an untouched 0
|
|
48
|
+
* everywhere else.
|
|
49
|
+
*
|
|
50
|
+
* @param {() => Promise<unknown>} main
|
|
51
|
+
* @param {{ source: string, exitCode: number, onError?: (err: Error) => void,
|
|
52
|
+
* propagateExitCode: boolean, errorPrefix?: string }} settings
|
|
53
|
+
* @returns {Promise<void>}
|
|
54
|
+
*/
|
|
55
|
+
async function settleCli(main, settings) {
|
|
56
|
+
const { source, exitCode, onError, propagateExitCode, errorPrefix } =
|
|
57
|
+
settings;
|
|
58
|
+
try {
|
|
59
|
+
const code = await main();
|
|
60
|
+
if (propagateExitCode) process.exitCode = code ?? 0;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (typeof onError === 'function') {
|
|
63
|
+
onError(err);
|
|
64
|
+
} else {
|
|
65
|
+
const prefix = errorPrefix ?? `[${source}] Fatal error`;
|
|
66
|
+
console.error(`${prefix}: ${formatCliError(err)}`);
|
|
67
|
+
process.exitCode = exitCode;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
await flushStdio();
|
|
71
|
+
}
|
|
72
|
+
|
|
35
73
|
/**
|
|
36
74
|
* Run `main` as the CLI entry point for the caller's module. No-op when the
|
|
37
75
|
* module is imported rather than invoked directly. Promise rejection from
|
|
38
76
|
* `main` is funnelled through either the caller-supplied `onError` callback
|
|
39
|
-
* or the default handler (prefixed stderr line + `process.
|
|
77
|
+
* or the default handler (prefixed stderr line + `process.exitCode`).
|
|
40
78
|
*
|
|
41
79
|
* A `usage` option makes the script self-describing: when the argv carries
|
|
42
80
|
* `--help` / `-h`, the rendered usage block goes to stdout and `main` is
|
|
@@ -51,6 +89,9 @@ export function isDirectInvocation(importMetaUrl) {
|
|
|
51
89
|
* @param {string} [options.source='CLI'] Prefix used in the default error message.
|
|
52
90
|
* @param {number} [options.exitCode=1] Exit code used by the default error handler.
|
|
53
91
|
* @param {(err: Error) => void} [options.onError] Full override of the error handler.
|
|
92
|
+
* @param {boolean} [options.propagateExitCode=false] Adopt `main`'s resolved
|
|
93
|
+
* value as the process exit code.
|
|
94
|
+
* @param {string} [options.errorPrefix] Overrides the `[source] Fatal error` prefix.
|
|
54
95
|
* @param {object|string} [options.usage] Usage spec (or pre-rendered
|
|
55
96
|
* text) printed for `--help`; see `lib/cli-usage.js`.
|
|
56
97
|
*/
|
|
@@ -65,17 +106,11 @@ export function runAsCli(importMetaUrl, main, options = {}) {
|
|
|
65
106
|
usage,
|
|
66
107
|
} = options;
|
|
67
108
|
if (usage && respondToHelp(process.argv.slice(2), usage)) return;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
onError(err);
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
const prefix = errorPrefix ?? `[${source}] Fatal error`;
|
|
78
|
-
console.error(`${prefix}: ${formatCliError(err)}`);
|
|
79
|
-
process.exit(exitCode);
|
|
109
|
+
void settleCli(main, {
|
|
110
|
+
source,
|
|
111
|
+
exitCode,
|
|
112
|
+
onError,
|
|
113
|
+
propagateExitCode,
|
|
114
|
+
errorPrefix,
|
|
80
115
|
});
|
|
81
116
|
}
|
|
@@ -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
|
+
}
|