mandrel 2.24.0 → 2.26.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/agents/acceptance-critic.md +19 -21
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/audit-rules.json +15 -0
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
- package/.agents/schemas/baselines/crap.schema.json +13 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +124 -15
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-arch-cycles.js +12 -93
- package/.agents/scripts/check-baseline-drift.js +16 -3
- package/.agents/scripts/check-baselines.js +19 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-cyclomatic.js +214 -0
- package/.agents/scripts/check-schema-references.js +392 -0
- package/.agents/scripts/check-test-temp-hygiene.js +121 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +3 -4
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/diagnose-friction.js +85 -19
- package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
- package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
- package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
- package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
- package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
- package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
- package/.agents/scripts/lib/audit-baselines/read.js +87 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
- package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
- package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
- package/.agents/scripts/lib/audit-suite/index.js +0 -5
- package/.agents/scripts/lib/audit-suite/selector.js +9 -62
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
- package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
- package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
- package/.agents/scripts/lib/baselines/scope.js +39 -90
- package/.agents/scripts/lib/baselines/writer.js +16 -7
- package/.agents/scripts/lib/changed-files.js +8 -1
- package/.agents/scripts/lib/cli-args.js +115 -1
- package/.agents/scripts/lib/close-validation/runner.js +70 -25
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +124 -27
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +86 -13
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
- package/.agents/scripts/lib/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/label-constants.js +17 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
- package/.agents/scripts/lib/observability/source-classifier.js +175 -2
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
- package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
- package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
- package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/concurrent-map.js +17 -0
- package/.agents/scripts/lib/util/parse-id-list.js +103 -0
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
- package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
- package/.agents/scripts/lib/workers/crap-worker.js +8 -13
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
- package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
- package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
- package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
- package/.agents/scripts/lib/worktree-manager.js +14 -0
- package/.agents/scripts/plan-run-epilogue.js +17 -5
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +137 -28
- package/.agents/scripts/resolve-stories.js +4 -1
- package/.agents/scripts/run-coverage.js +86 -35
- package/.agents/scripts/run-lint.js +20 -0
- package/.agents/scripts/run-tests.js +36 -36
- package/.agents/scripts/single-story-close.js +34 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +308 -47
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/scripts/validate-docs-freshness.js +1 -0
- package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
- package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +92 -97
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
- package/.agents/workflows/helpers/deliver-digest.md +41 -21
- package/.agents/workflows/helpers/deliver-reference.md +95 -4
- package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +49 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +62 -75
- package/README.md +13 -6
- package/docs/CHANGELOG.md +93 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +9 -4
- package/.agents/schemas/friction-event.schema.json +0 -56
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
|
@@ -37,16 +37,19 @@ import fs from 'node:fs';
|
|
|
37
37
|
import path from 'node:path';
|
|
38
38
|
import process from 'node:process';
|
|
39
39
|
import { runAsCli } from './lib/cli-utils.js';
|
|
40
|
+
import {
|
|
41
|
+
buildGraph,
|
|
42
|
+
collectJsFiles,
|
|
43
|
+
DEFAULT_ROOTS,
|
|
44
|
+
parseRelativeImports,
|
|
45
|
+
} from './lib/import-graph.js';
|
|
40
46
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
* @type {string[]}
|
|
48
|
-
*/
|
|
49
|
-
export const DEFAULT_ROOTS = [path.join('.agents', 'scripts'), 'bin', 'lib'];
|
|
47
|
+
// The import-graph builder itself lives in `lib/import-graph.js` (Story
|
|
48
|
+
// #4902) so `audit-baselines.js` can rank hotspots by import in-degree
|
|
49
|
+
// against the same graph this ratchet detects cycles in. Re-exported here
|
|
50
|
+
// because this module's named exports are its unit-test surface and its
|
|
51
|
+
// documented contract; the behaviour is unchanged by the move.
|
|
52
|
+
export { buildGraph, collectJsFiles, DEFAULT_ROOTS, parseRelativeImports };
|
|
50
53
|
|
|
51
54
|
/**
|
|
52
55
|
* Parse argv for `--baseline <path>`, `--root <path>`, and `--json`.
|
|
@@ -80,90 +83,6 @@ export function parseArgv(argv = []) {
|
|
|
80
83
|
return { baselinePath, rootPath, json };
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
/**
|
|
84
|
-
* Recursively collect `.js` files under `rootDir`, skipping
|
|
85
|
-
* `node_modules`. Returns absolute paths, sorted for determinism.
|
|
86
|
-
*
|
|
87
|
-
* @param {string} rootDir
|
|
88
|
-
* @returns {string[]}
|
|
89
|
-
*/
|
|
90
|
-
export function collectJsFiles(rootDir) {
|
|
91
|
-
const out = [];
|
|
92
|
-
const walk = (dir) => {
|
|
93
|
-
let entries;
|
|
94
|
-
try {
|
|
95
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
96
|
-
} catch {
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
for (const entry of entries) {
|
|
100
|
-
if (entry.name === 'node_modules') continue;
|
|
101
|
-
const full = path.join(dir, entry.name);
|
|
102
|
-
if (entry.isDirectory()) {
|
|
103
|
-
walk(full);
|
|
104
|
-
} else if (entry.isFile() && entry.name.endsWith('.js')) {
|
|
105
|
-
out.push(full);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
walk(rootDir);
|
|
110
|
-
return out.sort();
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const IMPORT_RE = /from\s+['"](\.\.?\/[^'"]+\.js)['"]/g;
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Pure helper: extract relative static-import specifiers from source text.
|
|
117
|
-
*
|
|
118
|
-
* @param {string} source
|
|
119
|
-
* @returns {string[]}
|
|
120
|
-
*/
|
|
121
|
-
export function parseRelativeImports(source) {
|
|
122
|
-
const specs = [];
|
|
123
|
-
for (const m of source.matchAll(IMPORT_RE)) {
|
|
124
|
-
specs.push(m[1]);
|
|
125
|
-
}
|
|
126
|
-
return specs;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Build a directed import graph over the given files. Node identity is the
|
|
131
|
-
* file path relative to `rootDir`, posix-separated, so the graph (and any
|
|
132
|
-
* cycles found in it) serializes identically across platforms. Edges that
|
|
133
|
-
* resolve outside the scanned file set are dropped.
|
|
134
|
-
*
|
|
135
|
-
* @param {string[]} files absolute paths
|
|
136
|
-
* @param {string} rootDir
|
|
137
|
-
* @param {{ readFile?: (p: string) => string }} [opts]
|
|
138
|
-
* @returns {Map<string, string[]>}
|
|
139
|
-
*/
|
|
140
|
-
export function buildGraph(files, rootDir, { readFile } = {}) {
|
|
141
|
-
const read = readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
|
|
142
|
-
const toId = (abs) => path.relative(rootDir, abs).split(path.sep).join('/');
|
|
143
|
-
const idSet = new Set(files.map(toId));
|
|
144
|
-
const graph = new Map();
|
|
145
|
-
for (const file of files) {
|
|
146
|
-
const id = toId(file);
|
|
147
|
-
let source;
|
|
148
|
-
try {
|
|
149
|
-
source = read(file);
|
|
150
|
-
} catch {
|
|
151
|
-
graph.set(id, []);
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
const edges = [];
|
|
155
|
-
for (const spec of parseRelativeImports(source)) {
|
|
156
|
-
const target = path
|
|
157
|
-
.relative(rootDir, path.resolve(path.dirname(file), spec))
|
|
158
|
-
.split(path.sep)
|
|
159
|
-
.join('/');
|
|
160
|
-
if (idSet.has(target) && target !== id) edges.push(target);
|
|
161
|
-
}
|
|
162
|
-
graph.set(id, [...new Set(edges)].sort());
|
|
163
|
-
}
|
|
164
|
-
return graph;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
86
|
/**
|
|
168
87
|
* Pure helper: rotate a cycle (array of module ids, no repeated terminal
|
|
169
88
|
* element) so it starts at its lexicographically-smallest member. The same
|
|
@@ -118,21 +118,34 @@ export async function runCheckBaselineDrift({
|
|
|
118
118
|
return { exitCode: run.ok ? 0 : 1, output };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Run the drift check and *return* its exit code rather than calling
|
|
123
|
+
* `process.exit()` — this CLI prints one row per drifted baseline entry
|
|
124
|
+
* full-scope, so its report is exactly the kind of payload that outgrows the
|
|
125
|
+
* 64 KiB pipe buffer under a `| tee`. `process.exit()` terminates before a
|
|
126
|
+
* queued async pipe write drains, silently truncating it (Story #4783, the
|
|
127
|
+
* same defect `check-baselines.js` carried). Handing the code back lets
|
|
128
|
+
* `runAsCli`'s `propagateExitCode` path settle it through
|
|
129
|
+
* `settleCli`/`flushStdio` instead. The 0/1/2 contract documented at the top
|
|
130
|
+
* of this file is unchanged — only *when* the process leaves is.
|
|
131
|
+
*
|
|
132
|
+
* @returns {Promise<number>} 0 no drift, 1 drift detected, 2 could not run.
|
|
133
|
+
*/
|
|
121
134
|
async function main() {
|
|
122
135
|
let result;
|
|
123
136
|
try {
|
|
124
137
|
result = await runCheckBaselineDrift({ argv: process.argv.slice(2) });
|
|
125
138
|
} catch (err) {
|
|
126
139
|
process.stdout.write(`${err?.message ?? String(err)}\n`);
|
|
127
|
-
|
|
128
|
-
return;
|
|
140
|
+
return 2;
|
|
129
141
|
}
|
|
130
142
|
process.stdout.write(`${result.output}\n`);
|
|
131
|
-
|
|
143
|
+
return result.exitCode;
|
|
132
144
|
}
|
|
133
145
|
|
|
134
146
|
runAsCli(import.meta.url, main, {
|
|
135
147
|
source: 'check-baseline-drift',
|
|
136
148
|
usage: HELP_TEXT,
|
|
137
149
|
exitCode: 2,
|
|
150
|
+
propagateExitCode: true,
|
|
138
151
|
});
|
|
@@ -65,6 +65,22 @@ export {
|
|
|
65
65
|
selectEnabledGates,
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Run the dispatcher and *return* its exit code rather than calling
|
|
70
|
+
* `process.exit()`.
|
|
71
|
+
*
|
|
72
|
+
* The return is load-bearing (Story #4783's defect, reintroduced here):
|
|
73
|
+
* `process.exit()` terminates before a queued async pipe write drains, so a
|
|
74
|
+
* full `--gate coverage` report — a quarter of a megabyte of JSON — arrived
|
|
75
|
+
* truncated at the 64 KiB pipe boundary under the `| tee` in CI's coverage
|
|
76
|
+
* step, while still exiting 0. Handing the code back to `runAsCli`'s
|
|
77
|
+
* `propagateExitCode` path settles it through `settleCli`/`flushStdio`
|
|
78
|
+
* instead, which assigns `process.exitCode` and lets Node terminate once
|
|
79
|
+
* stdout has drained. The 0/1/2/3/4 contract in `lib/baselines/exit-codes.js`
|
|
80
|
+
* is unchanged — only *when* the process leaves is.
|
|
81
|
+
*
|
|
82
|
+
* @returns {Promise<number>} An exit code from the `EXIT_*` contract.
|
|
83
|
+
*/
|
|
68
84
|
async function main() {
|
|
69
85
|
let result;
|
|
70
86
|
try {
|
|
@@ -74,14 +90,14 @@ async function main() {
|
|
|
74
90
|
process.stdout.write(
|
|
75
91
|
`${JSON.stringify({ schemaVersion: '1', error: message }, null, 2)}\n`,
|
|
76
92
|
);
|
|
77
|
-
|
|
78
|
-
return;
|
|
93
|
+
return EXIT_CONFIG;
|
|
79
94
|
}
|
|
80
95
|
process.stdout.write(`${result.output}\n`);
|
|
81
|
-
|
|
96
|
+
return result.exitCode;
|
|
82
97
|
}
|
|
83
98
|
|
|
84
99
|
runAsCli(import.meta.url, main, {
|
|
85
100
|
source: 'check-baselines',
|
|
86
101
|
usage: HELP_TEXT,
|
|
102
|
+
propagateExitCode: true,
|
|
87
103
|
});
|
|
@@ -47,9 +47,18 @@
|
|
|
47
47
|
* Ratchet semantics (mirroring the sibling ratchets):
|
|
48
48
|
* - A gated tier grows beyond `baseline.tiers.<tier>.totalBytes +
|
|
49
49
|
* baseline.toleranceBytes` → exit 1, naming the tier and its delta.
|
|
50
|
-
* - A gated tier shrinks below its baseline total →
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* - A gated tier shrinks below its baseline total → exit 1 (Story #4872).
|
|
51
|
+
* A ratchet that only tightens in one direction lets every measured
|
|
52
|
+
* improvement evaporate: the recorded total keeps promising headroom the
|
|
53
|
+
* tree no longer spends, so the next growth is absorbed by stale slack
|
|
54
|
+
* instead of being reported. Shrinkage is therefore **actionable** —
|
|
55
|
+
* refresh the baseline down and the gain is locked in. Unlike growth this
|
|
56
|
+
* is deliberately **zero-tolerance**: `toleranceBytes` exists to keep a
|
|
57
|
+
* trivial addition from churning the file, and applying it downward would
|
|
58
|
+
* silently discard every sub-tolerance gain.
|
|
59
|
+
* - A recorded row naming a path the measured tier no longer contains →
|
|
60
|
+
* exit 1. The row describes a file that has been deleted or de-listed, so
|
|
61
|
+
* the bytes it contributes to the recorded total are fiction.
|
|
53
62
|
* - Within tolerance / clean → exit 0.
|
|
54
63
|
* - Baseline file absent → warn + exit 0 (no-op; nothing to ratchet against).
|
|
55
64
|
*
|
|
@@ -303,16 +312,45 @@ export function buildBaseline(tierMap, toleranceBytes) {
|
|
|
303
312
|
};
|
|
304
313
|
}
|
|
305
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Collect the recorded rows of one gated tier that name a path the measured
|
|
317
|
+
* tier no longer contains (Story #4872). A deleted file drops out of the
|
|
318
|
+
* resolved tier, and so does one that has been de-listed from the read set —
|
|
319
|
+
* either way the row's bytes are counted into a recorded total that no live
|
|
320
|
+
* file backs, so the row is drift and not a detail.
|
|
321
|
+
*
|
|
322
|
+
* @param {string} tier
|
|
323
|
+
* @param {Array<{ path: string, bytes: number }>} files live tier measurement
|
|
324
|
+
* @param {{ files?: Array<{ path: string, bytes?: number }> }} baseTier recorded tier
|
|
325
|
+
* @returns {Array<{ tier: string, path: string, bytes: number|null }>}
|
|
326
|
+
*/
|
|
327
|
+
function absentRows(tier, files, baseTier) {
|
|
328
|
+
const live = new Set(files.map((f) => f.path));
|
|
329
|
+
const out = [];
|
|
330
|
+
for (const row of baseTier?.files ?? []) {
|
|
331
|
+
if (typeof row?.path !== 'string' || live.has(row.path)) continue;
|
|
332
|
+
out.push({
|
|
333
|
+
tier,
|
|
334
|
+
path: row.path,
|
|
335
|
+
bytes: Number.isFinite(row.bytes) ? row.bytes : null,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
|
|
306
341
|
/**
|
|
307
342
|
* Pure diff: compare the current tier map against the committed baseline. A
|
|
308
343
|
* gated tier with no current files is skipped; a tier absent from the baseline
|
|
309
|
-
* is skipped. `grown` entries fail the gate
|
|
344
|
+
* is skipped. `grown`, `shrunk` and `absent` entries all fail the gate — see
|
|
345
|
+
* the ratchet semantics in the module header for why shrinkage is actionable
|
|
346
|
+
* rather than informational (Story #4872).
|
|
310
347
|
*
|
|
311
348
|
* @param {{ tiers: Record<string, Array<{ path: string, bytes: number }>> }} tierMap
|
|
312
349
|
* @param {{ toleranceBytes?: number, tiers?: Record<string, { totalBytes: number }> }} baseline
|
|
313
350
|
* @returns {{
|
|
314
351
|
* grown: Array<{ tier: string, current: number, baseline: number, tolerance: number, delta: number }>,
|
|
315
|
-
* shrunk: Array<{ tier: string, current: number, baseline: number }>,
|
|
352
|
+
* shrunk: Array<{ tier: string, current: number, baseline: number, delta: number }>,
|
|
353
|
+
* absent: Array<{ tier: string, path: string, bytes: number|null }>,
|
|
316
354
|
* skipped: string[],
|
|
317
355
|
* }}
|
|
318
356
|
*/
|
|
@@ -322,6 +360,7 @@ export function diffBudget(tierMap, baseline) {
|
|
|
322
360
|
: 0;
|
|
323
361
|
const grown = [];
|
|
324
362
|
const shrunk = [];
|
|
363
|
+
const absent = [];
|
|
325
364
|
const skipped = [];
|
|
326
365
|
for (const tier of GATED_TIERS) {
|
|
327
366
|
const files = tierMap.tiers[tier] ?? [];
|
|
@@ -345,16 +384,42 @@ export function diffBudget(tierMap, baseline) {
|
|
|
345
384
|
delta: current - baselineBytes,
|
|
346
385
|
});
|
|
347
386
|
} else if (current < baselineBytes) {
|
|
348
|
-
|
|
387
|
+
// Deliberately zero-tolerance: `tolerance` guards against churn from a
|
|
388
|
+
// trivial *addition*; mirroring it downward would discard every gain
|
|
389
|
+
// smaller than the tolerance, which is the leak this branch closes.
|
|
390
|
+
shrunk.push({
|
|
391
|
+
tier,
|
|
392
|
+
current,
|
|
393
|
+
baseline: baselineBytes,
|
|
394
|
+
delta: baselineBytes - current,
|
|
395
|
+
});
|
|
349
396
|
}
|
|
397
|
+
absent.push(...absentRows(tier, files, baseTier));
|
|
350
398
|
}
|
|
351
|
-
return { grown, shrunk, skipped };
|
|
399
|
+
return { grown, shrunk, absent, skipped };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Count the drift entries that fail the gate. Every direction is actionable
|
|
404
|
+
* (Story #4872), so this is the one place the failure set is defined and both
|
|
405
|
+
* the summary tag and the exit code read it.
|
|
406
|
+
*
|
|
407
|
+
* @param {ReturnType<typeof diffBudget>} diff
|
|
408
|
+
* @returns {number}
|
|
409
|
+
*/
|
|
410
|
+
export function budgetFailureCount(diff) {
|
|
411
|
+
return (
|
|
412
|
+
(diff?.grown?.length ?? 0) +
|
|
413
|
+
(diff?.shrunk?.length ?? 0) +
|
|
414
|
+
(diff?.absent?.length ?? 0)
|
|
415
|
+
);
|
|
352
416
|
}
|
|
353
417
|
|
|
354
418
|
/**
|
|
355
419
|
* Render the human-readable diff. `+` lines are tiers that grew beyond
|
|
356
|
-
* tolerance
|
|
357
|
-
*
|
|
420
|
+
* tolerance; `-` lines are tiers that shrank below their recorded total or
|
|
421
|
+
* rows naming a path the tree no longer carries. All three fail the gate. A
|
|
422
|
+
* one-line summary always follows.
|
|
358
423
|
*
|
|
359
424
|
* @param {ReturnType<typeof diffBudget>} diff
|
|
360
425
|
* @returns {string}
|
|
@@ -368,12 +433,17 @@ export function renderDiff(diff) {
|
|
|
368
433
|
}
|
|
369
434
|
for (const s of diff.shrunk) {
|
|
370
435
|
lines.push(
|
|
371
|
-
`- ${s.tier}: ${s.current} bytes
|
|
436
|
+
`- ${s.tier}: ${s.current} bytes is under the recorded ${s.baseline} (delta -${s.delta}) — the ratchet is holding slack the tree no longer spends; refresh baselines/context-budget.json`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
for (const a of diff.absent ?? []) {
|
|
440
|
+
lines.push(
|
|
441
|
+
`- ${a.tier}: recorded row ${a.path} names a path the measured tier no longer contains — refresh baselines/context-budget.json`,
|
|
372
442
|
);
|
|
373
443
|
}
|
|
374
|
-
const tag = diff
|
|
444
|
+
const tag = budgetFailureCount(diff) > 0 ? '(gate fail)' : '(ok)';
|
|
375
445
|
lines.push(
|
|
376
|
-
`[context-budget] grown=${diff.grown.length} shrunk=${diff.shrunk.length} skipped=${diff.skipped.length} ${tag}`,
|
|
446
|
+
`[context-budget] grown=${diff.grown.length} shrunk=${diff.shrunk.length} absent=${diff.absent?.length ?? 0} skipped=${diff.skipped.length} ${tag}`,
|
|
377
447
|
);
|
|
378
448
|
return lines.join('\n');
|
|
379
449
|
}
|
|
@@ -454,7 +524,7 @@ export async function runCli({
|
|
|
454
524
|
if (!baseline) {
|
|
455
525
|
if (json) {
|
|
456
526
|
stdout.write(
|
|
457
|
-
`${JSON.stringify({ kind: 'context-budget-report', baselinePath: resolvedBaselinePath, tiers: tierMap.tiers, grown: [], shrunk: [], skipped: GATED_TIERS, exitCode: 0, noBaseline: true }, null, 2)}\n`,
|
|
527
|
+
`${JSON.stringify({ kind: 'context-budget-report', baselinePath: resolvedBaselinePath, tiers: tierMap.tiers, grown: [], shrunk: [], absent: [], skipped: GATED_TIERS, exitCode: 0, noBaseline: true }, null, 2)}\n`,
|
|
458
528
|
);
|
|
459
529
|
} else {
|
|
460
530
|
stderr.write(
|
|
@@ -472,7 +542,7 @@ export async function runCli({
|
|
|
472
542
|
const bootDrift = agentBootDrift(tierMap, baseline, ceiling);
|
|
473
543
|
const permissiveDrift = bootDrift.filter((d) => d.direction === 'permissive');
|
|
474
544
|
const exitCode =
|
|
475
|
-
diff
|
|
545
|
+
budgetFailureCount(diff) > 0 ||
|
|
476
546
|
bootOverflow.length > 0 ||
|
|
477
547
|
permissiveDrift.length > 0
|
|
478
548
|
? 1
|
|
@@ -490,6 +560,7 @@ export async function runCli({
|
|
|
490
560
|
),
|
|
491
561
|
grown: diff.grown,
|
|
492
562
|
shrunk: diff.shrunk,
|
|
563
|
+
absent: diff.absent,
|
|
493
564
|
skipped: diff.skipped,
|
|
494
565
|
agentBootCeilingBytes: ceiling,
|
|
495
566
|
agentBootOverflow: bootOverflow,
|
|
@@ -527,6 +598,16 @@ export async function runCli({
|
|
|
527
598
|
`[context-budget] ❌ a documentation tier grew beyond tolerance — refresh the budget consciously with \`node .agents/scripts/check-context-budget.js --update\` once the growth is intentional\n`,
|
|
528
599
|
);
|
|
529
600
|
}
|
|
601
|
+
if (diff.shrunk.length > 0) {
|
|
602
|
+
stderr.write(
|
|
603
|
+
`[context-budget] ❌ a documentation tier came in under its recorded total — the ratchet is holding slack the tree no longer spends, so the next growth would be absorbed silently. Lock the gain in with \`node .agents/scripts/check-context-budget.js --update\`\n`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
if (diff.absent.length > 0) {
|
|
607
|
+
stderr.write(
|
|
608
|
+
`[context-budget] ❌ a recorded row names a path the measured tier no longer contains — its bytes inflate the recorded total against nothing. Refresh with \`node .agents/scripts/check-context-budget.js --update\`\n`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
530
611
|
}
|
|
531
612
|
}
|
|
532
613
|
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI: ratchet on cyclomatic complexity against
|
|
3
|
+
* `delivery.quality.codingGuardrails.cyclomaticMustFix` (Story #4923).
|
|
4
|
+
*
|
|
5
|
+
* The must-fix ceiling was documented as blocking (`code-quality-guardrails.md`
|
|
6
|
+
* promises "the close-validation chain refuses the merge") while being read by
|
|
7
|
+
* nothing. This is the consumer that makes the promise true. It joins the
|
|
8
|
+
* standalone-ratchet family — `check-arch-cycles.js`, `check-dead-exports.js`,
|
|
9
|
+
* `check-context-budget.js` — which the CI `baselines` job runs as a required
|
|
10
|
+
* check, and it follows their contract exactly:
|
|
11
|
+
*
|
|
12
|
+
* - Reads the committed breach baseline at `baselines/cyclomatic.json`
|
|
13
|
+
* (override with `--baseline <path>`).
|
|
14
|
+
* - Walks the maintainability gate's `targetDirs` / `ignoreGlobs` and scores
|
|
15
|
+
* every function with the in-repo escomplex kernel — no coverage artifact
|
|
16
|
+
* required, so the verdict is available on a cold checkout.
|
|
17
|
+
* - Exit 0 when clean, improved, or shrinking; exit 1 when a file gains an
|
|
18
|
+
* over-ceiling function or its worst function gets worse.
|
|
19
|
+
*
|
|
20
|
+
* `--update` rewrites the baseline from the current tree. That is the
|
|
21
|
+
* sanctioned motion after a deliberate refactor lands, and the only way the
|
|
22
|
+
* recorded breach count is allowed to rise.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import process from 'node:process';
|
|
28
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
29
|
+
import { getQuality, resolveConfig } from './lib/config-resolver.js';
|
|
30
|
+
import {
|
|
31
|
+
buildCyclomaticEnvelope,
|
|
32
|
+
DEFAULT_CYCLOMATIC_BASELINE,
|
|
33
|
+
diffCyclomaticRows,
|
|
34
|
+
renderCyclomaticDiff,
|
|
35
|
+
resolveCyclomaticPolicy,
|
|
36
|
+
scanCyclomatic,
|
|
37
|
+
} from './lib/cyclomatic-ceiling.js';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse `--baseline <path>`, `--json`, and `--update`.
|
|
41
|
+
*
|
|
42
|
+
* @param {string[]} argv
|
|
43
|
+
* @returns {{ baselinePath: string | null, json: boolean, update: boolean }}
|
|
44
|
+
*/
|
|
45
|
+
function parseArgv(argv = []) {
|
|
46
|
+
let baselinePath = null;
|
|
47
|
+
let json = false;
|
|
48
|
+
let update = false;
|
|
49
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
50
|
+
const a = argv[i];
|
|
51
|
+
if (a === '--baseline') {
|
|
52
|
+
const next = argv[i + 1];
|
|
53
|
+
if (next && !next.startsWith('--')) {
|
|
54
|
+
baselinePath = next;
|
|
55
|
+
i += 1;
|
|
56
|
+
}
|
|
57
|
+
} else if (a === '--json') {
|
|
58
|
+
json = true;
|
|
59
|
+
} else if (a === '--update') {
|
|
60
|
+
update = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { baselinePath, json, update };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read a breach baseline envelope from disk. Returns `null` when the file is
|
|
68
|
+
* missing or unparseable — the caller treats that as an empty baseline, which
|
|
69
|
+
* makes the very first run report every existing breach as `added` rather than
|
|
70
|
+
* silently passing.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} baselinePath
|
|
73
|
+
* @returns {{ ceiling?: number, rows?: Array<object> } | null}
|
|
74
|
+
*/
|
|
75
|
+
function loadCyclomaticBaseline(baselinePath) {
|
|
76
|
+
try {
|
|
77
|
+
if (!fs.existsSync(baselinePath)) return null;
|
|
78
|
+
const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
|
|
79
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Top-level CLI entry. Exported so tests can drive the whole pipeline through
|
|
87
|
+
* the injected seams below without spawning a process.
|
|
88
|
+
*
|
|
89
|
+
* @param {{
|
|
90
|
+
* argv?: string[],
|
|
91
|
+
* cwd?: string,
|
|
92
|
+
* stdout?: { write: (s: string) => void },
|
|
93
|
+
* stderr?: { write: (s: string) => void },
|
|
94
|
+
* resolveConfigImpl?: typeof resolveConfig,
|
|
95
|
+
* scanImpl?: typeof scanCyclomatic,
|
|
96
|
+
* loadBaselineImpl?: typeof loadCyclomaticBaseline,
|
|
97
|
+
* writeFileImpl?: (p: string, data: string) => void,
|
|
98
|
+
* }} [opts]
|
|
99
|
+
* @returns {Promise<number>} 0 = clean / improved; 1 = ratchet breached
|
|
100
|
+
*/
|
|
101
|
+
export async function runCli({
|
|
102
|
+
argv = process.argv.slice(2),
|
|
103
|
+
cwd = process.cwd(),
|
|
104
|
+
stdout = process.stdout,
|
|
105
|
+
stderr = process.stderr,
|
|
106
|
+
resolveConfigImpl = resolveConfig,
|
|
107
|
+
scanImpl = scanCyclomatic,
|
|
108
|
+
loadBaselineImpl = loadCyclomaticBaseline,
|
|
109
|
+
writeFileImpl = (p, data) => fs.writeFileSync(p, data),
|
|
110
|
+
} = {}) {
|
|
111
|
+
const { baselinePath, json, update } = parseArgv(argv);
|
|
112
|
+
const quality = getQuality(resolveConfigImpl({ cwd }));
|
|
113
|
+
const policy = resolveCyclomaticPolicy(quality);
|
|
114
|
+
const resolvedBaselinePath = path.resolve(
|
|
115
|
+
cwd,
|
|
116
|
+
baselinePath ?? DEFAULT_CYCLOMATIC_BASELINE,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const scan = scanImpl({
|
|
120
|
+
targetDirs: policy.targetDirs,
|
|
121
|
+
ignoreGlobs: policy.ignoreGlobs,
|
|
122
|
+
ceiling: policy.mustFix,
|
|
123
|
+
cwd,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (update) {
|
|
127
|
+
const envelope = buildCyclomaticEnvelope({
|
|
128
|
+
rows: scan.rows,
|
|
129
|
+
ceiling: policy.mustFix,
|
|
130
|
+
});
|
|
131
|
+
writeFileImpl(
|
|
132
|
+
resolvedBaselinePath,
|
|
133
|
+
`${JSON.stringify(envelope, null, 2)}\n`,
|
|
134
|
+
);
|
|
135
|
+
stdout.write(
|
|
136
|
+
`[cyclomatic] wrote ${scan.rows.length} breach row(s) at ceiling c=${policy.mustFix} to ${resolvedBaselinePath}\n`,
|
|
137
|
+
);
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const baseline = loadBaselineImpl(resolvedBaselinePath);
|
|
142
|
+
const baselineRows = Array.isArray(baseline?.rows) ? baseline.rows : [];
|
|
143
|
+
const diff = diffCyclomaticRows(baselineRows, scan.rows);
|
|
144
|
+
const exitCode = diff.added.length + diff.worsened.length > 0 ? 1 : 0;
|
|
145
|
+
|
|
146
|
+
if (json) {
|
|
147
|
+
stdout.write(
|
|
148
|
+
`${JSON.stringify(
|
|
149
|
+
{
|
|
150
|
+
kind: 'cyclomatic-report',
|
|
151
|
+
ceiling: policy.mustFix,
|
|
152
|
+
flag: policy.flag,
|
|
153
|
+
baselinePath: resolvedBaselinePath,
|
|
154
|
+
baselineCeiling: baseline?.ceiling ?? null,
|
|
155
|
+
scannedFiles: scan.scannedFiles,
|
|
156
|
+
parseErrors: scan.parseErrors,
|
|
157
|
+
baselineRows,
|
|
158
|
+
currentRows: scan.rows,
|
|
159
|
+
...diff,
|
|
160
|
+
exitCode,
|
|
161
|
+
},
|
|
162
|
+
null,
|
|
163
|
+
2,
|
|
164
|
+
)}\n`,
|
|
165
|
+
);
|
|
166
|
+
return exitCode;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!baseline) {
|
|
170
|
+
stderr.write(
|
|
171
|
+
`[cyclomatic] ⚠ baseline not found at ${resolvedBaselinePath} — treating as empty\n`,
|
|
172
|
+
);
|
|
173
|
+
} else if (
|
|
174
|
+
typeof baseline.ceiling === 'number' &&
|
|
175
|
+
baseline.ceiling !== policy.mustFix
|
|
176
|
+
) {
|
|
177
|
+
stderr.write(
|
|
178
|
+
`[cyclomatic] ⚠ baseline was recorded at ceiling c=${baseline.ceiling} but the configured cyclomaticMustFix is c=${policy.mustFix} — re-run with --update\n`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
stdout.write('\n--- cyclomatic preview ---\n');
|
|
182
|
+
stdout.write(`${renderCyclomaticDiff(diff, policy.mustFix)}\n`);
|
|
183
|
+
return exitCode;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function main() {
|
|
187
|
+
return runCli();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
runAsCli(import.meta.url, main, {
|
|
191
|
+
source: 'cyclomatic',
|
|
192
|
+
propagateExitCode: true,
|
|
193
|
+
errorPrefix: '[cyclomatic] ❌ Fatal error',
|
|
194
|
+
usage: {
|
|
195
|
+
invocation:
|
|
196
|
+
'node .agents/scripts/check-cyclomatic.js [--baseline <path>] [--update] [--json]',
|
|
197
|
+
summary:
|
|
198
|
+
'Ratchet on cyclomatic complexity: fail when a file gains a function above `delivery.quality.codingGuardrails.cyclomaticMustFix`, or when its worst function gets worse than the recorded baseline.',
|
|
199
|
+
flags: [
|
|
200
|
+
[
|
|
201
|
+
'--baseline <path>',
|
|
202
|
+
'Baseline file (default: baselines/cyclomatic.json).',
|
|
203
|
+
],
|
|
204
|
+
[
|
|
205
|
+
'--update',
|
|
206
|
+
'Rewrite the baseline from the current tree (the post-refactor motion).',
|
|
207
|
+
],
|
|
208
|
+
['--json', 'Emit the comparison envelope as JSON.'],
|
|
209
|
+
],
|
|
210
|
+
notes: [
|
|
211
|
+
'Exit codes:\n 0 clean, improved, or shrinking\n 1 a new or worsened over-ceiling function',
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
});
|