mandrel 1.57.0 → 1.59.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/README.md +89 -87
- package/.agents/docs/SDLC.md +11 -7
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/audit-rules.json +20 -0
- package/.agents/scripts/acceptance-eval.js +20 -3
- package/.agents/scripts/assert-branch.js +1 -3
- package/.agents/scripts/bootstrap.js +1 -1
- package/.agents/scripts/check-arch-cycles.js +360 -0
- package/.agents/scripts/coverage-capture.js +24 -3
- package/.agents/scripts/epic-deliver-preflight.js +5 -3
- package/.agents/scripts/epic-deliver-prepare.js +12 -4
- package/.agents/scripts/epic-execute-record-wave.js +1 -1
- package/.agents/scripts/evidence-gate.js +1 -1
- package/.agents/scripts/git-rebase-and-resolve.js +1 -1
- package/.agents/scripts/hierarchy-gate.js +34 -14
- package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
- package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
- package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
- package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
- package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
- package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
- package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
- package/.agents/scripts/lib/baselines/writer.js +1 -1
- package/.agents/scripts/lib/close-validation/commands.js +188 -0
- package/.agents/scripts/lib/close-validation/gates.js +235 -0
- package/.agents/scripts/lib/close-validation/process.js +101 -0
- package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
- package/.agents/scripts/lib/close-validation/runner.js +325 -0
- package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
- package/.agents/scripts/lib/config/quality.js +6 -6
- package/.agents/scripts/lib/config-resolver.js +2 -5
- package/.agents/scripts/lib/coverage-capture.js +147 -4
- package/.agents/scripts/lib/cpu-pool.js +14 -0
- package/.agents/scripts/lib/crap-utils.js +6 -11
- package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
- package/.agents/scripts/lib/git-utils.js +24 -22
- package/.agents/scripts/lib/maintainability-engine.js +1 -1
- package/.agents/scripts/lib/maintainability-utils.js +4 -187
- package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +80 -6
- package/.agents/scripts/lib/orchestration/code-review.js +90 -77
- package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +5 -12
- package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +14 -14
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +184 -49
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +26 -2
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +26 -6
- package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +7 -20
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +0 -6
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
- package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -16
- package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -3
- package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +2 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +7 -7
- package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
- package/.agents/scripts/lib/orchestration/preflight-cache.js +35 -12
- package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
- package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
- package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +241 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +16 -3
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
- package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
- package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
- package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
- package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
- package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
- package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
- package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
- package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
- package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
- package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
- package/.agents/scripts/lib/project-root.js +17 -0
- package/.agents/scripts/lib/story-adjacency.js +76 -0
- package/.agents/scripts/lib/story-lifecycle.js +1 -1
- package/.agents/scripts/lib/transpile.js +93 -0
- package/.agents/scripts/lib/wave-runner/tick.js +4 -153
- package/.agents/scripts/lib/workers/crap-worker.js +1 -1
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
- package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
- package/.agents/scripts/providers/github/tickets.js +110 -6
- package/.agents/scripts/run-lint.js +9 -0
- package/.agents/scripts/run-tests.js +24 -4
- package/.agents/scripts/stories-wave-tick.js +8 -5
- package/.agents/scripts/story-init.js +149 -10
- package/.agents/scripts/sync-branch-from-base.js +1 -1
- package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
- package/.agents/workflows/audit-documentation.md +226 -0
- package/.agents/workflows/epic-deliver.md +16 -23
- package/.agents/workflows/epic-plan.md +1 -1
- package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
- package/.agents/workflows/helpers/single-story-deliver.md +2 -1
- package/.agents/workflows/onboard.md +4 -3
- package/.agents/workflows/story-deliver.md +1 -1
- package/README.md +21 -8
- package/lib/cli/init.js +336 -0
- package/package.json +2 -1
- package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
- package/.agents/scripts/lib/close-validation.js +0 -897
- package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
- package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
- package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
- package/.agents/scripts/lib/task-utils.js +0 -26
- package/.agents/scripts/story-deliver-prepare.js +0 -267
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI: ratchet-down architecture gate for import cycles (Story #3991).
|
|
3
|
+
*
|
|
4
|
+
* Walks every `.js` file under `.agents/scripts/` (excluding
|
|
5
|
+
* `node_modules`), parses relative static-import edges
|
|
6
|
+
* (`from './…/x.js'`), detects directed cycles via DFS, and compares
|
|
7
|
+
* them against the committed allowlist at `baselines/arch-cycles.json`.
|
|
8
|
+
*
|
|
9
|
+
* Ratchet semantics mirror `check-dead-exports.js`:
|
|
10
|
+
* - Any detected cycle NOT in the allowlist → exit 1, cycle path printed.
|
|
11
|
+
* - Allowlisted cycle no longer detected → printed as `-` (removal),
|
|
12
|
+
* warning that the allowlist can shrink. Removals-only exits 0.
|
|
13
|
+
* - Clean diff → exit 0.
|
|
14
|
+
*
|
|
15
|
+
* Cycles are normalized by rotating to the lexicographically-smallest
|
|
16
|
+
* member so the same cycle always serializes identically regardless of
|
|
17
|
+
* the DFS entry point.
|
|
18
|
+
*
|
|
19
|
+
* Flags:
|
|
20
|
+
* --baseline <path> override the allowlist path (default
|
|
21
|
+
* `baselines/arch-cycles.json`, resolved from cwd)
|
|
22
|
+
* --root <path> override the scanned root (default
|
|
23
|
+
* `.agents/scripts`, resolved from cwd)
|
|
24
|
+
* --json write the structured envelope to stdout
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import process from 'node:process';
|
|
30
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse argv for `--baseline <path>`, `--root <path>`, and `--json`.
|
|
34
|
+
* Exported so unit tests can pin the parser.
|
|
35
|
+
*
|
|
36
|
+
* @param {string[]} argv
|
|
37
|
+
* @returns {{ baselinePath: string | null, rootPath: string | null, json: boolean }}
|
|
38
|
+
*/
|
|
39
|
+
export function parseArgv(argv = []) {
|
|
40
|
+
let baselinePath = null;
|
|
41
|
+
let rootPath = null;
|
|
42
|
+
let json = false;
|
|
43
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
44
|
+
const a = argv[i];
|
|
45
|
+
if (a === '--baseline') {
|
|
46
|
+
const next = argv[i + 1];
|
|
47
|
+
if (next && !next.startsWith('--')) {
|
|
48
|
+
baselinePath = next;
|
|
49
|
+
i += 1;
|
|
50
|
+
}
|
|
51
|
+
} else if (a === '--root') {
|
|
52
|
+
const next = argv[i + 1];
|
|
53
|
+
if (next && !next.startsWith('--')) {
|
|
54
|
+
rootPath = next;
|
|
55
|
+
i += 1;
|
|
56
|
+
}
|
|
57
|
+
} else if (a === '--json') {
|
|
58
|
+
json = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { baselinePath, rootPath, json };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Recursively collect `.js` files under `rootDir`, skipping
|
|
66
|
+
* `node_modules`. Returns absolute paths, sorted for determinism.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} rootDir
|
|
69
|
+
* @returns {string[]}
|
|
70
|
+
*/
|
|
71
|
+
export function collectJsFiles(rootDir) {
|
|
72
|
+
const out = [];
|
|
73
|
+
const walk = (dir) => {
|
|
74
|
+
let entries;
|
|
75
|
+
try {
|
|
76
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
77
|
+
} catch {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (entry.name === 'node_modules') continue;
|
|
82
|
+
const full = path.join(dir, entry.name);
|
|
83
|
+
if (entry.isDirectory()) {
|
|
84
|
+
walk(full);
|
|
85
|
+
} else if (entry.isFile() && entry.name.endsWith('.js')) {
|
|
86
|
+
out.push(full);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
walk(rootDir);
|
|
91
|
+
return out.sort();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const IMPORT_RE = /from\s+['"](\.\.?\/[^'"]+\.js)['"]/g;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Pure helper: extract relative static-import specifiers from source text.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} source
|
|
100
|
+
* @returns {string[]}
|
|
101
|
+
*/
|
|
102
|
+
export function parseRelativeImports(source) {
|
|
103
|
+
const specs = [];
|
|
104
|
+
for (const m of source.matchAll(IMPORT_RE)) {
|
|
105
|
+
specs.push(m[1]);
|
|
106
|
+
}
|
|
107
|
+
return specs;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build a directed import graph over the given files. Node identity is the
|
|
112
|
+
* file path relative to `rootDir`, posix-separated, so the graph (and any
|
|
113
|
+
* cycles found in it) serializes identically across platforms. Edges that
|
|
114
|
+
* resolve outside the scanned file set are dropped.
|
|
115
|
+
*
|
|
116
|
+
* @param {string[]} files absolute paths
|
|
117
|
+
* @param {string} rootDir
|
|
118
|
+
* @param {{ readFile?: (p: string) => string }} [opts]
|
|
119
|
+
* @returns {Map<string, string[]>}
|
|
120
|
+
*/
|
|
121
|
+
export function buildGraph(files, rootDir, { readFile } = {}) {
|
|
122
|
+
const read = readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
|
|
123
|
+
const toId = (abs) => path.relative(rootDir, abs).split(path.sep).join('/');
|
|
124
|
+
const idSet = new Set(files.map(toId));
|
|
125
|
+
const graph = new Map();
|
|
126
|
+
for (const file of files) {
|
|
127
|
+
const id = toId(file);
|
|
128
|
+
let source;
|
|
129
|
+
try {
|
|
130
|
+
source = read(file);
|
|
131
|
+
} catch {
|
|
132
|
+
graph.set(id, []);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const edges = [];
|
|
136
|
+
for (const spec of parseRelativeImports(source)) {
|
|
137
|
+
const target = path
|
|
138
|
+
.relative(rootDir, path.resolve(path.dirname(file), spec))
|
|
139
|
+
.split(path.sep)
|
|
140
|
+
.join('/');
|
|
141
|
+
if (idSet.has(target) && target !== id) edges.push(target);
|
|
142
|
+
}
|
|
143
|
+
graph.set(id, [...new Set(edges)].sort());
|
|
144
|
+
}
|
|
145
|
+
return graph;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Pure helper: rotate a cycle (array of module ids, no repeated terminal
|
|
150
|
+
* element) so it starts at its lexicographically-smallest member. The same
|
|
151
|
+
* cycle therefore always serializes identically regardless of where the
|
|
152
|
+
* DFS entered it.
|
|
153
|
+
*
|
|
154
|
+
* @param {string[]} cycle
|
|
155
|
+
* @returns {string[]}
|
|
156
|
+
*/
|
|
157
|
+
export function normalizeCycle(cycle) {
|
|
158
|
+
if (cycle.length === 0) return [];
|
|
159
|
+
let minIdx = 0;
|
|
160
|
+
for (let i = 1; i < cycle.length; i += 1) {
|
|
161
|
+
if (cycle[i] < cycle[minIdx]) minIdx = i;
|
|
162
|
+
}
|
|
163
|
+
return [...cycle.slice(minIdx), ...cycle.slice(0, minIdx)];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Detect directed cycles in the graph via iterative-stack DFS (white /
|
|
168
|
+
* gray / black coloring). Each back edge to a gray node yields the cycle
|
|
169
|
+
* slice currently on the DFS path. Cycles are normalized and deduplicated
|
|
170
|
+
* by their serialized form, then sorted for stable output.
|
|
171
|
+
*
|
|
172
|
+
* @param {Map<string, string[]>} graph
|
|
173
|
+
* @returns {string[][]} normalized cycles
|
|
174
|
+
*/
|
|
175
|
+
export function findCycles(graph) {
|
|
176
|
+
const WHITE = 0;
|
|
177
|
+
const GRAY = 1;
|
|
178
|
+
const BLACK = 2;
|
|
179
|
+
const color = new Map();
|
|
180
|
+
for (const node of graph.keys()) color.set(node, WHITE);
|
|
181
|
+
const seen = new Map();
|
|
182
|
+
|
|
183
|
+
const pathStack = [];
|
|
184
|
+
const onPath = new Map(); // node -> index in pathStack
|
|
185
|
+
|
|
186
|
+
const visit = (start) => {
|
|
187
|
+
// Iterative DFS frame stack: [node, edge cursor].
|
|
188
|
+
const frames = [[start, 0]];
|
|
189
|
+
color.set(start, GRAY);
|
|
190
|
+
onPath.set(start, pathStack.length);
|
|
191
|
+
pathStack.push(start);
|
|
192
|
+
while (frames.length > 0) {
|
|
193
|
+
const frame = frames[frames.length - 1];
|
|
194
|
+
const [node] = frame;
|
|
195
|
+
const edges = graph.get(node) ?? [];
|
|
196
|
+
if (frame[1] < edges.length) {
|
|
197
|
+
const next = edges[frame[1]];
|
|
198
|
+
frame[1] += 1;
|
|
199
|
+
const c = color.get(next);
|
|
200
|
+
if (c === GRAY) {
|
|
201
|
+
const cycle = normalizeCycle(pathStack.slice(onPath.get(next)));
|
|
202
|
+
seen.set(cycle.join(' -> '), cycle);
|
|
203
|
+
} else if (c === WHITE) {
|
|
204
|
+
color.set(next, GRAY);
|
|
205
|
+
onPath.set(next, pathStack.length);
|
|
206
|
+
pathStack.push(next);
|
|
207
|
+
frames.push([next, 0]);
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
color.set(node, BLACK);
|
|
211
|
+
onPath.delete(node);
|
|
212
|
+
pathStack.pop();
|
|
213
|
+
frames.pop();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
for (const node of [...graph.keys()].sort()) {
|
|
219
|
+
if (color.get(node) === WHITE) visit(node);
|
|
220
|
+
}
|
|
221
|
+
return [...seen.values()].sort((a, b) =>
|
|
222
|
+
a.join(' -> ').localeCompare(b.join(' -> ')),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Pure helper: read the allowlist envelope from disk. Returns the parsed
|
|
228
|
+
* object or `null` when the file is missing or unparseable.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} baselinePath
|
|
231
|
+
* @returns {{ cycles?: string[][] } | null}
|
|
232
|
+
*/
|
|
233
|
+
export function loadBaseline(baselinePath) {
|
|
234
|
+
try {
|
|
235
|
+
if (!fs.existsSync(baselinePath)) return null;
|
|
236
|
+
const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
|
|
237
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
238
|
+
return parsed;
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Pure helper: diff detected cycles against the allowlist. Both sides are
|
|
246
|
+
* normalized before comparison so rotation differences never count as
|
|
247
|
+
* drift. Identity is the ` -> `-joined normalized cycle.
|
|
248
|
+
*
|
|
249
|
+
* @param {string[][]} allowlisted
|
|
250
|
+
* @param {string[][]} detected
|
|
251
|
+
* @returns {{ added: string[][], removed: string[][] }}
|
|
252
|
+
*/
|
|
253
|
+
export function diffCycles(allowlisted, detected) {
|
|
254
|
+
const key = (c) => normalizeCycle(c).join(' -> ');
|
|
255
|
+
const baseSet = new Set((allowlisted ?? []).map(key));
|
|
256
|
+
const currentSet = new Set((detected ?? []).map(key));
|
|
257
|
+
const added = (detected ?? []).filter((c) => !baseSet.has(key(c)));
|
|
258
|
+
const removed = (allowlisted ?? []).filter((c) => !currentSet.has(key(c)));
|
|
259
|
+
const sortFn = (a, b) => key(a).localeCompare(key(b));
|
|
260
|
+
return { added: added.sort(sortFn), removed: removed.sort(sortFn) };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Pure helper: render the human-readable diff. `+` lines are new cycles
|
|
265
|
+
* (gate fail), `-` lines are fixed cycles whose allowlist entry can be
|
|
266
|
+
* removed. A one-line summary always follows.
|
|
267
|
+
*
|
|
268
|
+
* @param {{ added: string[][], removed: string[][] }} diff
|
|
269
|
+
* @returns {string}
|
|
270
|
+
*/
|
|
271
|
+
export function renderDiff(diff) {
|
|
272
|
+
const lines = [];
|
|
273
|
+
const fmt = (c) => `${c.join(' -> ')} -> ${c[0]}`;
|
|
274
|
+
for (const c of diff.added) lines.push(`+ ${fmt(c)}`);
|
|
275
|
+
for (const c of diff.removed) lines.push(`- ${fmt(c)}`);
|
|
276
|
+
if (diff.removed.length > 0) {
|
|
277
|
+
lines.push(
|
|
278
|
+
`[arch-cycles] ⚠ ${diff.removed.length} allowlisted cycle(s) no longer detected — shrink baselines/arch-cycles.json`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
const tag = diff.added.length > 0 ? '(gate fail)' : '(ok)';
|
|
282
|
+
lines.push(
|
|
283
|
+
`[arch-cycles] added=${diff.added.length} removed=${diff.removed.length} ${tag}`,
|
|
284
|
+
);
|
|
285
|
+
return lines.join('\n');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Top-level CLI entry. Exported so tests can drive the full pipeline
|
|
290
|
+
* against a tmpdir fixture graph.
|
|
291
|
+
*
|
|
292
|
+
* @param {{
|
|
293
|
+
* argv?: string[],
|
|
294
|
+
* cwd?: string,
|
|
295
|
+
* stdout?: { write: (s: string) => void },
|
|
296
|
+
* stderr?: { write: (s: string) => void },
|
|
297
|
+
* }} [opts]
|
|
298
|
+
* @returns {Promise<number>} 0 = clean or removals-only; 1 = new cycle detected
|
|
299
|
+
*/
|
|
300
|
+
export async function runCli({
|
|
301
|
+
argv = process.argv.slice(2),
|
|
302
|
+
cwd = process.cwd(),
|
|
303
|
+
stdout = process.stdout,
|
|
304
|
+
stderr = process.stderr,
|
|
305
|
+
} = {}) {
|
|
306
|
+
const { baselinePath, rootPath, json } = parseArgv(argv);
|
|
307
|
+
const resolvedRoot = path.resolve(
|
|
308
|
+
cwd,
|
|
309
|
+
rootPath ?? path.join('.agents', 'scripts'),
|
|
310
|
+
);
|
|
311
|
+
const resolvedBaselinePath = path.resolve(
|
|
312
|
+
cwd,
|
|
313
|
+
baselinePath ?? path.join('baselines', 'arch-cycles.json'),
|
|
314
|
+
);
|
|
315
|
+
if (!fs.existsSync(resolvedRoot)) {
|
|
316
|
+
throw new Error(`[arch-cycles] scan root not found: ${resolvedRoot}`);
|
|
317
|
+
}
|
|
318
|
+
const baseline = loadBaseline(resolvedBaselinePath);
|
|
319
|
+
const allowlisted = Array.isArray(baseline?.cycles) ? baseline.cycles : [];
|
|
320
|
+
|
|
321
|
+
const files = collectJsFiles(resolvedRoot);
|
|
322
|
+
const graph = buildGraph(files, resolvedRoot);
|
|
323
|
+
const detected = findCycles(graph);
|
|
324
|
+
const diff = diffCycles(allowlisted, detected);
|
|
325
|
+
const exitCode = diff.added.length > 0 ? 1 : 0;
|
|
326
|
+
|
|
327
|
+
if (json) {
|
|
328
|
+
const envelope = {
|
|
329
|
+
kind: 'arch-cycles-report',
|
|
330
|
+
root: resolvedRoot,
|
|
331
|
+
baselinePath: resolvedBaselinePath,
|
|
332
|
+
allowlisted: allowlisted.map(normalizeCycle),
|
|
333
|
+
detected,
|
|
334
|
+
added: diff.added,
|
|
335
|
+
removed: diff.removed,
|
|
336
|
+
exitCode,
|
|
337
|
+
};
|
|
338
|
+
stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
339
|
+
} else {
|
|
340
|
+
if (!baseline) {
|
|
341
|
+
stderr.write(
|
|
342
|
+
`[arch-cycles] ⚠ allowlist not found at ${resolvedBaselinePath} — treating as empty\n`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
stdout.write(`\n--- arch-cycles preview ---\n`);
|
|
346
|
+
stdout.write(`${renderDiff(diff)}\n`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return exitCode;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function main() {
|
|
353
|
+
return runCli();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
runAsCli(import.meta.url, main, {
|
|
357
|
+
source: 'arch-cycles',
|
|
358
|
+
propagateExitCode: true,
|
|
359
|
+
errorPrefix: '[arch-cycles] ❌ Fatal error',
|
|
360
|
+
});
|
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
* 2. With `--skip-when-no-crap-files`: read `git diff --name-only <ref>...HEAD`
|
|
10
10
|
* (default ref `main`) and exit 0 if no changed file lives under
|
|
11
11
|
* `crap.targetDirs`.
|
|
12
|
-
* 3.
|
|
13
|
-
* `
|
|
14
|
-
*
|
|
12
|
+
* 3. Test freshness: content digest of `crap.targetDirs` vs. the persisted
|
|
13
|
+
* capture stamp (`coverage/.capture-stamp.json`), falling back to the
|
|
14
|
+
* artifact-mtime heuristic when no stamp exists. Exit 0 when fresh.
|
|
15
|
+
* 4. Otherwise spawn `npm run test:coverage`, write a fresh capture stamp
|
|
16
|
+
* on success, and propagate the exit code.
|
|
15
17
|
*
|
|
16
18
|
* Exit codes:
|
|
17
19
|
* 0 — coverage is fresh (or capture skipped/succeeded).
|
|
@@ -24,8 +26,10 @@ import { getChangedFiles } from './lib/changed-files.js';
|
|
|
24
26
|
import { getQuality, resolveConfig } from './lib/config-resolver.js';
|
|
25
27
|
import {
|
|
26
28
|
anyChangedUnderTargets,
|
|
29
|
+
computeContentDigest,
|
|
27
30
|
isCoverageFresh,
|
|
28
31
|
runCapture,
|
|
32
|
+
writeCaptureStamp,
|
|
29
33
|
} from './lib/coverage-capture.js';
|
|
30
34
|
|
|
31
35
|
import { Logger } from './lib/Logger.js';
|
|
@@ -99,6 +103,23 @@ function main() {
|
|
|
99
103
|
Logger.error(
|
|
100
104
|
`[coverage-capture] ✖ npm run test:coverage exited ${code}. Fix failing tests or coverage-threshold breaches before re-running the CRAP gate.`,
|
|
101
105
|
);
|
|
106
|
+
return code;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Persist the content digest next to the fresh artifact so subsequent
|
|
110
|
+
// freshness checks are content-aware (mtime churn from branch switches no
|
|
111
|
+
// longer invalidates). Best-effort — a missing stamp just means the next
|
|
112
|
+
// check falls back to the mtime heuristic.
|
|
113
|
+
const digest = computeContentDigest(args.cwd, crap.targetDirs);
|
|
114
|
+
if (
|
|
115
|
+
digest &&
|
|
116
|
+
writeCaptureStamp({
|
|
117
|
+
cwd: args.cwd,
|
|
118
|
+
coveragePath: crap.coveragePath,
|
|
119
|
+
digest,
|
|
120
|
+
})
|
|
121
|
+
) {
|
|
122
|
+
Logger.info('[coverage-capture] Wrote content-digest capture stamp.');
|
|
102
123
|
}
|
|
103
124
|
return code;
|
|
104
125
|
}
|
|
@@ -297,9 +297,11 @@ export async function runPreflight({
|
|
|
297
297
|
// Persist the snapshot/DAG envelope so `epic-deliver-prepare.js` can
|
|
298
298
|
// reuse it instead of re-walking the hierarchy. The cache key is a
|
|
299
299
|
// deterministic fingerprint of the Epic ticket returned by the same
|
|
300
|
-
// `getTicket(epicId)` call that drove `runSnapshotPhase
|
|
301
|
-
//
|
|
302
|
-
|
|
300
|
+
// `getTicket(epicId)` call that drove `runSnapshotPhase` **plus** the
|
|
301
|
+
// Story snapshots that drove the wave DAG (Story #4019), so any drift —
|
|
302
|
+
// Epic label/body/updatedAt or a Story-dependency edit — forces a cache
|
|
303
|
+
// miss in prepare.
|
|
304
|
+
const baseSha = computeBaseSha(state.epic, state.stories);
|
|
303
305
|
let cacheWritten = false;
|
|
304
306
|
if (!dryRun) {
|
|
305
307
|
await writePreflightCache({
|
|
@@ -226,16 +226,24 @@ export async function runEpicDeliverPrepare({
|
|
|
226
226
|
|
|
227
227
|
// Story #3027: try the preflight cache first so we don't re-walk Epic
|
|
228
228
|
// → Feature → Story when `epic-deliver-preflight.js` already did. The
|
|
229
|
-
// cache key is a deterministic fingerprint of the Epic ticket
|
|
230
|
-
//
|
|
231
|
-
//
|
|
229
|
+
// cache key is a deterministic fingerprint of the Epic ticket plus the
|
|
230
|
+
// cached Story snapshots (Story #4019): the Epic re-fetch plus one
|
|
231
|
+
// getTicket per cached Story is still far cheaper than the full
|
|
232
|
+
// hierarchy BFS, and a Story-dependency edit now invalidates the cache.
|
|
233
|
+
// Cache miss or baseSha mismatch → fall back to a fresh pass.
|
|
232
234
|
const ctx = { epicId, provider };
|
|
233
235
|
let state = {};
|
|
234
236
|
let cacheStatus = 'miss';
|
|
235
237
|
const cached = await readPreflightCache({ epicId, cwd });
|
|
236
238
|
if (cached) {
|
|
237
239
|
const freshEpic = await provider.getTicket(epicId);
|
|
238
|
-
const
|
|
240
|
+
const cachedStoryIds = cached.stories
|
|
241
|
+
.map((s) => Number(s?.id ?? s?.number))
|
|
242
|
+
.filter((id) => Number.isInteger(id) && id > 0);
|
|
243
|
+
const freshStories = await Promise.all(
|
|
244
|
+
cachedStoryIds.map((id) => provider.getTicket(id)),
|
|
245
|
+
);
|
|
246
|
+
const freshBaseSha = computeBaseSha(freshEpic, freshStories);
|
|
239
247
|
if (freshBaseSha === cached.baseSha) {
|
|
240
248
|
state = {
|
|
241
249
|
epic: cached.epic,
|
|
@@ -42,7 +42,7 @@ import { getRunners } from './lib/config/runners.js';
|
|
|
42
42
|
import { resolveConfig } from './lib/config-resolver.js';
|
|
43
43
|
import { Logger } from './lib/Logger.js';
|
|
44
44
|
import * as epicRunStateStore from './lib/orchestration/epic-run-state-store.js';
|
|
45
|
-
import { upsertEpicRunProgress } from './lib/orchestration/epic-runner/progress-reporter.js';
|
|
45
|
+
import { upsertEpicRunProgress } from './lib/orchestration/epic-runner/progress-reporter/composition.js';
|
|
46
46
|
import {
|
|
47
47
|
emitStoryDispatchEnd,
|
|
48
48
|
storyStatusToDispatchOutcome,
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
import { spawnSync } from 'node:child_process';
|
|
48
48
|
import { parseArgs } from 'node:util';
|
|
49
49
|
import { runAsCli } from './lib/cli-utils.js';
|
|
50
|
-
import { PROJECT_ROOT } from './lib/config-resolver.js';
|
|
51
50
|
import { gitSpawn } from './lib/git-utils.js';
|
|
52
51
|
import { Logger } from './lib/Logger.js';
|
|
52
|
+
import { PROJECT_ROOT } from './lib/project-root.js';
|
|
53
53
|
import {
|
|
54
54
|
hashCommandConfig,
|
|
55
55
|
recordPass,
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
|
|
41
41
|
import { parseArgs } from 'node:util';
|
|
42
42
|
import { runAsCli } from './lib/cli-utils.js';
|
|
43
|
-
import { PROJECT_ROOT } from './lib/config-resolver.js';
|
|
44
43
|
import { gitSpawn } from './lib/git-utils.js';
|
|
45
44
|
import { Logger } from './lib/Logger.js';
|
|
45
|
+
import { PROJECT_ROOT } from './lib/project-root.js';
|
|
46
46
|
|
|
47
47
|
function currentBranch(cwd) {
|
|
48
48
|
const res = gitSpawn(cwd, 'rev-parse', '--abbrev-ref', 'HEAD');
|
|
@@ -42,6 +42,13 @@ import { resolveConfig } from './lib/config-resolver.js';
|
|
|
42
42
|
import { Logger } from './lib/Logger.js';
|
|
43
43
|
import { CONTEXT_LABELS, TYPE_LABELS } from './lib/label-constants.js';
|
|
44
44
|
import { createProvider } from './lib/provider-factory.js';
|
|
45
|
+
import { concurrentMap } from './lib/util/concurrent-map.js';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Bounded fan-out for per-level `getSubTickets` calls. Matches the
|
|
49
|
+
* wave-record-io.js precedent (Story #3024).
|
|
50
|
+
*/
|
|
51
|
+
const SUB_TICKET_FETCH_CONCURRENCY = 4;
|
|
45
52
|
|
|
46
53
|
function classify(ticket) {
|
|
47
54
|
const labels = ticket.labels ?? [];
|
|
@@ -70,22 +77,35 @@ function ticketIsComplete(ticket) {
|
|
|
70
77
|
*/
|
|
71
78
|
async function collectDescendants(provider, epicId) {
|
|
72
79
|
const visited = new Set([epicId]);
|
|
73
|
-
const queue = [epicId];
|
|
74
80
|
const out = [];
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
81
|
+
// Level-order BFS: each round fetches the whole frontier's children with a
|
|
82
|
+
// bounded-parallel map instead of one awaited round-trip per node. Stories
|
|
83
|
+
// are 3-tier leaves (no sub-issues by contract), so they are never expanded
|
|
84
|
+
// — that skip alone removes the largest class of wasted GraphQL calls.
|
|
85
|
+
let frontier = [epicId];
|
|
86
|
+
while (frontier.length > 0) {
|
|
87
|
+
const levels = await concurrentMap(
|
|
88
|
+
frontier,
|
|
89
|
+
async (parentId) => {
|
|
90
|
+
try {
|
|
91
|
+
return await provider.getSubTickets(parentId);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
throw new Error(`getSubTickets(#${parentId}) failed: ${err.message}`);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
{ concurrency: SUB_TICKET_FETCH_CONCURRENCY },
|
|
97
|
+
);
|
|
98
|
+
const next = [];
|
|
99
|
+
for (const children of levels) {
|
|
100
|
+
for (const child of children) {
|
|
101
|
+
if (visited.has(child.id)) continue;
|
|
102
|
+
visited.add(child.id);
|
|
103
|
+
out.push(child);
|
|
104
|
+
const labels = child.labels ?? [];
|
|
105
|
+
if (!labels.includes(TYPE_LABELS.STORY)) next.push(child.id);
|
|
106
|
+
}
|
|
88
107
|
}
|
|
108
|
+
frontier = next;
|
|
89
109
|
}
|
|
90
110
|
return out;
|
|
91
111
|
}
|