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
|
@@ -46,6 +46,25 @@ import { Logger } from './lib/Logger.js';
|
|
|
46
46
|
import { createProvider } from './lib/provider-factory.js';
|
|
47
47
|
import { buildProtectionCtx } from './lib/single-story-sweep/protection-ctx.js';
|
|
48
48
|
import { sweepMergedBranches } from './lib/single-story-sweep.js';
|
|
49
|
+
import { sweepTempRetention } from './lib/temp-retention.js';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Recover the Story ids from the branch names a sweep reaped. Only the
|
|
53
|
+
* canonical `story-<id>` shape yields an id — an operator's ad-hoc branch that
|
|
54
|
+
* happened to match the include glob contributes nothing, so a purge can never
|
|
55
|
+
* be triggered by a name this framework did not create.
|
|
56
|
+
*
|
|
57
|
+
* @param {string[]|undefined} branches
|
|
58
|
+
* @returns {number[]}
|
|
59
|
+
*/
|
|
60
|
+
export function storyIdsFromBranches(branches) {
|
|
61
|
+
const ids = [];
|
|
62
|
+
for (const branch of Array.isArray(branches) ? branches : []) {
|
|
63
|
+
const match = /^story-(\d+)$/.exec(String(branch));
|
|
64
|
+
if (match) ids.push(Number(match[1]));
|
|
65
|
+
}
|
|
66
|
+
return ids;
|
|
67
|
+
}
|
|
49
68
|
|
|
50
69
|
const HELP = `Usage: node .agents/scripts/boot-sweep.js [options]
|
|
51
70
|
|
|
@@ -85,9 +104,11 @@ Options:
|
|
|
85
104
|
* injectedConfig?: object,
|
|
86
105
|
* injectedProvider?: object,
|
|
87
106
|
* injectedSweep?: Function,
|
|
107
|
+
* purgeFn?: Function,
|
|
88
108
|
* logger?: { info?: Function, warn?: Function },
|
|
89
109
|
* }} [args]
|
|
90
|
-
* @returns {Promise<object>} the {@link sweepMergedBranches} envelope
|
|
110
|
+
* @returns {Promise<object>} the {@link sweepMergedBranches} envelope, plus a
|
|
111
|
+
* `tempPurge` result from the Story #4794 temp-retention catch-up.
|
|
91
112
|
*/
|
|
92
113
|
export async function runBootSweep({
|
|
93
114
|
cwd,
|
|
@@ -99,6 +120,7 @@ export async function runBootSweep({
|
|
|
99
120
|
injectedConfig,
|
|
100
121
|
injectedProvider,
|
|
101
122
|
injectedSweep,
|
|
123
|
+
purgeFn = sweepTempRetention,
|
|
102
124
|
logger = Logger,
|
|
103
125
|
} = {}) {
|
|
104
126
|
const root = path.resolve(cwd ?? PROJECT_ROOT);
|
|
@@ -125,7 +147,7 @@ export async function runBootSweep({
|
|
|
125
147
|
config.delivery?.worktreeIsolation?.sweepLockMs ?? 60_000;
|
|
126
148
|
|
|
127
149
|
const sweepFn = injectedSweep ?? sweepMergedBranches;
|
|
128
|
-
|
|
150
|
+
const result = await sweepFn({
|
|
129
151
|
cwd: root,
|
|
130
152
|
baseBranch,
|
|
131
153
|
include: includeGlobs,
|
|
@@ -140,6 +162,21 @@ export async function runBootSweep({
|
|
|
140
162
|
lockPath,
|
|
141
163
|
lockTimeoutMs,
|
|
142
164
|
});
|
|
165
|
+
|
|
166
|
+
// Story #4794 — the temp-retention catch-up. Two eligibility signals, both
|
|
167
|
+
// already paid for: every branch this sweep reaped is a merge it CONFIRMED
|
|
168
|
+
// (merged PR + matching headRefOid), so those Stories' artifacts are spent;
|
|
169
|
+
// and the age floor collects everything else — the backlog from Stories
|
|
170
|
+
// merged before this existed, merged through the GitHub UI, or whose branch
|
|
171
|
+
// was already gone. Best-effort like the sweep itself: `runBootSweep`'s
|
|
172
|
+
// catch swallows any throw into the `ok: false` envelope, and exit stays 0.
|
|
173
|
+
const purge = await purgeFn({
|
|
174
|
+
config,
|
|
175
|
+
mergedStoryIds: storyIdsFromBranches(result?.reaped),
|
|
176
|
+
label: 'boot-sweep',
|
|
177
|
+
logger,
|
|
178
|
+
});
|
|
179
|
+
return { ...result, tempPurge: purge };
|
|
143
180
|
} catch (err) {
|
|
144
181
|
const msg = err?.message ?? String(err);
|
|
145
182
|
logger.warn?.(`[boot-sweep] sweep threw (host continues): ${msg}`);
|
|
@@ -176,8 +213,25 @@ export function buildSummaryLine(result) {
|
|
|
176
213
|
return `[boot-sweep] reaped ${result.localDeleted} local + ${result.remoteDeleted} remote; protected ${protectedCount}${contentMergedSuffix}.`;
|
|
177
214
|
}
|
|
178
215
|
|
|
179
|
-
|
|
216
|
+
/**
|
|
217
|
+
* The CLI core: parse argv, run the sweep, render the report. Extracted from
|
|
218
|
+
* the `main` shell so the argv → render decision table is reachable without
|
|
219
|
+
* spawning a real sweep against a real git tree.
|
|
220
|
+
*
|
|
221
|
+
* Both seams on the optional final `deps` parameter default to the real
|
|
222
|
+
* implementation (`.agents/rules/test-seams.md` rules 1-2), so `main` and any
|
|
223
|
+
* production caller are unchanged.
|
|
224
|
+
*
|
|
225
|
+
* @param {string[]} [argv]
|
|
226
|
+
* @param {{ runBootSweepImpl?: typeof runBootSweep, logger?: { info: Function } }} [deps]
|
|
227
|
+
* @returns {Promise<object>} the sweep envelope that was rendered.
|
|
228
|
+
*/
|
|
229
|
+
export async function runBootSweepCli(
|
|
230
|
+
argv = process.argv.slice(2),
|
|
231
|
+
{ runBootSweepImpl = runBootSweep, logger = Logger } = {},
|
|
232
|
+
) {
|
|
180
233
|
const { values } = parseArgs({
|
|
234
|
+
args: argv,
|
|
181
235
|
options: {
|
|
182
236
|
base: { type: 'string' },
|
|
183
237
|
cwd: { type: 'string' },
|
|
@@ -192,11 +246,11 @@ async function main() {
|
|
|
192
246
|
});
|
|
193
247
|
|
|
194
248
|
if (values.help) {
|
|
195
|
-
|
|
196
|
-
return;
|
|
249
|
+
logger.info(HELP);
|
|
250
|
+
return undefined;
|
|
197
251
|
}
|
|
198
252
|
|
|
199
|
-
const result = await
|
|
253
|
+
const result = await runBootSweepImpl({
|
|
200
254
|
cwd: typeof values.cwd === 'string' ? values.cwd : undefined,
|
|
201
255
|
base: typeof values.base === 'string' ? values.base : undefined,
|
|
202
256
|
include: Array.isArray(values.include) ? values.include : [],
|
|
@@ -206,10 +260,15 @@ async function main() {
|
|
|
206
260
|
});
|
|
207
261
|
|
|
208
262
|
if (values.json) {
|
|
209
|
-
|
|
263
|
+
logger.info(JSON.stringify(result, null, 2));
|
|
210
264
|
} else {
|
|
211
|
-
|
|
265
|
+
logger.info(buildSummaryLine(result));
|
|
212
266
|
}
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function main() {
|
|
271
|
+
await runBootSweepCli();
|
|
213
272
|
}
|
|
214
273
|
|
|
215
274
|
runAsCli(import.meta.url, main, {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// .agents/scripts/check-baseline-drift.js — scheduled full-scope baseline
|
|
4
|
+
// drift check (Story #4776).
|
|
5
|
+
//
|
|
6
|
+
// The three per-PR enforcement sites (close-validation, pre-push, CI) are all
|
|
7
|
+
// diff-scoped: they compare the files a branch touched against their committed
|
|
8
|
+
// baseline rows. A file nobody touches after its row is written is therefore
|
|
9
|
+
// never re-scored, so drift introduced indirectly — a dependency getting more
|
|
10
|
+
// complex, coverage moving underneath a method — stays invisible indefinitely.
|
|
11
|
+
//
|
|
12
|
+
// This CLI is the periodic full-scope counterpart. It re-scores every target
|
|
13
|
+
// directory through the same scorer that writes the baseline, prints a per-row
|
|
14
|
+
// before/after table for everything that moved beyond the gate's tolerance,
|
|
15
|
+
// and exits non-zero when it finds any — so a consumer can wire it as a
|
|
16
|
+
// scheduled CI job without wrapping it in verdict-parsing glue. Wiring it is
|
|
17
|
+
// deliberately consumer-side work; nothing in this repo schedules it.
|
|
18
|
+
//
|
|
19
|
+
// Exit codes:
|
|
20
|
+
// 0 — no drift (or every kind skipped: disabled gate, no baseline, no scorer)
|
|
21
|
+
// 1 — drift detected in at least one kind
|
|
22
|
+
// 2 — the check itself could not run
|
|
23
|
+
|
|
24
|
+
// Fail-fast if the framework's runtime deps are not installed — must be the
|
|
25
|
+
// first import so the check runs before any third-party-importing sibling
|
|
26
|
+
// module is evaluated (Story #3432).
|
|
27
|
+
import './lib/runtime-deps/ensure-installed.js';
|
|
28
|
+
import {
|
|
29
|
+
DRIFT_KINDS,
|
|
30
|
+
detectBaselineDrift,
|
|
31
|
+
formatDriftReport,
|
|
32
|
+
} from './lib/baselines/drift-detector.js';
|
|
33
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
34
|
+
|
|
35
|
+
export const HELP_TEXT = `Usage: node .agents/scripts/check-baseline-drift.js [options]
|
|
36
|
+
|
|
37
|
+
Re-score the configured baselines FULL-SCOPE and report every row whose
|
|
38
|
+
current score has drifted from its committed baseline by more than the
|
|
39
|
+
gate's tolerance — including files untouched by any recent diff, which the
|
|
40
|
+
diff-scoped gates structurally cannot see.
|
|
41
|
+
|
|
42
|
+
Options:
|
|
43
|
+
--gate <kind> Restrict to one kind (repeatable). Default: ${DRIFT_KINDS.join(', ')}.
|
|
44
|
+
--tolerance <n> Override the per-gate absolute tolerance.
|
|
45
|
+
--json Emit the machine-readable report instead of the table.
|
|
46
|
+
-h, --help Show this help.
|
|
47
|
+
|
|
48
|
+
Exit codes: 0 no drift · 1 drift detected · 2 the check could not run.
|
|
49
|
+
|
|
50
|
+
On drift, refresh with the printed \`*:update -- --full-scope\` command and
|
|
51
|
+
commit the result with a \`baseline-refresh:\` tagged subject (non-empty body).`;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parse the CLI surface. Unknown flags are rejected so a typo'd `--gate`
|
|
55
|
+
* cannot silently widen a scheduled job's scope.
|
|
56
|
+
*
|
|
57
|
+
* @param {string[]} argv
|
|
58
|
+
* @returns {{ kinds: string[], tolerance: number|null, json: boolean }}
|
|
59
|
+
*/
|
|
60
|
+
export function parseArgs(argv = []) {
|
|
61
|
+
const kinds = [];
|
|
62
|
+
let tolerance = null;
|
|
63
|
+
let json = false;
|
|
64
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
65
|
+
const arg = argv[i];
|
|
66
|
+
if (arg === '--gate' && argv[i + 1]) {
|
|
67
|
+
const kind = argv[i + 1];
|
|
68
|
+
if (!DRIFT_KINDS.includes(kind)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`[drift] unknown --gate "${kind}"; expected one of ${DRIFT_KINDS.join(', ')}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
kinds.push(kind);
|
|
74
|
+
i += 1;
|
|
75
|
+
} else if (arg === '--tolerance' && argv[i + 1]) {
|
|
76
|
+
const value = Number(argv[i + 1]);
|
|
77
|
+
if (!Number.isFinite(value)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`[drift] --tolerance must be a number (got ${argv[i + 1]})`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
tolerance = value;
|
|
83
|
+
i += 1;
|
|
84
|
+
} else if (arg === '--json') {
|
|
85
|
+
json = true;
|
|
86
|
+
} else {
|
|
87
|
+
throw new Error(`[drift] unrecognised argument "${arg}"`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
kinds: kinds.length > 0 ? kinds : [...DRIFT_KINDS],
|
|
92
|
+
tolerance,
|
|
93
|
+
json,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Run the drift check and render it. Returns the process exit code rather
|
|
99
|
+
* than exiting, so the whole path is unit-testable.
|
|
100
|
+
*
|
|
101
|
+
* @param {{ argv?: string[], cwd?: string, detect?: typeof detectBaselineDrift }} opts
|
|
102
|
+
* @returns {Promise<{ exitCode: number, output: string }>}
|
|
103
|
+
*/
|
|
104
|
+
export async function runCheckBaselineDrift({
|
|
105
|
+
argv = [],
|
|
106
|
+
cwd = process.cwd(),
|
|
107
|
+
detect = detectBaselineDrift,
|
|
108
|
+
} = {}) {
|
|
109
|
+
const args = parseArgs(argv);
|
|
110
|
+
const run = await detect({
|
|
111
|
+
kinds: args.kinds,
|
|
112
|
+
cwd,
|
|
113
|
+
tolerance: args.tolerance,
|
|
114
|
+
});
|
|
115
|
+
const output = args.json
|
|
116
|
+
? JSON.stringify({ schemaVersion: '1', ...run }, null, 2)
|
|
117
|
+
: formatDriftReport(run);
|
|
118
|
+
return { exitCode: run.ok ? 0 : 1, output };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function main() {
|
|
122
|
+
let result;
|
|
123
|
+
try {
|
|
124
|
+
result = await runCheckBaselineDrift({ argv: process.argv.slice(2) });
|
|
125
|
+
} catch (err) {
|
|
126
|
+
process.stdout.write(`${err?.message ?? String(err)}\n`);
|
|
127
|
+
process.exit(2);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
process.stdout.write(`${result.output}\n`);
|
|
131
|
+
process.exit(result.exitCode);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
runAsCli(import.meta.url, main, {
|
|
135
|
+
source: 'check-baseline-drift',
|
|
136
|
+
usage: HELP_TEXT,
|
|
137
|
+
exitCode: 2,
|
|
138
|
+
});
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import path from 'node:path';
|
|
25
25
|
import { getChangedFiles } from './lib/changed-files.js';
|
|
26
|
+
import { isDirectInvocation } from './lib/cli-utils.js';
|
|
26
27
|
import { getQuality, resolveConfig } from './lib/config-resolver.js';
|
|
27
28
|
import {
|
|
28
29
|
anyChangedUnderTargets,
|
|
@@ -35,7 +36,13 @@ import {
|
|
|
35
36
|
import { Logger } from './lib/Logger.js';
|
|
36
37
|
import { hasNpmScript, readPackageScripts } from './lib/npm-scripts.js';
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Parse the full `process.argv` (index 2 onward) into the capture options.
|
|
41
|
+
*
|
|
42
|
+
* @param {string[]} argv
|
|
43
|
+
* @returns {{ skipWhenNoCrapFiles: boolean, ref: string, cwd: string }}
|
|
44
|
+
*/
|
|
45
|
+
export function parseArgs(argv) {
|
|
39
46
|
const out = {
|
|
40
47
|
skipWhenNoCrapFiles: false,
|
|
41
48
|
ref: 'main',
|
|
@@ -50,13 +57,50 @@ function parseArgs(argv) {
|
|
|
50
57
|
return out;
|
|
51
58
|
}
|
|
52
59
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
/**
|
|
61
|
+
* The capture decision core, extracted from the CLI shell so the whole
|
|
62
|
+
* decision table (disabled gate, missing npm script, changed-file skip,
|
|
63
|
+
* freshness skip, capture + stamp) is reachable without spawning
|
|
64
|
+
* `npm run test:coverage` or touching the real filesystem.
|
|
65
|
+
*
|
|
66
|
+
* Every seam on the optional final `deps` parameter defaults to the real
|
|
67
|
+
* implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so the CLI
|
|
68
|
+
* shell below and any production caller are unchanged.
|
|
69
|
+
*
|
|
70
|
+
* @param {string[]} [argv] Full `process.argv`-shaped array.
|
|
71
|
+
* @param {{
|
|
72
|
+
* resolveConfigImpl?: typeof resolveConfig,
|
|
73
|
+
* getQualityImpl?: typeof getQuality,
|
|
74
|
+
* getChangedFilesImpl?: typeof getChangedFiles,
|
|
75
|
+
* readPackageScriptsImpl?: typeof readPackageScripts,
|
|
76
|
+
* hasNpmScriptImpl?: typeof hasNpmScript,
|
|
77
|
+
* isCoverageFreshImpl?: typeof isCoverageFresh,
|
|
78
|
+
* runCaptureImpl?: typeof runCapture,
|
|
79
|
+
* computeContentDigestImpl?: typeof computeContentDigest,
|
|
80
|
+
* writeCaptureStampImpl?: typeof writeCaptureStamp,
|
|
81
|
+
* logger?: { info: Function, warn: Function, error: Function },
|
|
82
|
+
* }} [deps]
|
|
83
|
+
* @returns {number} process exit code
|
|
84
|
+
*/
|
|
85
|
+
export function runCoverageCapture(argv = process.argv, deps = {}) {
|
|
86
|
+
const {
|
|
87
|
+
resolveConfigImpl = resolveConfig,
|
|
88
|
+
getQualityImpl = getQuality,
|
|
89
|
+
getChangedFilesImpl = getChangedFiles,
|
|
90
|
+
readPackageScriptsImpl = readPackageScripts,
|
|
91
|
+
hasNpmScriptImpl = hasNpmScript,
|
|
92
|
+
isCoverageFreshImpl = isCoverageFresh,
|
|
93
|
+
runCaptureImpl = runCapture,
|
|
94
|
+
computeContentDigestImpl = computeContentDigest,
|
|
95
|
+
writeCaptureStampImpl = writeCaptureStamp,
|
|
96
|
+
logger = Logger,
|
|
97
|
+
} = deps;
|
|
98
|
+
const args = parseArgs(argv);
|
|
99
|
+
const config = resolveConfigImpl({ cwd: args.cwd });
|
|
100
|
+
const { crap, coverage } = getQualityImpl(config);
|
|
57
101
|
|
|
58
102
|
if (crap.enabled === false) {
|
|
59
|
-
|
|
103
|
+
logger.info('[coverage-capture] CRAP gate disabled — skipping capture.');
|
|
60
104
|
return 0;
|
|
61
105
|
}
|
|
62
106
|
|
|
@@ -67,8 +111,8 @@ function main() {
|
|
|
67
111
|
// defined it. Surface a one-line, fix-naming diagnostic instead of
|
|
68
112
|
// spawning `npm run test:coverage` only to propagate npm's opaque
|
|
69
113
|
// "Missing script" exit code.
|
|
70
|
-
if (!
|
|
71
|
-
|
|
114
|
+
if (!hasNpmScriptImpl(readPackageScriptsImpl(args.cwd), 'test:coverage')) {
|
|
115
|
+
logger.error(
|
|
72
116
|
'[coverage-capture] ✖ No "test:coverage" script in package.json. ' +
|
|
73
117
|
'Add one (e.g. "test:coverage": "node --test --experimental-test-coverage") ' +
|
|
74
118
|
'or disable the CRAP gate via delivery.quality.gates.crap.enabled=false.',
|
|
@@ -79,45 +123,45 @@ function main() {
|
|
|
79
123
|
if (args.skipWhenNoCrapFiles) {
|
|
80
124
|
let changed;
|
|
81
125
|
try {
|
|
82
|
-
changed =
|
|
126
|
+
changed = getChangedFilesImpl({ ref: args.ref, cwd: args.cwd });
|
|
83
127
|
} catch (err) {
|
|
84
128
|
// A bad ref must not silently relax the gate. Fall through to the
|
|
85
129
|
// freshness check so coverage still gets captured if needed.
|
|
86
|
-
|
|
130
|
+
logger.warn(
|
|
87
131
|
`[coverage-capture] ⚠ ${err?.message ?? err} — falling back to freshness check.`,
|
|
88
132
|
);
|
|
89
133
|
changed = null;
|
|
90
134
|
}
|
|
91
135
|
if (changed && !anyChangedUnderTargets(changed, crap.targetDirs)) {
|
|
92
|
-
|
|
136
|
+
logger.info(
|
|
93
137
|
`[coverage-capture] No changed files under [${crap.targetDirs.join(', ')}] — skipping capture.`,
|
|
94
138
|
);
|
|
95
139
|
return 0;
|
|
96
140
|
}
|
|
97
141
|
}
|
|
98
142
|
|
|
99
|
-
const freshness =
|
|
143
|
+
const freshness = isCoverageFreshImpl({
|
|
100
144
|
coveragePath: crap.coveragePath,
|
|
101
145
|
targetDirs: crap.targetDirs,
|
|
102
146
|
cwd: args.cwd,
|
|
103
147
|
});
|
|
104
148
|
if (freshness.fresh) {
|
|
105
|
-
|
|
149
|
+
logger.info(
|
|
106
150
|
`[coverage-capture] Coverage at ${path.resolve(args.cwd, crap.coveragePath)} is ${freshness.reason} — skipping capture.`,
|
|
107
151
|
);
|
|
108
152
|
return 0;
|
|
109
153
|
}
|
|
110
154
|
|
|
111
|
-
|
|
155
|
+
logger.info(
|
|
112
156
|
`[coverage-capture] Coverage at ${crap.coveragePath} is ${freshness.reason}; running npm run test:coverage…`,
|
|
113
157
|
);
|
|
114
|
-
const code =
|
|
158
|
+
const code = runCaptureImpl({
|
|
115
159
|
cwd: args.cwd,
|
|
116
160
|
timeoutMs: coverage?.timeoutMs,
|
|
117
|
-
log: (m) =>
|
|
161
|
+
log: (m) => logger.info(m),
|
|
118
162
|
});
|
|
119
163
|
if (code !== 0) {
|
|
120
|
-
|
|
164
|
+
logger.error(
|
|
121
165
|
`[coverage-capture] ✖ npm run test:coverage exited ${code}. Fix failing tests or coverage-threshold breaches before re-running the CRAP gate.`,
|
|
122
166
|
);
|
|
123
167
|
return code;
|
|
@@ -127,24 +171,29 @@ function main() {
|
|
|
127
171
|
// freshness checks are content-aware (mtime churn from branch switches no
|
|
128
172
|
// longer invalidates). Best-effort — a missing stamp just means the next
|
|
129
173
|
// check falls back to the mtime heuristic.
|
|
130
|
-
const digest =
|
|
174
|
+
const digest = computeContentDigestImpl(args.cwd, crap.targetDirs);
|
|
131
175
|
if (
|
|
132
176
|
digest &&
|
|
133
|
-
|
|
177
|
+
writeCaptureStampImpl({
|
|
134
178
|
cwd: args.cwd,
|
|
135
179
|
coveragePath: crap.coveragePath,
|
|
136
180
|
digest,
|
|
137
181
|
})
|
|
138
182
|
) {
|
|
139
|
-
|
|
183
|
+
logger.info('[coverage-capture] Wrote content-digest capture stamp.');
|
|
140
184
|
}
|
|
141
185
|
return code;
|
|
142
186
|
}
|
|
143
187
|
|
|
144
188
|
// cli-opt-out: synchronous main returns an exit code that is forwarded via process.exit(code); runAsCli's async-main signature does not preserve the result code.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
189
|
+
// The direct-invocation guard keeps `import`ing this module (from the unit
|
|
190
|
+
// tests that drive `runCoverageCapture` with injected seams) side-effect free;
|
|
191
|
+
// invoked as a CLI the behaviour — exit code and log lines — is unchanged.
|
|
192
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
193
|
+
try {
|
|
194
|
+
process.exit(runCoverageCapture());
|
|
195
|
+
} catch (err) {
|
|
196
|
+
Logger.error('[coverage-capture] unexpected error:', err);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
150
199
|
}
|
|
@@ -82,19 +82,44 @@ export function parseArgv(argv) {
|
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
84
|
* Probe and report. Exported for testing.
|
|
85
|
+
*
|
|
86
|
+
* The optional final `deps` parameter is the module's injectable seam
|
|
87
|
+
* (`.agents/rules/test-seams.md` rules 1-2): config resolution, provider
|
|
88
|
+
* construction, the probe itself, the renderer, and the log sink each default
|
|
89
|
+
* to the real implementation, so the CLI path and every production caller are
|
|
90
|
+
* unchanged. The pre-existing `injected*` fields on the first argument stay
|
|
91
|
+
* supported for callers already threading a resolved provider/config.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} [args]
|
|
94
|
+
* @param {{
|
|
95
|
+
* resolveConfigImpl?: typeof resolveConfig,
|
|
96
|
+
* createProviderImpl?: typeof createProvider,
|
|
97
|
+
* recoverStoryImpl?: typeof recoverStory,
|
|
98
|
+
* renderRecoveryImpl?: typeof renderRecovery,
|
|
99
|
+
* logger?: { info: Function },
|
|
100
|
+
* }} [deps]
|
|
85
101
|
*/
|
|
86
|
-
export async function runDeliverRecover(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
102
|
+
export async function runDeliverRecover(
|
|
103
|
+
{
|
|
104
|
+
storyId: storyIdParam,
|
|
105
|
+
cwd: cwdParam,
|
|
106
|
+
json: jsonParam,
|
|
107
|
+
reprobe: reprobeParam,
|
|
108
|
+
argv,
|
|
109
|
+
injectedProvider,
|
|
110
|
+
injectedConfig,
|
|
111
|
+
injectedGh,
|
|
112
|
+
injectedGitSpawn,
|
|
113
|
+
injectedSleepFn,
|
|
114
|
+
} = {},
|
|
115
|
+
{
|
|
116
|
+
resolveConfigImpl = resolveConfig,
|
|
117
|
+
createProviderImpl = createProvider,
|
|
118
|
+
recoverStoryImpl = recoverStory,
|
|
119
|
+
renderRecoveryImpl = renderRecovery,
|
|
120
|
+
logger = Logger,
|
|
121
|
+
} = {},
|
|
122
|
+
) {
|
|
98
123
|
const parsed =
|
|
99
124
|
storyIdParam !== undefined
|
|
100
125
|
? {
|
|
@@ -106,7 +131,7 @@ export async function runDeliverRecover({
|
|
|
106
131
|
: parseArgv(argv ?? process.argv.slice(2));
|
|
107
132
|
|
|
108
133
|
if (parsed.help) {
|
|
109
|
-
|
|
134
|
+
logger.info(HELP);
|
|
110
135
|
return { success: true, result: null };
|
|
111
136
|
}
|
|
112
137
|
if (!Number.isInteger(parsed.storyId) || parsed.storyId <= 0) {
|
|
@@ -116,10 +141,10 @@ export async function runDeliverRecover({
|
|
|
116
141
|
}
|
|
117
142
|
|
|
118
143
|
const cwd = parsed.cwd ?? PROJECT_ROOT;
|
|
119
|
-
const config = injectedConfig ||
|
|
120
|
-
const provider = injectedProvider ||
|
|
144
|
+
const config = injectedConfig || resolveConfigImpl({ cwd });
|
|
145
|
+
const provider = injectedProvider || createProviderImpl(config);
|
|
121
146
|
|
|
122
|
-
const recovery = await
|
|
147
|
+
const recovery = await recoverStoryImpl({
|
|
123
148
|
storyId: parsed.storyId,
|
|
124
149
|
cwd,
|
|
125
150
|
provider,
|
|
@@ -130,8 +155,10 @@ export async function runDeliverRecover({
|
|
|
130
155
|
...(injectedSleepFn ? { sleepFn: injectedSleepFn } : {}),
|
|
131
156
|
});
|
|
132
157
|
|
|
133
|
-
|
|
134
|
-
parsed.json
|
|
158
|
+
logger.info(
|
|
159
|
+
parsed.json
|
|
160
|
+
? JSON.stringify(recovery, null, 2)
|
|
161
|
+
: renderRecoveryImpl(recovery),
|
|
135
162
|
);
|
|
136
163
|
return { success: true, result: recovery };
|
|
137
164
|
}
|