mandrel 1.61.0 → 1.63.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/SDLC.md +10 -3
- package/.agents/docs/workflows.md +1 -1
- package/.agents/scripts/check-action-pinning.js +260 -0
- package/.agents/scripts/check-arch-cycles.js +38 -14
- package/.agents/scripts/epic-deliver-prepare.js +149 -104
- package/.agents/scripts/lib/baseline-snapshot.js +245 -141
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
- package/.agents/scripts/lib/orchestration/code-review.js +206 -168
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
- package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
- package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
- package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
- package/.agents/scripts/lib/signals/detectors/common.js +107 -0
- package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
- package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
- package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
- package/.agents/scripts/lib/story-body/story-body.js +102 -76
- package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
- package/.agents/scripts/single-story-init.js +16 -3
- package/.agents/workflows/audit-architecture.md +9 -0
- package/.agents/workflows/deliver.md +87 -26
- package/.agents/workflows/helpers/deliver-epic.md +12 -5
- package/.agents/workflows/helpers/deliver-stories.md +13 -7
- package/.agents/workflows/plan.md +3 -1
- package/README.md +1 -1
- package/docs/CHANGELOG.md +40 -0
- package/lib/cli/registry.js +1 -1
- package/lib/cli/update.js +114 -8
- package/package.json +1 -1
package/.agents/docs/SDLC.md
CHANGED
|
@@ -143,6 +143,11 @@ From zero to shipped:
|
|
|
143
143
|
a halt), re-run `/deliver <epicId>` — the wave loop picks up
|
|
144
144
|
incomplete Stories from the dispatch manifest automatically. Standalone
|
|
145
145
|
Stories (no `Epic: #N` reference) use `/deliver <storyId>` instead.
|
|
146
|
+
Mixed input — several Epics, or Epics plus standalone Stories — is
|
|
147
|
+
accepted in one invocation: `/deliver` composes a **sequential segment
|
|
148
|
+
plan** (the standalone-Story set as one segment, delivered first, then
|
|
149
|
+
each Epic as its own segment in input order) and executes the segments
|
|
150
|
+
one at a time through the same two path helpers, never interleaved.
|
|
146
151
|
|
|
147
152
|
That is the whole happy path. Everything below is **detail** — branching
|
|
148
153
|
conventions, HITL escalation, audit gates — that you only need when the
|
|
@@ -664,10 +669,12 @@ side-effects rather than inline calls at phase boundaries; the
|
|
|
664
669
|
| **Standalone Story — plan** | `/plan` | Plan a one-off Story that does not belong to an Epic backlog. |
|
|
665
670
|
| **Standalone Story — deliver** | `/deliver <storyId> [<storyId>...]` | Deliver one or more standalone Stories authored by `/plan`. |
|
|
666
671
|
| **Standalone Story (worker)** | *helper* `helpers/single-story-deliver <storyId>` | Per-Story sub-agent called internally by `/deliver`; not an operator slash command. |
|
|
672
|
+
| **Mixed set** | `/deliver <ids...>` | Any mix of ≥1 Epics and standalone Stories. The router composes a sequential segment plan — standalone segment first, then Epic segments in input order — delegating each segment to the path helpers above. |
|
|
667
673
|
|
|
668
|
-
The operator-facing entry
|
|
669
|
-
|
|
670
|
-
|
|
674
|
+
The single operator-facing entry point is `/deliver` — it routes a lone
|
|
675
|
+
Epic, a standalone-Story set, or a mixed set (via the sequential segment
|
|
676
|
+
plan) to the right path helper(s). The `helpers/` layer sits below it and
|
|
677
|
+
is never invoked directly by the operator.
|
|
671
678
|
|
|
672
679
|
### Story-centric branching
|
|
673
680
|
|
|
@@ -44,7 +44,7 @@ description, edit the workflow file’s front-matter and regenerate.
|
|
|
44
44
|
| `/audit-sre` | "Audit production-readiness for a release candidate: SLOs, observability, runbooks, error budgets, and rollback paths." |
|
|
45
45
|
| `/audit-to-stories` | Convert findings produced by the audit-\* workflows into actionable GitHub Stories. Reads temp/audits/audit-\*-results.md, groups findings cross-audit, deduplicates against existing Issues by fingerprint, and either chains into /plan --idea or opens standalone Stories. |
|
|
46
46
|
| `/audit-ux-ui` | Audit UX/UI consistency and design system adherence |
|
|
47
|
-
| `/deliver` | Unified delivery entry point. Inspects the ticket type(s) and Epic-reference state of the supplied IDs, then
|
|
47
|
+
| `/deliver` | Unified delivery entry point. Inspects the ticket type(s) and Epic-reference state of the supplied IDs, composes a sequential segment plan over any mix of Epics and standalone Stories, then delegates each segment to the Epic wave loop or the standalone multi-Story fan-out — preserving every flag and the parallel-delivery contract of the retired commands. |
|
|
48
48
|
| `/explain` | Walk the operator through a code change until they genuinely understand it. Targets a PR, a branch, or the working-tree diff, then drives the `core/knowledge-transfer` skill (restate-first, why-ladder, mastery gates, persistent checklist) with an operator-controlled stop at every checkpoint. |
|
|
49
49
|
| `/git-cleanup` | Tidy the local checkout in four phases: fast-forward `main`, prune stale remote-tracking refs, sweep merged branches (squash-aware), and triage `git stash` entries — each step gated by operator confirmation. |
|
|
50
50
|
| `/git-commit-all` | Stage every untracked and modified file, then create a single conventional-commit on the current branch (no push). |
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI: third-party GitHub Action pinning gate.
|
|
3
|
+
*
|
|
4
|
+
* Story #4079 (audit::devops). Closes a supply-chain regression window the
|
|
5
|
+
* `ci.yml` / `release-please.yml` comments *claimed* was guarded by a
|
|
6
|
+
* nonexistent `npm run audit-security` gate. There is no such npm script;
|
|
7
|
+
* `audit-security` is only a manual `/audit-security` slash-command lens.
|
|
8
|
+
* Nothing actually enforced that third-party `uses:` refs stay SHA-pinned,
|
|
9
|
+
* so a future edit reverting `trufflehog@<sha>` to `@main` would pass CI
|
|
10
|
+
* silently.
|
|
11
|
+
*
|
|
12
|
+
* This script scans `.github/workflows/*.yml` (and `*.yaml`), extracts every
|
|
13
|
+
* `uses:` ref, and fails the build when a **third-party** action (anything
|
|
14
|
+
* not under the first-party `actions/*` org) is pinned to a floating ref
|
|
15
|
+
* instead of a full 40-char commit SHA. First-party `actions/*` refs are
|
|
16
|
+
* allowed on major-version tags (`@v4`) — Dependabot's `github-actions`
|
|
17
|
+
* ecosystem bumps those in-place, matching the rationale in the workflow
|
|
18
|
+
* file headers.
|
|
19
|
+
*
|
|
20
|
+
* A "floating ref" is any of:
|
|
21
|
+
* - a branch head: `@main`, `@master`
|
|
22
|
+
* - a tag / partial-SHA that is not a full 40-hex-char commit SHA
|
|
23
|
+
* (`@v5`, `@v3.95.3`, `@release`, a 7-char short SHA, …)
|
|
24
|
+
*
|
|
25
|
+
* Contract:
|
|
26
|
+
* - Scans the workflows directory (default `.github/workflows`, override
|
|
27
|
+
* with `--dir <path>`).
|
|
28
|
+
* - Prints `<file>:<lineNo> <ref> — <reason>` for each violation, then a
|
|
29
|
+
* one-line summary even on a clean scan so operators see the "ok" signal.
|
|
30
|
+
* - With `--json`: writes a structured envelope to stdout and skips the
|
|
31
|
+
* human summary.
|
|
32
|
+
* - Exit codes: 0 = no violations; 1 = at least one floating third-party
|
|
33
|
+
* ref. A missing / empty workflows directory exits 0 (nothing to gate).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import process from 'node:process';
|
|
39
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse argv for `--dir <path>` and `--json`. Exported so unit tests can pin
|
|
43
|
+
* the parser.
|
|
44
|
+
*
|
|
45
|
+
* @param {string[]} argv
|
|
46
|
+
* @returns {{ dir: string | null, json: boolean }}
|
|
47
|
+
*/
|
|
48
|
+
export function parseArgv(argv = []) {
|
|
49
|
+
let dir = null;
|
|
50
|
+
let json = false;
|
|
51
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
52
|
+
const a = argv[i];
|
|
53
|
+
if (a === '--dir') {
|
|
54
|
+
const next = argv[i + 1];
|
|
55
|
+
if (next && !next.startsWith('--')) {
|
|
56
|
+
dir = next;
|
|
57
|
+
i += 1;
|
|
58
|
+
}
|
|
59
|
+
} else if (a === '--json') {
|
|
60
|
+
json = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { dir, json };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Is the given ref suffix a full 40-char hex commit SHA?
|
|
68
|
+
*
|
|
69
|
+
* @param {string} ref The portion after the `@` in a `uses:` value.
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
export function isFullSha(ref) {
|
|
73
|
+
return /^[0-9a-f]{40}$/i.test(ref);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Is the action a first-party `actions/*` action (e.g. `actions/checkout`)?
|
|
78
|
+
* First-party refs are allowed to float on major-version tags because
|
|
79
|
+
* Dependabot's `github-actions` ecosystem bumps them in-place.
|
|
80
|
+
*
|
|
81
|
+
* Local (`./…`) and reusable-workflow (`owner/repo/.github/workflows/x.yml`)
|
|
82
|
+
* refs and Docker refs (`docker://…`) are out of scope for the SHA-pin gate;
|
|
83
|
+
* `isFirstParty` only matters for `owner/repo[@ref]` registry actions.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} action The portion before the `@` in a `uses:` value.
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
export function isFirstParty(action) {
|
|
89
|
+
return /^actions\//.test(action);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Pure helper: scan a single workflow file's text for `uses:` refs and return
|
|
94
|
+
* the violations. A violation is a third-party `owner/repo@ref` where `ref`
|
|
95
|
+
* is not a full 40-char SHA.
|
|
96
|
+
*
|
|
97
|
+
* Skips:
|
|
98
|
+
* - local actions (`uses: ./path`)
|
|
99
|
+
* - Docker refs (`uses: docker://…`)
|
|
100
|
+
* - first-party `actions/*` refs (allowed on major-version tags)
|
|
101
|
+
* - refs with no `@` (pinned by default branch implicitly — flagged as a
|
|
102
|
+
* violation: an unpinned third-party ref floats on the default branch)
|
|
103
|
+
*
|
|
104
|
+
* @param {string} file Relative file label used in violation rows.
|
|
105
|
+
* @param {string} text The file contents.
|
|
106
|
+
* @returns {Array<{ file: string, line: number, action: string, ref: string | null, reason: string }>}
|
|
107
|
+
*/
|
|
108
|
+
export function scanWorkflowText(file, text) {
|
|
109
|
+
const violations = [];
|
|
110
|
+
const lines = text.split(/\r?\n/);
|
|
111
|
+
// Match `uses:` values, optionally quoted. The value runs until whitespace
|
|
112
|
+
// or a `#` comment. Capture the raw value for downstream parsing.
|
|
113
|
+
const usesRe = /^\s*(?:-\s*)?uses:\s*['"]?([^'"#\s]+)['"]?/;
|
|
114
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
115
|
+
const m = usesRe.exec(lines[i]);
|
|
116
|
+
if (!m) continue;
|
|
117
|
+
const value = m[1];
|
|
118
|
+
const lineNo = i + 1;
|
|
119
|
+
// Local actions and Docker refs are out of scope for the SHA-pin gate.
|
|
120
|
+
if (value.startsWith('./') || value.startsWith('docker://')) continue;
|
|
121
|
+
const atIndex = value.indexOf('@');
|
|
122
|
+
const action = atIndex === -1 ? value : value.slice(0, atIndex);
|
|
123
|
+
const ref = atIndex === -1 ? null : value.slice(atIndex + 1);
|
|
124
|
+
// First-party actions/* may float on major-version tags.
|
|
125
|
+
if (isFirstParty(action)) continue;
|
|
126
|
+
if (ref === null) {
|
|
127
|
+
violations.push({
|
|
128
|
+
file,
|
|
129
|
+
line: lineNo,
|
|
130
|
+
action,
|
|
131
|
+
ref: null,
|
|
132
|
+
reason: 'third-party action with no ref floats on the default branch',
|
|
133
|
+
});
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!isFullSha(ref)) {
|
|
137
|
+
const floating = ref === 'main' || ref === 'master';
|
|
138
|
+
violations.push({
|
|
139
|
+
file,
|
|
140
|
+
line: lineNo,
|
|
141
|
+
action,
|
|
142
|
+
ref,
|
|
143
|
+
reason: floating
|
|
144
|
+
? `third-party action pinned to branch head @${ref} (CWE-1357)`
|
|
145
|
+
: `third-party action @${ref} is not a full 40-char commit SHA`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return violations;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Enumerate workflow files (`*.yml` / `*.yaml`) directly under `dir`.
|
|
154
|
+
* Returns absolute paths sorted for deterministic output. A missing directory
|
|
155
|
+
* yields an empty list.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} dir Absolute workflows directory.
|
|
158
|
+
* @returns {string[]}
|
|
159
|
+
*/
|
|
160
|
+
export function listWorkflowFiles(dir) {
|
|
161
|
+
let entries;
|
|
162
|
+
try {
|
|
163
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
164
|
+
} catch {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
return entries
|
|
168
|
+
.filter((e) => e.isFile() && /\.ya?ml$/i.test(e.name))
|
|
169
|
+
.map((e) => path.join(dir, e.name))
|
|
170
|
+
.sort();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Pure helper: render the human-readable report. One line per violation
|
|
175
|
+
* followed by a one-line summary. The summary carries a `(gate fail)` /
|
|
176
|
+
* `(ok)` marker so the result is visible in CI output.
|
|
177
|
+
*
|
|
178
|
+
* @param {Array<{ file: string, line: number, action: string, ref: string | null, reason: string }>} violations
|
|
179
|
+
* @returns {string}
|
|
180
|
+
*/
|
|
181
|
+
export function renderReport(violations) {
|
|
182
|
+
const lines = [];
|
|
183
|
+
for (const v of violations) {
|
|
184
|
+
const refLabel = v.ref === null ? '(no ref)' : `@${v.ref}`;
|
|
185
|
+
lines.push(`${v.file}:${v.line} ${v.action}${refLabel} — ${v.reason}`);
|
|
186
|
+
}
|
|
187
|
+
const tag = violations.length > 0 ? '(gate fail)' : '(ok)';
|
|
188
|
+
lines.push(`[action-pinning] violations=${violations.length} ${tag}`);
|
|
189
|
+
return lines.join('\n');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Top-level CLI entry. Exported so tests can drive the full pipeline against a
|
|
194
|
+
* fixture workflows directory without touching the repo's real workflows.
|
|
195
|
+
*
|
|
196
|
+
* @param {{
|
|
197
|
+
* argv?: string[],
|
|
198
|
+
* cwd?: string,
|
|
199
|
+
* stdout?: { write: (s: string) => void },
|
|
200
|
+
* stderr?: { write: (s: string) => void },
|
|
201
|
+
* }} [opts]
|
|
202
|
+
* @returns {Promise<number>} exit code: 0 = clean; 1 = floating third-party ref
|
|
203
|
+
*/
|
|
204
|
+
export async function runCli({
|
|
205
|
+
argv = process.argv.slice(2),
|
|
206
|
+
cwd = process.cwd(),
|
|
207
|
+
stdout = process.stdout,
|
|
208
|
+
stderr = process.stderr,
|
|
209
|
+
} = {}) {
|
|
210
|
+
const { dir, json } = parseArgv(argv);
|
|
211
|
+
const resolvedDir = path.resolve(
|
|
212
|
+
cwd,
|
|
213
|
+
dir ?? path.join('.github', 'workflows'),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const files = listWorkflowFiles(resolvedDir);
|
|
217
|
+
const violations = [];
|
|
218
|
+
for (const file of files) {
|
|
219
|
+
let text;
|
|
220
|
+
try {
|
|
221
|
+
text = fs.readFileSync(file, 'utf-8');
|
|
222
|
+
} catch {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
violations.push(...scanWorkflowText(path.relative(cwd, file), text));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const exitCode = violations.length > 0 ? 1 : 0;
|
|
229
|
+
|
|
230
|
+
if (json) {
|
|
231
|
+
const envelope = {
|
|
232
|
+
kind: 'action-pinning-report',
|
|
233
|
+
dir: resolvedDir,
|
|
234
|
+
filesScanned: files.length,
|
|
235
|
+
violations,
|
|
236
|
+
exitCode,
|
|
237
|
+
};
|
|
238
|
+
stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
239
|
+
} else {
|
|
240
|
+
if (files.length === 0) {
|
|
241
|
+
stderr.write(
|
|
242
|
+
`[action-pinning] ⚠ no workflow files found under ${resolvedDir}\n`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
stdout.write(`\n--- action-pinning scan ---\n`);
|
|
246
|
+
stdout.write(`${renderReport(violations)}\n`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return exitCode;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function main() {
|
|
253
|
+
return runCli();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
runAsCli(import.meta.url, main, {
|
|
257
|
+
source: 'action-pinning',
|
|
258
|
+
propagateExitCode: true,
|
|
259
|
+
errorPrefix: '[action-pinning] ❌ Fatal error',
|
|
260
|
+
});
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI: ratchet-down architecture gate for import cycles (Story #3991).
|
|
3
3
|
*
|
|
4
|
-
* Walks every `.js` file
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Walks every `.js` file across the project's **distributed surface**
|
|
5
|
+
* (the `files[]` set published to npm — `.agents/scripts/`, `bin/`, and
|
|
6
|
+
* the root `lib/`, excluding `node_modules`), parses relative
|
|
7
|
+
* static-import edges (`from './…/x.js'`), detects directed cycles via
|
|
8
|
+
* DFS, and compares them against the committed allowlist at
|
|
9
|
+
* `baselines/arch-cycles.json`.
|
|
10
|
+
*
|
|
11
|
+
* The multi-root scan resolves every root into a **single** import graph
|
|
12
|
+
* keyed by repository-relative module ids (Story #4071). This lets
|
|
13
|
+
* `findCycles` catch cycles that cross the documented lifecycle↔runtime
|
|
14
|
+
* partition — e.g. a `bin/` lifecycle script and an `.agents/scripts/lib`
|
|
15
|
+
* runtime module importing each other — which a single-root scan cannot
|
|
16
|
+
* see because it only walks one side of the partition.
|
|
8
17
|
*
|
|
9
18
|
* Ratchet semantics mirror `check-dead-exports.js`:
|
|
10
19
|
* - Any detected cycle NOT in the allowlist → exit 1, cycle path printed.
|
|
@@ -19,8 +28,8 @@
|
|
|
19
28
|
* Flags:
|
|
20
29
|
* --baseline <path> override the allowlist path (default
|
|
21
30
|
* `baselines/arch-cycles.json`, resolved from cwd)
|
|
22
|
-
* --root <path>
|
|
23
|
-
*
|
|
31
|
+
* --root <path> scan a single explicit root instead of the default
|
|
32
|
+
* distributed surface, relativized against that root
|
|
24
33
|
* --json write the structured envelope to stdout
|
|
25
34
|
*/
|
|
26
35
|
|
|
@@ -29,6 +38,16 @@ import path from 'node:path';
|
|
|
29
38
|
import process from 'node:process';
|
|
30
39
|
import { runAsCli } from './lib/cli-utils.js';
|
|
31
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Default scan roots making up the project's distributed surface — the
|
|
43
|
+
* directories published to npm via `package.json` `files[]`. Resolving
|
|
44
|
+
* them into one graph (relativized against the repo root) means a cycle
|
|
45
|
+
* crossing two roots is visible to `findCycles`.
|
|
46
|
+
*
|
|
47
|
+
* @type {string[]}
|
|
48
|
+
*/
|
|
49
|
+
export const DEFAULT_ROOTS = [path.join('.agents', 'scripts'), 'bin', 'lib'];
|
|
50
|
+
|
|
32
51
|
/**
|
|
33
52
|
* Parse argv for `--baseline <path>`, `--root <path>`, and `--json`.
|
|
34
53
|
* Exported so unit tests can pin the parser.
|
|
@@ -304,22 +323,27 @@ export async function runCli({
|
|
|
304
323
|
stderr = process.stderr,
|
|
305
324
|
} = {}) {
|
|
306
325
|
const { baselinePath, rootPath, json } = parseArgv(argv);
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
326
|
+
// With an explicit `--root`, scan that single root and relativize ids
|
|
327
|
+
// against it (unchanged contract). Without it, scan the full distributed
|
|
328
|
+
// surface and relativize every id against the repo root (`cwd`) so edges
|
|
329
|
+
// that cross two roots resolve into a single graph.
|
|
330
|
+
const graphRoot = rootPath ? path.resolve(cwd, rootPath) : path.resolve(cwd);
|
|
331
|
+
const scanDirs = (rootPath ? [rootPath] : DEFAULT_ROOTS).map((dir) =>
|
|
332
|
+
path.resolve(cwd, dir),
|
|
310
333
|
);
|
|
311
334
|
const resolvedBaselinePath = path.resolve(
|
|
312
335
|
cwd,
|
|
313
336
|
baselinePath ?? path.join('baselines', 'arch-cycles.json'),
|
|
314
337
|
);
|
|
315
|
-
|
|
316
|
-
|
|
338
|
+
const presentScanDirs = scanDirs.filter((dir) => fs.existsSync(dir));
|
|
339
|
+
if (presentScanDirs.length === 0) {
|
|
340
|
+
throw new Error(`[arch-cycles] no scan root found: ${scanDirs.join(', ')}`);
|
|
317
341
|
}
|
|
318
342
|
const baseline = loadBaseline(resolvedBaselinePath);
|
|
319
343
|
const allowlisted = Array.isArray(baseline?.cycles) ? baseline.cycles : [];
|
|
320
344
|
|
|
321
|
-
const files = collectJsFiles(
|
|
322
|
-
const graph = buildGraph(files,
|
|
345
|
+
const files = presentScanDirs.flatMap((dir) => collectJsFiles(dir));
|
|
346
|
+
const graph = buildGraph(files, graphRoot);
|
|
323
347
|
const detected = findCycles(graph);
|
|
324
348
|
const diff = diffCycles(allowlisted, detected);
|
|
325
349
|
const exitCode = diff.added.length > 0 ? 1 : 0;
|
|
@@ -327,7 +351,7 @@ export async function runCli({
|
|
|
327
351
|
if (json) {
|
|
328
352
|
const envelope = {
|
|
329
353
|
kind: 'arch-cycles-report',
|
|
330
|
-
root:
|
|
354
|
+
root: graphRoot,
|
|
331
355
|
baselinePath: resolvedBaselinePath,
|
|
332
356
|
allowlisted: allowlisted.map(normalizeCycle),
|
|
333
357
|
detected,
|