@ecoma-io/archkeep 0.13.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/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- package/src/workspace.mjs +580 -0
package/cli.mjs
ADDED
|
@@ -0,0 +1,2792 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Command-line entry for the module-boundary enforcer — the surface that turns
|
|
4
|
+
* a verdict into a failed build.
|
|
5
|
+
*
|
|
6
|
+
* `check` reads the project graph, analyzes every tracked source file a
|
|
7
|
+
* project owns, judges the import sites against the workspace's boundary law,
|
|
8
|
+
* and exits 1 if anything violates it. That closes a measured hole: a
|
|
9
|
+
* layer-violating import in a Go file left `nx run <project>:lint` at exit 0,
|
|
10
|
+
* because that target runs ESLint and ESLint answers "File ignored because no
|
|
11
|
+
* matching configuration was supplied" for a `.go` file — the tags were a
|
|
12
|
+
* declaration with no mechanism behind them.
|
|
13
|
+
*
|
|
14
|
+
* The three layers below it stay unaware of this one, which is what lets an
|
|
15
|
+
* editor reuse them: `src/analysis/` never decides which files to visit,
|
|
16
|
+
* `src/rules/` never reads a file, and `src/report/` never decides whether
|
|
17
|
+
* something is a violation. This file owns the two decisions nobody else may
|
|
18
|
+
* make — which tree to judge, and what the exit code means. Three things it
|
|
19
|
+
* once held sit below it now, each because a second caller needed the same
|
|
20
|
+
* answer: `src/commands/context.mjs` owns which workspace, which provider,
|
|
21
|
+
* which files, and what analyzing them found; `src/commands/check.mjs` owns
|
|
22
|
+
* `check` itself, the computation with nothing about argv or the destination
|
|
23
|
+
* in it; and `src/verdict.mjs` owns the exit-code table and the one function
|
|
24
|
+
* that turns a run's counts into a verdict, so the report and the process
|
|
25
|
+
* cannot disagree about one. `src/commands/README.md` states the rule those
|
|
26
|
+
* modules follow.
|
|
27
|
+
*
|
|
28
|
+
* When the workspace has a tracked `go.work` at its root, `check` also
|
|
29
|
+
* compares its `use` list against the graph's go.mod projects
|
|
30
|
+
* (`src/go-work.mjs` owns the mechanics): drift means a developer's `go build`
|
|
31
|
+
* and CI select different module sets, so a drift finding fails the run the
|
|
32
|
+
* way a violation does. The comparison is workspace-level and ignores path
|
|
33
|
+
* scoping — two lists are being compared, not files analyzed.
|
|
34
|
+
*
|
|
35
|
+
* When the workspace tsconfig declares a `paths` table, `check` also judges
|
|
36
|
+
* each alias for life (`src/tsconfig-paths.mjs` owns the rule and its limits):
|
|
37
|
+
* an alias whose every target points into directories that do not exist
|
|
38
|
+
* resolves no import, so it fails the run the way a violation does. Same
|
|
39
|
+
* workspace-level shape as go.work — a table is judged, not files analyzed.
|
|
40
|
+
*
|
|
41
|
+
* When the boundary policy declares `customRules`, `check` also judges each
|
|
42
|
+
* declared rule against the evidence this run already computed
|
|
43
|
+
* (`src/commands/custom-rules.mjs` owns the mechanics and the failure split).
|
|
44
|
+
* By presence, never by flag, and folded into the same exit machinery fitness
|
|
45
|
+
* uses: a `fail` verdict is a finding, an `unknown` one is a
|
|
46
|
+
* could-not-determine, and a rule whose ARTIFACT could not be loaded refuses
|
|
47
|
+
* the run the way a malformed config does rather than becoming a verdict about
|
|
48
|
+
* a law that was never read.
|
|
49
|
+
*
|
|
50
|
+
* Exit codes are part of the contract; a script calling this has to tell "your
|
|
51
|
+
* tree is dirty" from "you typed it wrong" from "the checker itself broke":
|
|
52
|
+
* 0 no violations, and every selected file was analyzed
|
|
53
|
+
* 1 findings — boundary violations, go.work drift, dead tsconfig path
|
|
54
|
+
* aliases, or architecture-intent findings. `check` is the only command
|
|
55
|
+
* that can produce this exit code — every other verb this table might grow
|
|
56
|
+
* only ever reads.
|
|
57
|
+
* 2 usage error — unknown command, unknown flag, missing argument, path
|
|
58
|
+
* outside the tree
|
|
59
|
+
* 3 no verdict — no workspace, malformed config, the graph provider or git
|
|
60
|
+
* failed, a selected file could not be analyzed, an architecture-intent
|
|
61
|
+
* boundary matched no observed project, or a `boundarySuppressions` row
|
|
62
|
+
* accepts nothing this run judged. Distinct from
|
|
63
|
+
* 1 on purpose: a checker that could not look must never be mistaken for
|
|
64
|
+
* one that looked and found nothing.
|
|
65
|
+
*
|
|
66
|
+
* That last clause is why 3 covers a partial run and not only a total one. A
|
|
67
|
+
* file with no analyzer, an unreadable file, a `tsconfig` that will not load —
|
|
68
|
+
* each leaves a file the summary counts but no rule ever judged, and exiting 0
|
|
69
|
+
* there is precisely the mistake the code exists to prevent. An import site
|
|
70
|
+
* whose specifier is not statically knowable is NOT this case: that file was
|
|
71
|
+
* judged, one position in it has no answer, and `src/report/text.mjs` prints
|
|
72
|
+
* the two under separate headings for the same reason they get separate codes.
|
|
73
|
+
*
|
|
74
|
+
* `--format json` wraps the same verdict in the versioned envelope
|
|
75
|
+
* `src/report/json.mjs` builds — `docs/reference/json-output.md` is the
|
|
76
|
+
* published contract. It changes no exit code and no byte of the text
|
|
77
|
+
* or SARIF report; it is a third rendering of a verdict every other format
|
|
78
|
+
* already computes.
|
|
79
|
+
*
|
|
80
|
+
* `COMMANDS` below is a table rather than a `switch`, and `parseArgs` is
|
|
81
|
+
* shared rather than hand-rolled per command, so a new command is a new
|
|
82
|
+
* row rather than a second copy of the dispatch and flag-parsing this file
|
|
83
|
+
* used to own alone. The table carries the whole command surface (`COMMANDS`
|
|
84
|
+
* is the one copy of it; `docs/reference/cli.md` documents each row), with
|
|
85
|
+
* three flags shared across most of them (`--format`, `--output`, `--config`)
|
|
86
|
+
* and `history`'s boolean `--capture`, the first flag that takes no value. No subcommand nesting, and no shell completion to
|
|
87
|
+
* generate, mean a plain table gets there without a framework; reach for one
|
|
88
|
+
* only once a later command needs something this table cannot express.
|
|
89
|
+
*/
|
|
90
|
+
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
91
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
92
|
+
|
|
93
|
+
import { containmentViolation } from "./src/containment.mjs";
|
|
94
|
+
import { UsageError } from "./src/errors.mjs";
|
|
95
|
+
import { check, sortViolations } from "./src/commands/check.mjs";
|
|
96
|
+
import { hasProfiles, resolvePolicy } from "./src/commands/policy.mjs";
|
|
97
|
+
import {
|
|
98
|
+
DEFAULT_OPTIONS,
|
|
99
|
+
WORKSPACE_MARKERS,
|
|
100
|
+
markersAt,
|
|
101
|
+
resolveCommandContext,
|
|
102
|
+
} from "./src/commands/context.mjs";
|
|
103
|
+
import { contextCommand } from "./src/commands/context-command.mjs";
|
|
104
|
+
import { planContextCommand } from "./src/commands/plan-context-command.mjs";
|
|
105
|
+
import { adrCommand } from "./src/commands/adr.mjs";
|
|
106
|
+
import { diffCommand } from "./src/commands/diff.mjs";
|
|
107
|
+
import { discoverCommand } from "./src/commands/discover.mjs";
|
|
108
|
+
import { driftCommand } from "./src/commands/drift.mjs";
|
|
109
|
+
import { fitnessCommand } from "./src/commands/fitness.mjs";
|
|
110
|
+
import { reconcileCommand } from "./src/commands/reconcile.mjs";
|
|
111
|
+
import { computePolicyFingerprint, graphCommand } from "./src/commands/graph.mjs";
|
|
112
|
+
import { historyCommand } from "./src/commands/history.mjs";
|
|
113
|
+
import { healthCommand } from "./src/commands/health.mjs";
|
|
114
|
+
import { reportCommand } from "./src/commands/report.mjs";
|
|
115
|
+
import { debtCommand } from "./src/commands/debt.mjs";
|
|
116
|
+
import { explainCommand } from "./src/commands/explain.mjs";
|
|
117
|
+
import { impactCommand } from "./src/commands/impact.mjs";
|
|
118
|
+
import { provenanceCommand } from "./src/commands/provenance-command.mjs";
|
|
119
|
+
import { waiversCommand } from "./src/commands/waivers.mjs";
|
|
120
|
+
import { INTENT_FILE, loadIntent } from "./src/architecture-intent/model.mjs";
|
|
121
|
+
import { isProgramEntry } from "./src/entry-point.mjs";
|
|
122
|
+
import { readPluginOptions } from "./src/options.mjs";
|
|
123
|
+
import { EXIT, verdictFor } from "./src/verdict.mjs";
|
|
124
|
+
|
|
125
|
+
import { ARCHKEEP_MODEL_FILE, loadNativeModel } from "./src/providers/native/model.mjs";
|
|
126
|
+
import { findWorkspaceRoot, listTrackedFiles } from "./src/workspace.mjs";
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Workspace-relative read from `root`, the same default `createWorkspace`
|
|
130
|
+
* builds when no reader is injected (`./src/workspace.mjs`) — duplicated
|
|
131
|
+
* rather than imported for the reason `./src/commands/context.mjs` carries its
|
|
132
|
+
* own copy of the same helper: `optionsForUsage` below needs one BEFORE any
|
|
133
|
+
* `Workspace` exists, to hand `loadNativeModel` a reader for `archkeep.json`
|
|
134
|
+
* itself. `check` no longer needs a copy of its own — `resolveCommandContext`
|
|
135
|
+
* owns that read now — which is why this is the only one left in this file.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} root
|
|
138
|
+
* @returns {(path: string) => string|null}
|
|
139
|
+
*/
|
|
140
|
+
function readWorkspaceRoot(root) {
|
|
141
|
+
return (path) => {
|
|
142
|
+
const abs = join(root, path);
|
|
143
|
+
// Same containment rule as the `check` reader (`./src/containment.mjs`):
|
|
144
|
+
// a tracked symlink whose realpath leaves the workspace hands `--help`
|
|
145
|
+
// outside bytes as the workspace's own declaration. Refusing keeps a
|
|
146
|
+
// symlinked `archkeep.json` from being read as the model (`../G-10` class).
|
|
147
|
+
if (containmentViolation(root, abs) !== null) return null;
|
|
148
|
+
try {
|
|
149
|
+
return readFileSync(abs, "utf8");
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Re-exported under this module's own name because the bodies moved below it —
|
|
157
|
+
// `./src/commands/check.mjs` owns the computation and `./src/verdict.mjs` the
|
|
158
|
+
// exit-code contract — while every importer still reads them from here,
|
|
159
|
+
// `./src/conformance/corpus-engine.mjs` among them.
|
|
160
|
+
export { EXIT, check, sortViolations };
|
|
161
|
+
|
|
162
|
+
/** Every format `check --format` accepts, in the order the help text lists them. */
|
|
163
|
+
const CHECK_FORMATS = Object.freeze(["text", "sarif", "json"]);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Every format the descriptive commands (`graph`, `diff`) accept. Text and the
|
|
167
|
+
* versioned JSON envelope — no SARIF, because a descriptive command does not
|
|
168
|
+
* produce findings and SARIF's `results[]` is a findings container.
|
|
169
|
+
*/
|
|
170
|
+
const DESCRIBABLE_FORMATS = Object.freeze(["text", "json"]);
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Column `usage()`'s Options block aligns flag descriptions to. Matches the
|
|
174
|
+
* hand-written text this table-driven rendering replaced, so deriving the
|
|
175
|
+
* block from `COMMANDS` changes no byte of it.
|
|
176
|
+
*/
|
|
177
|
+
const FLAG_HELP_COLUMN = 24;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* One row of `usage()`'s Options block, and the source of the `flag`
|
|
181
|
+
* `parseArgs` needs. Kept as the single place a flag's name, its parsed key,
|
|
182
|
+
* and its printed description live — a hand-kept second list beside
|
|
183
|
+
* `COMMANDS` is exactly the drift `usage()`'s header argues against.
|
|
184
|
+
*
|
|
185
|
+
* @typedef {object} FlagHelp
|
|
186
|
+
* @property {string} flag The literal flag, e.g. `--format`.
|
|
187
|
+
* @property {string} key The key `parseArgs` fills in the parsed options.
|
|
188
|
+
* @property {string} arg The placeholder shown after the flag, e.g. `<file>`.
|
|
189
|
+
* @property {readonly string[] | ((options: {boundaryConfig: string, inline?: boolean}) => readonly string[])} describe
|
|
190
|
+
* The description, one array entry per printed line. A function when the
|
|
191
|
+
* text depends on what THIS workspace's own `boundaryConfig` resolved to
|
|
192
|
+
* (`--config`'s second line, which names it).
|
|
193
|
+
*/
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Renders one `FlagHelp` as it appears in `--help`'s Options block: the flag
|
|
197
|
+
* and its placeholder, padded to `FLAG_HELP_COLUMN` (or a bare 3-space gap
|
|
198
|
+
* when the header itself already runs past that column), continuation lines
|
|
199
|
+
* indented to the same column.
|
|
200
|
+
*
|
|
201
|
+
* @param {FlagHelp} flagHelp
|
|
202
|
+
* @param {{boundaryConfig: string, inline?: boolean}} options
|
|
203
|
+
* @returns {string}
|
|
204
|
+
*/
|
|
205
|
+
function renderFlagHelp(flagHelp, options) {
|
|
206
|
+
const header = ` ${flagHelp.flag} ${flagHelp.arg}`;
|
|
207
|
+
const gap = " ".repeat(Math.max(3, FLAG_HELP_COLUMN - header.length));
|
|
208
|
+
const lines =
|
|
209
|
+
typeof flagHelp.describe === "function" ? flagHelp.describe(options) : flagHelp.describe;
|
|
210
|
+
const continuationIndent = " ".repeat(FLAG_HELP_COLUMN);
|
|
211
|
+
return [
|
|
212
|
+
`${header}${gap}${lines[0]}`,
|
|
213
|
+
...lines.slice(1).map((line) => `${continuationIndent}${line}`),
|
|
214
|
+
].join("\n");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The help text, told what THIS workspace calls its boundary config.
|
|
219
|
+
*
|
|
220
|
+
* A function rather than a constant because the filename is a per-workspace
|
|
221
|
+
* option now (`src/options.mjs`). Printing the default in a workspace that
|
|
222
|
+
* renamed it would send the reader to look for a file that is not there — and
|
|
223
|
+
* `--config`'s whole description is "instead of the one at the root", which
|
|
224
|
+
* says nothing useful if the one at the root is misnamed in the sentence.
|
|
225
|
+
*
|
|
226
|
+
* `inline` is true only for a native workspace whose `archkeep.json →
|
|
227
|
+
* boundaryConfig` is the policy object itself rather than a filename
|
|
228
|
+
* (`docs/reference/policy-schema.md`, "An inline policy, for archkeep.json") — there
|
|
229
|
+
* is then no file to name, no ESLint table it is shared with, and no
|
|
230
|
+
* `nx.json` to change it through, so that case gets its own paragraph rather
|
|
231
|
+
* than a sentence that assumes a filename exists.
|
|
232
|
+
*
|
|
233
|
+
* The command list AND the Options block both render straight from
|
|
234
|
+
* `COMMANDS` — the command line from each row's `name`/`args`/`summary`, the
|
|
235
|
+
* flag list from each row's `flagHelp` — so a command or a flag added later
|
|
236
|
+
* cannot end up missing from `--help` the way a hand-kept second copy could,
|
|
237
|
+
* and adding one changes no line of this function.
|
|
238
|
+
*
|
|
239
|
+
* @param {{boundaryConfig: string, inline?: boolean}} options
|
|
240
|
+
*/
|
|
241
|
+
const usage = ({ boundaryConfig, inline = false }) => {
|
|
242
|
+
const commandLines = Object.values(COMMANDS)
|
|
243
|
+
.map((command) => ` archkeep ${command.name} ${command.args} ${command.summary}`)
|
|
244
|
+
.join("\n");
|
|
245
|
+
// Flags are deduplicated by name across commands — today only `check` has
|
|
246
|
+
// any, but a second command sharing `--format` must not print it twice.
|
|
247
|
+
const seenFlags = new Set();
|
|
248
|
+
const optionLines = Object.values(COMMANDS)
|
|
249
|
+
.flatMap((command) => command.flagHelp)
|
|
250
|
+
.filter((flagHelp) => {
|
|
251
|
+
if (seenFlags.has(flagHelp.flag)) return false;
|
|
252
|
+
seenFlags.add(flagHelp.flag);
|
|
253
|
+
return true;
|
|
254
|
+
})
|
|
255
|
+
.map((flagHelp) => renderFlagHelp(flagHelp, { boundaryConfig, inline }))
|
|
256
|
+
.join("\n");
|
|
257
|
+
return `archkeep — module-boundary enforcement across every language in the workspace
|
|
258
|
+
|
|
259
|
+
Usage:
|
|
260
|
+
${commandLines}
|
|
261
|
+
archkeep --help Show this message
|
|
262
|
+
|
|
263
|
+
Options:
|
|
264
|
+
${optionLines}
|
|
265
|
+
|
|
266
|
+
${
|
|
267
|
+
inline
|
|
268
|
+
? `Projects and tags come from archkeep.json's own declared/inferred model; the rules come
|
|
269
|
+
from ${boundaryConfig} — an inline policy object on archkeep.json's own \`boundaryConfig\`
|
|
270
|
+
field, not a separate file. There is no filename here for ESLint to share and no nx.json
|
|
271
|
+
to change it through; see docs/reference/policy-schema.md's "An inline policy" section.`
|
|
272
|
+
: `Projects and tags come from the project graph (archkeep.json or Nx); the rules come from
|
|
273
|
+
${boundaryConfig} at the workspace root — the same table ESLint
|
|
274
|
+
reads, so both enforcers answer from one source. That filename is a
|
|
275
|
+
convention and can be changed per workspace: through archkeep.json's
|
|
276
|
+
\`boundaryConfig\` field, or the integration's \`boundaryConfig\` option in nx.json.`
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
Naming paths scopes the run to those files. That is a fast local pre-check and
|
|
280
|
+
not the gate: the cycle and lazy-load rules judge the file graph as a whole, so
|
|
281
|
+
a scoped run can miss what a whole-workspace run would find.
|
|
282
|
+
|
|
283
|
+
A workspace with a go.work at its root also has its use list compared against
|
|
284
|
+
every project's go.mod, whatever paths scope the run — a module in one list
|
|
285
|
+
and not the other means a developer's go build and CI build different trees.
|
|
286
|
+
|
|
287
|
+
A workspace whose tsconfig declares a paths table also has each alias judged
|
|
288
|
+
for life: an alias whose every target points into directories that do not
|
|
289
|
+
exist resolves no import, so it fails the run the way a violation does. The
|
|
290
|
+
table itself is never re-resolved — the check reads the same parsed tsconfig
|
|
291
|
+
the import resolver uses.
|
|
292
|
+
|
|
293
|
+
Exit codes: ${EXIT.ok} clean · ${EXIT.violations} findings (violations, go.work drift, dead path aliases) · ${EXIT.usage} usage error · ${EXIT.error} no verdict (a file could not be analyzed, or the run could not start)`;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The options to WORD the help text with — best-effort, never fatal.
|
|
298
|
+
*
|
|
299
|
+
* `--help` has to work in a tree with a broken `nx.json`; refusing to print help
|
|
300
|
+
* because the thing help would explain is misconfigured is the wrong order. A
|
|
301
|
+
* real run reads the same options strictly, inside `check`, where a malformed
|
|
302
|
+
* `nx.json` is a reason to stop rather than a reason to print a default.
|
|
303
|
+
*/
|
|
304
|
+
function optionsForUsage(cwd) {
|
|
305
|
+
try {
|
|
306
|
+
const root = resolveWorkspaceRootForUsage(cwd);
|
|
307
|
+
if (root === null) return DEFAULT_OPTIONS;
|
|
308
|
+
const { hasNx, hasNative } = markersAt(root);
|
|
309
|
+
if (hasNative && !hasNx) {
|
|
310
|
+
const model = loadNativeModel(root, { readFile: readWorkspaceRoot(root) });
|
|
311
|
+
// An inline policy object has no filename to print — `${boundaryConfig}`
|
|
312
|
+
// below would otherwise coerce it to the literal text "[object Object]",
|
|
313
|
+
// which reads as a real (and wrong) filename rather than as the "there
|
|
314
|
+
// is no file" it actually means. `inline: true` is what tells `usage()`
|
|
315
|
+
// to print the paragraph that says so, instead of the one describing a
|
|
316
|
+
// named file.
|
|
317
|
+
return typeof model.boundaryConfig === "string"
|
|
318
|
+
? { boundaryConfig: model.boundaryConfig, tsConfig: model.tsConfig }
|
|
319
|
+
: {
|
|
320
|
+
boundaryConfig: "an inline policy in archkeep.json",
|
|
321
|
+
tsConfig: model.tsConfig,
|
|
322
|
+
inline: true,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return readPluginOptions(root);
|
|
326
|
+
} catch {
|
|
327
|
+
return DEFAULT_OPTIONS;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Walks up from `cwd` looking for any root marker — the same walk
|
|
333
|
+
* `resolveCommandContext` does, kept separate here because `--help` has to work
|
|
334
|
+
* with no workspace at all (returning `DEFAULT_OPTIONS`) while `check` throws
|
|
335
|
+
* on exactly that condition; the two callers cannot share one function without
|
|
336
|
+
* one of them losing its posture.
|
|
337
|
+
*
|
|
338
|
+
* They share the marker LIST all the same (`WORKSPACE_MARKERS`), because
|
|
339
|
+
* differing postures are the reason for two functions and differing answers to
|
|
340
|
+
* "what is a workspace root" never were. This walk used to hold its own copy
|
|
341
|
+
* naming `nx.json` and `archkeep.json` only, and the omission of `.moon` made
|
|
342
|
+
* `adr` unusable on every Moon workspace — this repository included — while
|
|
343
|
+
* its own error message named `.moon` as something it had looked for.
|
|
344
|
+
*
|
|
345
|
+
* @param {string} cwd
|
|
346
|
+
* @returns {string|null}
|
|
347
|
+
*/
|
|
348
|
+
function resolveWorkspaceRootForUsage(cwd) {
|
|
349
|
+
return findWorkspaceRoot(cwd, WORKSPACE_MARKERS);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Splits a command's arguments into its declared flags and a list of
|
|
354
|
+
* positional paths.
|
|
355
|
+
*
|
|
356
|
+
* Shared by every command's table entry, rather than hand-rolled per command:
|
|
357
|
+
* `spec.flags` maps each `--flag` this command accepts to the key it fills in
|
|
358
|
+
* the returned object, `spec.defaults` seeds those keys before argv is read,
|
|
359
|
+
* and `spec.formats` — when the command has one — is the closed list
|
|
360
|
+
* `--format` must resolve to.
|
|
361
|
+
*
|
|
362
|
+
* Rejects an unknown `--flag` rather than treating it as a path: a typo like
|
|
363
|
+
* `--fromat sarif` would otherwise be read as two paths, select no files, and
|
|
364
|
+
* report a clean tree — the exact false green this tool exists to remove.
|
|
365
|
+
*
|
|
366
|
+
* @param {string[]} argv
|
|
367
|
+
* @param {{flags: Record<string,string>, defaults: object, formats?: readonly string[],
|
|
368
|
+
* booleans?: readonly string[]}} spec
|
|
369
|
+
* @returns {object} `{...spec.defaults, paths: string[]}`, with every declared
|
|
370
|
+
* flag's value substituted in.
|
|
371
|
+
* @throws {Error} on an unknown flag, a missing value, or (when `spec.formats`
|
|
372
|
+
* is given) a `--format` value outside it.
|
|
373
|
+
*/
|
|
374
|
+
export function parseArgs(argv, spec) {
|
|
375
|
+
const parsed = { ...spec.defaults, paths: [] };
|
|
376
|
+
const booleans = new Set(spec.booleans ?? []);
|
|
377
|
+
for (let index = 0; index < argv.length; index++) {
|
|
378
|
+
const arg = argv[index];
|
|
379
|
+
if (!arg.startsWith("--")) {
|
|
380
|
+
parsed.paths.push(arg);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const [flag, inlineValue] = arg.includes("=")
|
|
384
|
+
? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)]
|
|
385
|
+
: [arg, undefined];
|
|
386
|
+
const key = spec.flags[flag];
|
|
387
|
+
if (!key) throw new Error(`unknown option '${flag}'`);
|
|
388
|
+
if (booleans.has(key)) {
|
|
389
|
+
// A boolean flag takes no value. `--capture` must not swallow the next
|
|
390
|
+
// argument (the history directory) as its value the way a value-flag
|
|
391
|
+
// would; the presence of the flag is the whole assertion.
|
|
392
|
+
if (inlineValue !== undefined) {
|
|
393
|
+
throw new Error(`'${flag}' takes no value`);
|
|
394
|
+
}
|
|
395
|
+
parsed[key] = true;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const value = inlineValue ?? argv[++index];
|
|
399
|
+
if (value === undefined) throw new Error(`'${flag}' needs a value`);
|
|
400
|
+
parsed[key] = value;
|
|
401
|
+
}
|
|
402
|
+
if (spec.formats && !spec.formats.includes(parsed.format)) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
`unknown format '${parsed.format}' — expected one of ${spec.formats.join(", ")}`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
return parsed;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* `parseArgs` bound to `check`'s own flag spec — kept as its own export
|
|
412
|
+
* because `src/cli.integration.test.mjs` already drives it directly, and a
|
|
413
|
+
* refactor that moved its tests to `parseArgs` instead would be testing the
|
|
414
|
+
* generic parser rather than the contract `check`'s callers rely on.
|
|
415
|
+
*
|
|
416
|
+
* @param {string[]} argv Arguments after `check`.
|
|
417
|
+
* @returns {{format: string, output: string|null, config: string|null, paths: string[]}}
|
|
418
|
+
* @throws {Error} on an unknown flag, a missing value, or an unknown format.
|
|
419
|
+
*/
|
|
420
|
+
export function parseCheckArgs(argv) {
|
|
421
|
+
return parseArgs(argv, COMMANDS.check);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The fixed names `--output` may never resolve to, resolved the same ways
|
|
426
|
+
* this file already resolves each of them elsewhere: `INTENT_FILE` and
|
|
427
|
+
* `ARCHKEEP_MODEL_FILE` are workspace-root-relative constants read at
|
|
428
|
+
* `resolve(root, …)` wherever `check`/`drift` load them; the boundary law is
|
|
429
|
+
* `options.config` resolved `isAbsolute(...) ? ... : resolve(cwd, ...)` — the
|
|
430
|
+
* exact expression repeated at every other `options.config` read in this
|
|
431
|
+
* file — falling back to the per-provider name `optionsForUsage(cwd)`
|
|
432
|
+
* resolves (`nx.json`'s `plugins[].options.boundaryConfig`, a `archkeep.json`
|
|
433
|
+
* `boundaryConfig`, or the default), because that is the name this run's
|
|
434
|
+
* `check`/`graph` actually load through `resolvePolicy`. A workspace that
|
|
435
|
+
* renamed its law via `nx.json` or `archkeep.json` is covered, not just the
|
|
436
|
+
* default name and the `--config` override.
|
|
437
|
+
*
|
|
438
|
+
* `root === null` (no workspace reachable from `cwd`) returns an empty map
|
|
439
|
+
* rather than throwing: every caller of `writeOutputReport` below already
|
|
440
|
+
* resolved a workspace earlier in its own run, from this same `cwd`, or it
|
|
441
|
+
* would not have reached the point of writing a report at all — this is a
|
|
442
|
+
* defensive fallback, not a path any command here takes today.
|
|
443
|
+
*
|
|
444
|
+
* @param {string} cwd
|
|
445
|
+
* @param {string|null|undefined} configOption This run's `options.config`.
|
|
446
|
+
* @returns {Map<string, string>} absolute path → the name to name back in a
|
|
447
|
+
* refusal, for every fixed name that resolved.
|
|
448
|
+
*/
|
|
449
|
+
function governanceOutputTargets(cwd, configOption) {
|
|
450
|
+
const root = resolveWorkspaceRootForUsage(cwd);
|
|
451
|
+
if (root === null) return new Map();
|
|
452
|
+
const boundaryConfigName =
|
|
453
|
+
configOption ?? optionsForUsage(cwd).boundaryConfig ?? DEFAULT_OPTIONS.boundaryConfig;
|
|
454
|
+
const boundaryConfigAbs = configOption
|
|
455
|
+
? isAbsolute(configOption)
|
|
456
|
+
? configOption
|
|
457
|
+
: resolve(cwd, configOption)
|
|
458
|
+
: resolve(root, boundaryConfigName);
|
|
459
|
+
return new Map([
|
|
460
|
+
[resolve(root, INTENT_FILE), INTENT_FILE],
|
|
461
|
+
[resolve(root, ARCHKEEP_MODEL_FILE), ARCHKEEP_MODEL_FILE],
|
|
462
|
+
[boundaryConfigAbs, boundaryConfigName],
|
|
463
|
+
]);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Writes a rendered report to `--output`'s path, atomically and without
|
|
468
|
+
* following a symlink planted at the temp path — the one function every
|
|
469
|
+
* `run*` below calls rather than the 16 near-identical inline copies this
|
|
470
|
+
* replaces.
|
|
471
|
+
*
|
|
472
|
+
* Refuses first, before any write is attempted, on two grounds. `outputPath`
|
|
473
|
+
* resolving to a name `governanceOutputTargets` above guards — the workspace's
|
|
474
|
+
* declared architecture intent, its native model file, or its boundary law,
|
|
475
|
+
* however that law is named — is one. The other is containment: a
|
|
476
|
+
* workspace-controlled symlink in an INTERMEDIATE directory component of
|
|
477
|
+
* `outputPath` (`./src/containment.mjs`'s `containmentViolation` with
|
|
478
|
+
* `forWrite: true`) would make this write land somewhere other than the path
|
|
479
|
+
* the user named — outside the workspace entirely, or at the workspace root
|
|
480
|
+
* for a `sub -> .` self-loop — with the run reporting success. That write is
|
|
481
|
+
* refused loudly (`exit 3`, never a silent different-location write). `{flag:
|
|
482
|
+
* "wx"}` below stops the temp write from following or truncating through
|
|
483
|
+
* whatever already sits at `<target>.tmp`; it says nothing about the FINAL
|
|
484
|
+
* name, and `renameSync` replaces whatever is there unconditionally — which
|
|
485
|
+
* is exactly what makes an ordinary `--output` reusable across runs
|
|
486
|
+
* (`docs/usage/ci.md`'s own recipe reruns `--output boundaries.json` on every
|
|
487
|
+
* push, relying on the previous run's file being silently replaced). That
|
|
488
|
+
* reuse is fine for a report; it is never fine for a file THIS TOOL reads as
|
|
489
|
+
* the workspace's own declared fact — `archkeep check --output
|
|
490
|
+
* architecture-intent.json` (a copy-pasted flag, a typo'd path, or a CI
|
|
491
|
+
* script a pull request edited) would otherwise silently replace a tracked
|
|
492
|
+
* governance file with a report, exit 0, with the loss surfacing only later
|
|
493
|
+
* and elsewhere, the first time something else tries to read the file it used
|
|
494
|
+
* to be. Deliberately narrow: an arbitrary tracked file this tool never
|
|
495
|
+
* assigns a meaning to is not refused, because refusing every pre-existing
|
|
496
|
+
* target would break the documented reuse above.
|
|
497
|
+
*
|
|
498
|
+
* Written to a sibling `.tmp` file first, then renamed onto the target — a
|
|
499
|
+
* rename within one directory is atomic, so a reader of `outputPath` (this
|
|
500
|
+
* process crashing mid-write, or a second run racing this one) sees either
|
|
501
|
+
* the previous complete file or the new complete one, never a truncated or
|
|
502
|
+
* half-written report. No fsync: the guarantee this buys is "never a torn
|
|
503
|
+
* file", not "survives a power loss".
|
|
504
|
+
*
|
|
505
|
+
* The temp write uses `{flag: "wx"}` — `O_CREAT|O_EXCL`, which POSIX
|
|
506
|
+
* guarantees fails on a path that already exists WITHOUT following it if it
|
|
507
|
+
* is a symlink, dangling or not. Every value in `--output` and every value in
|
|
508
|
+
* a report body — a project name, a directory, an import specifier — comes
|
|
509
|
+
* from the tree being judged and is attacker-supplied the moment a pull
|
|
510
|
+
* request adds one (`../SECURITY.md`'s threat model). Before this guard, a
|
|
511
|
+
* tracked symlink at `<target>.tmp` pointing outside the workspace made this
|
|
512
|
+
* write follow it and overwrite whatever the symlink named, with
|
|
513
|
+
* attacker-chosen bytes, while reporting success — the documented CI recipe
|
|
514
|
+
* (`docs/usage/ci.md`) run against such a pull request was a runner-write
|
|
515
|
+
* primitive. The same flag closes a second hole for free: a stray `.tmp` left
|
|
516
|
+
* by a crashed prior run — or a file that happens to collide with the name —
|
|
517
|
+
* used to be silently truncated and then renamed away; now the write refuses
|
|
518
|
+
* with EEXIST, loud, rather than destroying a file this run did not create.
|
|
519
|
+
* `renameSync` itself needs no equivalent guard against a SYMLINK: POSIX
|
|
520
|
+
* `rename(2)` replaces whatever sits at the destination path — including a
|
|
521
|
+
* symlink there — as a single directory-entry swap, and never dereferences it
|
|
522
|
+
* to write through. It needs the governance guard above instead, because a
|
|
523
|
+
* symlink was never the only thing worth protecting at that destination.
|
|
524
|
+
*
|
|
525
|
+
* The containment decision and the actual write share ONE resolved path.
|
|
526
|
+
* `outputAbs = resolve(cwd, outputPath)` collapses `..` segments lexically, and
|
|
527
|
+
* the tmp write and `renameSync` act on `outputAbs` — the same string the
|
|
528
|
+
* governance and containment checks saw — never on the raw `outputPath`. This
|
|
529
|
+
* is what closes the `..`-across-a-symlink corner of the intermediate-component
|
|
530
|
+
* escape (`./src/containment.mjs`'s `containsDotDot` owns the mechanism): if
|
|
531
|
+
* the write used the raw spelling, a `sub -> /tmp/out` plus `sub/../report.json`
|
|
532
|
+
* would resolve the check to a clean path while the kernel, walking the raw
|
|
533
|
+
* string, followed `sub` out of the tree. A written path that has no `..` left
|
|
534
|
+
* in it cannot hide a symlink the kernel would walk later.
|
|
535
|
+
*
|
|
536
|
+
* @param {string} outputPath The flag's value as the user wrote it.
|
|
537
|
+
* @param {string} text The rendered report, newline-terminated by the caller.
|
|
538
|
+
* @param {{err: Function}} env
|
|
539
|
+
* @param {string} cwd This run's working directory, for resolving both
|
|
540
|
+
* `outputPath` and the governance names above the same way.
|
|
541
|
+
* @param {string|null|undefined} configOption This run's `options.config`.
|
|
542
|
+
* @param {string} [flagName] The flag being served, for the messages. Every
|
|
543
|
+
* caller but one is `--output`; `--evidence-out` writes through the same
|
|
544
|
+
* guards and has to name itself, because a refusal that blamed a flag the
|
|
545
|
+
* user did not type would send them to the wrong argument.
|
|
546
|
+
* @returns {boolean} `true` on success; `false` after reporting the failure
|
|
547
|
+
* to `env.err` — the caller's cue to return its own no-verdict exit code.
|
|
548
|
+
*/
|
|
549
|
+
function writeOutputReport(outputPath, text, env, cwd, configOption, flagName = "--output") {
|
|
550
|
+
// `resolve` normalises BOTH branches — an absolute `--output` keeps its `..`
|
|
551
|
+
// segments collapsed lexically, and a relative one resolves against this
|
|
552
|
+
// run's `cwd`, not the process's. The identical `outputAbs` then feeds the
|
|
553
|
+
// governance guard, the containment decision, AND the write.
|
|
554
|
+
const outputAbs = isAbsolute(outputPath) ? resolve(outputPath) : resolve(cwd, outputPath);
|
|
555
|
+
const guardedName = governanceOutputTargets(cwd, configOption).get(outputAbs);
|
|
556
|
+
if (guardedName !== undefined) {
|
|
557
|
+
env.err(
|
|
558
|
+
`archkeep: ${flagName} '${outputPath}' resolves to '${guardedName}' — this tool reads that ` +
|
|
559
|
+
`file as the workspace's own declared fact, and overwriting it with a report would ` +
|
|
560
|
+
`destroy the declaration instead. Write the report somewhere else.`,
|
|
561
|
+
);
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
const root = resolveWorkspaceRootForUsage(cwd);
|
|
565
|
+
if (root !== null) {
|
|
566
|
+
const violation = containmentViolation(root, outputAbs, { forWrite: true });
|
|
567
|
+
if (violation !== null) {
|
|
568
|
+
env.err(`archkeep: ${flagName} '${outputPath}' is refused: ${violation}`);
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const tmpOutput = `${outputAbs}.tmp`;
|
|
573
|
+
let tmpCreated = false;
|
|
574
|
+
try {
|
|
575
|
+
writeFileSync(tmpOutput, text, { flag: "wx" });
|
|
576
|
+
tmpCreated = true;
|
|
577
|
+
renameSync(tmpOutput, outputAbs);
|
|
578
|
+
return true;
|
|
579
|
+
} catch (cause) {
|
|
580
|
+
// Only clean up a `.tmp` THIS call created — an `EEXIST` from `wx` means
|
|
581
|
+
// nothing was written, so there is nothing here to remove, and the path
|
|
582
|
+
// may be attacker- or accident-owned rather than this run's own leftover.
|
|
583
|
+
if (tmpCreated) {
|
|
584
|
+
try {
|
|
585
|
+
unlinkSync(tmpOutput);
|
|
586
|
+
} catch {
|
|
587
|
+
// Nothing this run can do about it either way.
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
env.err(`archkeep: could not write ${flagName} '${outputPath}': ${cause?.message ?? cause}`);
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* `--evidence-out`: one file per declared custom rule, holding the exact
|
|
597
|
+
* document that rule was judged over.
|
|
598
|
+
*
|
|
599
|
+
* The sandbox that makes a custom rule deterministic is also what makes one
|
|
600
|
+
* hard to debug: a rule that answers `unknown` has no way to show its author
|
|
601
|
+
* what it read, and the author has no way to reproduce the run locally except
|
|
602
|
+
* by guessing the bundle. This flag is the answer — the bytes the host wrote
|
|
603
|
+
* into linear memory, on disk, ready to replay through the SDK harness that
|
|
604
|
+
* package ships.
|
|
605
|
+
*
|
|
606
|
+
* **Three ways this can write nothing, and each says so.** A directory left
|
|
607
|
+
* empty would be indistinguishable from a rule whose evidence was fine, which
|
|
608
|
+
* is the silent direction wearing a debugging flag's name
|
|
609
|
+
* (`../../AGENTS.md`). So: a policy that declares no custom rule, a run scoped
|
|
610
|
+
* to a path (whose evidence would describe a workspace that does not exist —
|
|
611
|
+
* `./src/commands/custom-rules.mjs` owns that refusal), and the ordinary case
|
|
612
|
+
* are three different lines on stderr, never one silent no-op.
|
|
613
|
+
*
|
|
614
|
+
* Writes go through `writeOutputReport`, so every guard `--output` earned
|
|
615
|
+
* applies unchanged: the governance targets a report may never overwrite, the
|
|
616
|
+
* containment check, and the `wx` temp write that refuses to follow a symlink.
|
|
617
|
+
* The directory must already exist, exactly as `--output`'s parent must —
|
|
618
|
+
* creating one would mean creating directories on a path the containment
|
|
619
|
+
* check may be about to refuse.
|
|
620
|
+
*
|
|
621
|
+
* A rule name cannot escape the directory: the policy loader holds names to
|
|
622
|
+
* dash-separated lowercase (`./src/config.mjs`), so no name carries a
|
|
623
|
+
* separator or a `..` to begin with.
|
|
624
|
+
*
|
|
625
|
+
* @param {string} dir The `--evidence-out` value as the user wrote it.
|
|
626
|
+
* @param {{customRuleEvidence: {rule: string, bytes: Uint8Array}[],
|
|
627
|
+
* customRulesDeclared: boolean}} result From `check`.
|
|
628
|
+
* @param {{err: Function}} env
|
|
629
|
+
* @param {string} cwd This run's working directory.
|
|
630
|
+
* @param {string|null} configOption This run's `options.config`.
|
|
631
|
+
* @param {boolean} scoped Whether `paths` narrowed this run.
|
|
632
|
+
* @returns {boolean} `true` when everything the run had was written.
|
|
633
|
+
*/
|
|
634
|
+
function writeEvidenceBundles(dir, result, env, cwd, configOption, scoped) {
|
|
635
|
+
if (!result.customRulesDeclared) {
|
|
636
|
+
env.err(
|
|
637
|
+
`archkeep: --evidence-out '${dir}' wrote nothing — this workspace's policy declares no ` +
|
|
638
|
+
`customRules, so there is no rule whose evidence to write.`,
|
|
639
|
+
);
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
if (scoped) {
|
|
643
|
+
env.err(
|
|
644
|
+
`archkeep: --evidence-out '${dir}' wrote nothing — a path-scoped run answers ` +
|
|
645
|
+
`not_applicable for every custom rule, because a rule's evidence is the whole tree and ` +
|
|
646
|
+
`this run read part of it. Run check over the whole workspace to write bundles.`,
|
|
647
|
+
);
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
const decoder = new TextDecoder();
|
|
651
|
+
for (const { rule, bytes } of result.customRuleEvidence) {
|
|
652
|
+
// Re-indented from the canonical bytes rather than written as they came:
|
|
653
|
+
// the canonical form is one line, and the document a human is about to
|
|
654
|
+
// read in a diff, a review, or an editor is the one worth writing. Key
|
|
655
|
+
// order is the canonical order, because `JSON.parse` preserves it.
|
|
656
|
+
const document = `${JSON.stringify(JSON.parse(decoder.decode(bytes)), null, 2)}\n`;
|
|
657
|
+
if (
|
|
658
|
+
!writeOutputReport(
|
|
659
|
+
join(dir, `${rule}.json`),
|
|
660
|
+
document,
|
|
661
|
+
env,
|
|
662
|
+
cwd,
|
|
663
|
+
configOption,
|
|
664
|
+
"--evidence-out",
|
|
665
|
+
)
|
|
666
|
+
) {
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
env.err(
|
|
671
|
+
`archkeep: ${result.customRuleEvidence.length} evidence bundle` +
|
|
672
|
+
`${result.customRuleEvidence.length === 1 ? "" : "s"} → ${dir}`,
|
|
673
|
+
);
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* `COMMANDS.check`'s `run`: drives `check`, writes the report where it
|
|
679
|
+
* belongs, and returns the process's exit code. Everything about argv parsing
|
|
680
|
+
* and where output goes lives here, not in `check` itself — `src/commands/README.md`'s
|
|
681
|
+
* rule applied to the one command that predates that rule.
|
|
682
|
+
*
|
|
683
|
+
* @param {{format: string, output: string|null, config: string|null, paths: string[],
|
|
684
|
+
* evidenceOut?: string|null}} options
|
|
685
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
686
|
+
* @returns {Promise<number>}
|
|
687
|
+
*/
|
|
688
|
+
async function runCheck(options, { cwd, env }) {
|
|
689
|
+
let result;
|
|
690
|
+
try {
|
|
691
|
+
result = await check(options, { cwd, readGraph: env.readGraph, listFiles: env.listFiles });
|
|
692
|
+
} catch (error) {
|
|
693
|
+
// A bad path argument — outside the tree, or matching no tracked file at
|
|
694
|
+
// all (a typo, the wrong cwd, or a file not yet `git add`ed) — is the
|
|
695
|
+
// user's mistake to retype; everything else is the run failing. The two
|
|
696
|
+
// get different codes because only one is worth retrying with different
|
|
697
|
+
// arguments.
|
|
698
|
+
const usageError = error instanceof UsageError;
|
|
699
|
+
env.err(String(error?.message ?? error));
|
|
700
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Before the report, so a run whose `--output` is refused still leaves the
|
|
704
|
+
// author the evidence they asked for — the two flags answer to different
|
|
705
|
+
// needs and neither should take the other down with it.
|
|
706
|
+
if (
|
|
707
|
+
options.evidenceOut &&
|
|
708
|
+
!writeEvidenceBundles(
|
|
709
|
+
options.evidenceOut,
|
|
710
|
+
result,
|
|
711
|
+
env,
|
|
712
|
+
cwd,
|
|
713
|
+
options.config,
|
|
714
|
+
options.paths.length > 0,
|
|
715
|
+
)
|
|
716
|
+
) {
|
|
717
|
+
return EXIT.error;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (options.output) {
|
|
721
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
722
|
+
// the mechanism and the threat it closes.
|
|
723
|
+
const reportText = result.report.endsWith("\n") ? result.report : `${result.report}\n`;
|
|
724
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
|
|
725
|
+
// The report went to a file, so the log would otherwise say nothing at all
|
|
726
|
+
// about a run that just failed the build.
|
|
727
|
+
env.err(
|
|
728
|
+
`archkeep: ${result.violations} violation${result.violations === 1 ? "" : "s"} ` +
|
|
729
|
+
`over ${result.analyzed} analyzed file${result.analyzed === 1 ? "" : "s"}` +
|
|
730
|
+
(result.waived > 0 ? ` (${result.waived} waived until their expiry)` : "") +
|
|
731
|
+
(result.declaredEdgeFindings > 0
|
|
732
|
+
? `, ${result.declaredEdgeFindings} declared-edge finding${result.declaredEdgeFindings === 1 ? "" : "s"}`
|
|
733
|
+
: "") +
|
|
734
|
+
(result.goWorkDrift > 0
|
|
735
|
+
? `, ${result.goWorkDrift} go.work drift finding${result.goWorkDrift === 1 ? "" : "s"}`
|
|
736
|
+
: "") +
|
|
737
|
+
(result.tsconfigPathsDead > 0
|
|
738
|
+
? `, ${result.tsconfigPathsDead} dead tsconfig path alias${result.tsconfigPathsDead === 1 ? "" : "es"}`
|
|
739
|
+
: "") +
|
|
740
|
+
(result.intentFindings > 0
|
|
741
|
+
? `, ${result.intentFindings} architecture-intent finding${result.intentFindings === 1 ? "" : "s"}`
|
|
742
|
+
: "") +
|
|
743
|
+
// Fitness drives the exit code exactly like every count above it
|
|
744
|
+
// (`verdictFor`) — omitting it here is what let a fitness-only
|
|
745
|
+
// failure log "0 violations …" beside a non-zero exit.
|
|
746
|
+
(result.fitnessFail > 0
|
|
747
|
+
? `, ${result.fitnessFail} fitness function${result.fitnessFail === 1 ? "" : "s"} failed`
|
|
748
|
+
: "") +
|
|
749
|
+
(result.fitnessUnknown > 0
|
|
750
|
+
? `, ${result.fitnessUnknown} fitness function${result.fitnessUnknown === 1 ? "" : "s"} undetermined`
|
|
751
|
+
: "") +
|
|
752
|
+
// Custom rules drive the exit code exactly like every count above
|
|
753
|
+
// them (`verdictFor`), so they are named here for the same reason
|
|
754
|
+
// fitness is: a custom-rule-only failure would otherwise log
|
|
755
|
+
// "0 violations …" beside a non-zero exit.
|
|
756
|
+
(result.customRuleFail > 0
|
|
757
|
+
? `, ${result.customRuleFail} custom rule${result.customRuleFail === 1 ? "" : "s"} failed`
|
|
758
|
+
: "") +
|
|
759
|
+
(result.customRuleUnknown > 0
|
|
760
|
+
? `, ${result.customRuleUnknown} custom rule${result.customRuleUnknown === 1 ? "" : "s"} undetermined`
|
|
761
|
+
: "") +
|
|
762
|
+
(result.unchecked > 0
|
|
763
|
+
? `, ${result.unchecked} file${result.unchecked === 1 ? "" : "s"} not analyzed`
|
|
764
|
+
: "") +
|
|
765
|
+
` → ${options.output}`,
|
|
766
|
+
);
|
|
767
|
+
} else {
|
|
768
|
+
env.out(result.report);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return verdictFor(result).exitCode;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* `graph`'s `run`: resolves the command context, drives `graphCommand`, writes
|
|
776
|
+
* the report where it belongs, and returns the process's exit code.
|
|
777
|
+
*
|
|
778
|
+
* @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
|
|
779
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
780
|
+
* @returns {Promise<number>}
|
|
781
|
+
*/
|
|
782
|
+
async function runGraph(options, { cwd, env }) {
|
|
783
|
+
if (options.paths.length > 0) {
|
|
784
|
+
env.err(`archkeep: graph takes no positional arguments; got ${options.paths.join(", ")}`);
|
|
785
|
+
return EXIT.usage;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
let result;
|
|
789
|
+
try {
|
|
790
|
+
const commandContext = resolveCommandContext(
|
|
791
|
+
{ cwd },
|
|
792
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
// Load the boundary config so the snapshot carries a policy fingerprint
|
|
796
|
+
// that `diff` can use to warn when the policy changed between runs. Without
|
|
797
|
+
// a config, the snapshot carries no policy identity — the consumer did not
|
|
798
|
+
// provide one (`./src/commands/graph.mjs` makes that field conditional). A
|
|
799
|
+
// profile-selected workspace's `boundaryConfig` names a profile rather than
|
|
800
|
+
// a file, resolved the same way `check` resolves it (`resolvePolicy`), so
|
|
801
|
+
// the fingerprint moves with a profile edit the same way it already does
|
|
802
|
+
// with a file or inline-object edit.
|
|
803
|
+
//
|
|
804
|
+
// `graph` describes the project graph, not the boundary law — it reads no
|
|
805
|
+
// constraint row and judges nothing against one — so a workspace that has
|
|
806
|
+
// not written a law yet must not be refused here. It was, with exit 3: the
|
|
807
|
+
// workspace-default `boundaryConfig` is never absent on the Nx and Moon
|
|
808
|
+
// paths (`readPluginOptions` falls back to `DEFAULT_OPTIONS`, and Moon
|
|
809
|
+
// takes the same default by convention), so that arm of `resolvePolicy`
|
|
810
|
+
// fired unconditionally and a missing file became the command's exit code.
|
|
811
|
+
// `discover`, the other descriptive verb over the same graph, answered
|
|
812
|
+
// fine on the identical tree — and `graph` is what a workspace runs to see
|
|
813
|
+
// what Archkeep found, which is what it needs in order to WRITE a first
|
|
814
|
+
// policy.
|
|
815
|
+
//
|
|
816
|
+
// What is skipped is the load of a file that is NOT THERE. A boundary
|
|
817
|
+
// config that exists and will not load still fails the run, because an
|
|
818
|
+
// absent law and a broken one must not report alike; a `--config`, a
|
|
819
|
+
// profile, and an inline `archkeep.json` policy are explicit declarations
|
|
820
|
+
// and stay loud. Every command that JUDGES against the law keeps loading
|
|
821
|
+
// it unconditionally — making it optional for those would turn a missing
|
|
822
|
+
// file into a silent no-law run.
|
|
823
|
+
//
|
|
824
|
+
// `boundaryConfigDeclared` is what keeps this guard to the un-overridden
|
|
825
|
+
// default, and it is load-bearing rather than belt-and-braces. The name
|
|
826
|
+
// alone cannot answer it: `commandContext.options.boundaryConfig` is a
|
|
827
|
+
// string BOTH when it came from `./src/options.mjs`'s `DEFAULT_OPTIONS`
|
|
828
|
+
// and when the consumer WROTE it into `nx.json`'s plugin options or
|
|
829
|
+
// `archkeep.json`, and a workspace is free to declare the convention
|
|
830
|
+
// filename itself, so comparing against the default would still read a
|
|
831
|
+
// deliberate declaration as an assumption. Without the bit, measured on a
|
|
832
|
+
// committed native tree whose `archkeep.json` declares `boundaryConfig:
|
|
833
|
+
// "policy-we-declared.mjs"` and does not contain that file: `graph` exited
|
|
834
|
+
// 0 with a snapshot carrying no `policy` field — byte-identical to a
|
|
835
|
+
// workspace that never had a law — where the same tree with that file
|
|
836
|
+
// present but unparseable exited 3. A law someone named and then renamed
|
|
837
|
+
// or deleted is exactly the case that must stay loud, so the provenance
|
|
838
|
+
// survives the options layer instead (`./src/options.mjs`'s
|
|
839
|
+
// `resolveOptions`, `./src/providers/native/model.mjs`'s
|
|
840
|
+
// `normalizeNativeModel`, and `./src/commands/context.mjs`'s three
|
|
841
|
+
// branches carry it; Moon answers `false` because it has no table to
|
|
842
|
+
// declare one in).
|
|
843
|
+
const workspaceDefault =
|
|
844
|
+
!options.config &&
|
|
845
|
+
!hasProfiles(commandContext.options) &&
|
|
846
|
+
commandContext.options.boundaryConfigDeclared === false &&
|
|
847
|
+
typeof commandContext.options.boundaryConfig === "string"
|
|
848
|
+
? resolve(commandContext.root, commandContext.options.boundaryConfig)
|
|
849
|
+
: null;
|
|
850
|
+
const { config } =
|
|
851
|
+
workspaceDefault !== null && !existsSync(workspaceDefault)
|
|
852
|
+
? { config: null }
|
|
853
|
+
: await resolvePolicy(options, commandContext, cwd);
|
|
854
|
+
|
|
855
|
+
result = graphCommand(commandContext, { config });
|
|
856
|
+
} catch (error) {
|
|
857
|
+
const usageError = error instanceof UsageError;
|
|
858
|
+
env.err(String(error?.message ?? error));
|
|
859
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
863
|
+
|
|
864
|
+
if (options.output) {
|
|
865
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
866
|
+
// the mechanism and the threat it closes.
|
|
867
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
868
|
+
// `graph` has no `--config` flag (`GRAPH_FLAG_HELP`) — there is no
|
|
869
|
+
// override to pass, only the workspace's un-overridden default to guard.
|
|
870
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, null)) return EXIT.error;
|
|
871
|
+
env.err(
|
|
872
|
+
`archkeep: ${result.projects.length} projects, ${result.dependencies.length} edges ` +
|
|
873
|
+
`→ ${options.output}`,
|
|
874
|
+
);
|
|
875
|
+
} else {
|
|
876
|
+
env.out(report);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Descriptive: 0 for answered, 3 for incomplete coverage.
|
|
880
|
+
return result.status === "ok" ? EXIT.ok : EXIT.error;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* `diff`'s `run`: resolves the command context, reads the baseline, drives
|
|
885
|
+
* `diffCommand`, writes the report, and returns the exit code.
|
|
886
|
+
*
|
|
887
|
+
* The baseline file is the single positional argument (a file, not a git ref).
|
|
888
|
+
*
|
|
889
|
+
* @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
|
|
890
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
891
|
+
* @returns {Promise<number>}
|
|
892
|
+
*/
|
|
893
|
+
async function runDiff(options, { cwd, env }) {
|
|
894
|
+
if (options.paths.length !== 1) {
|
|
895
|
+
env.err(
|
|
896
|
+
`archkeep: diff takes exactly one positional argument (the baseline file); ` +
|
|
897
|
+
`got ${options.paths.length}`,
|
|
898
|
+
);
|
|
899
|
+
return EXIT.usage;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const baselinePath = isAbsolute(options.paths[0])
|
|
903
|
+
? options.paths[0]
|
|
904
|
+
: resolve(cwd, options.paths[0]);
|
|
905
|
+
|
|
906
|
+
let result;
|
|
907
|
+
try {
|
|
908
|
+
const commandContext = resolveCommandContext(
|
|
909
|
+
{ cwd },
|
|
910
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
// Load the boundary config when --config is given or when the workspace
|
|
914
|
+
// declares one, so rule-impact analysis is computed. Without a config,
|
|
915
|
+
// the diff reports only structural changes — same as before. A
|
|
916
|
+
// profile-selected workspace resolves the same way `check` does
|
|
917
|
+
// (`resolvePolicy`), so a policy edit under an unchanged profile NAME is
|
|
918
|
+
// still visible as a fingerprint change here.
|
|
919
|
+
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
920
|
+
|
|
921
|
+
result = diffCommand(baselinePath, commandContext, { config });
|
|
922
|
+
} catch (error) {
|
|
923
|
+
const usageError = error instanceof UsageError;
|
|
924
|
+
env.err(String(error?.message ?? error));
|
|
925
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
929
|
+
|
|
930
|
+
if (options.output) {
|
|
931
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
932
|
+
// the mechanism and the threat it closes.
|
|
933
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
934
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
|
|
935
|
+
env.err(`archkeep: diff complete → ${options.output}`);
|
|
936
|
+
} else {
|
|
937
|
+
env.out(report);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Diff is descriptive: 0 when it completes, never 1.
|
|
941
|
+
return EXIT.ok;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* `drift`'s `run`: resolves the command context, drives `driftCommand`, writes
|
|
946
|
+
* the report where it belongs, and returns the process's exit code.
|
|
947
|
+
*
|
|
948
|
+
* `drift` takes no positional arguments — the observed side is the whole
|
|
949
|
+
* graph, and the intended side is the tracked root `architecture-intent.json`.
|
|
950
|
+
*
|
|
951
|
+
* @param {{format: string, output: string|null, paths: string[]}} options
|
|
952
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
953
|
+
* @returns {Promise<number>}
|
|
954
|
+
*/
|
|
955
|
+
async function runDrift(options, { cwd, env }) {
|
|
956
|
+
if (options.paths.length > 0) {
|
|
957
|
+
env.err(`archkeep: drift takes no positional arguments; got ${options.paths.join(", ")}`);
|
|
958
|
+
return EXIT.usage;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
let result;
|
|
962
|
+
try {
|
|
963
|
+
const commandContext = resolveCommandContext(
|
|
964
|
+
{ cwd },
|
|
965
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
966
|
+
);
|
|
967
|
+
// The loaded policy — profile-aware the same way `check` is
|
|
968
|
+
// (`resolvePolicy`), `null` when the workspace declares none. Drift reads
|
|
969
|
+
// the intent's rows, and the fitness half of a row's `decisionRef`
|
|
970
|
+
// resolves against the ids THIS policy declares (F04), so the same policy
|
|
971
|
+
// that made the boundary law answerable to the model must answer here.
|
|
972
|
+
// `drift` has no `--config` (`DRIFT_FLAG_HELP`), so `config` is always the
|
|
973
|
+
// workspace's own default — resolvePolicy reads `options.config` as the
|
|
974
|
+
// override, hence `null` here, which selects the workspace's configured
|
|
975
|
+
// boundary law (or a profile, when one is registered).
|
|
976
|
+
//
|
|
977
|
+
// The failure is DEFERRED rather than thrown here. `drift`'s only reader of
|
|
978
|
+
// this policy is the non-verdict decisionRef axis, and only for rows that
|
|
979
|
+
// carry one, so a workspace with an intent and no boundary config was
|
|
980
|
+
// exiting 3 over a law drift would never have opened — a fifth refusal
|
|
981
|
+
// neither `docs/usage/drift.md` nor `reconcile`, which makes the same four,
|
|
982
|
+
// ever had. `driftCommand` rethrows it, unchanged, at the one site that
|
|
983
|
+
// reads the policy, so every workspace whose intent cites anything keeps the
|
|
984
|
+
// exact exit-3 it had.
|
|
985
|
+
let config = null;
|
|
986
|
+
let configError = null;
|
|
987
|
+
try {
|
|
988
|
+
({ config } = await resolvePolicy({ ...options, config: null }, commandContext, cwd));
|
|
989
|
+
} catch (error) {
|
|
990
|
+
configError = /** @type {Error} */ (error);
|
|
991
|
+
}
|
|
992
|
+
result = await driftCommand(commandContext, { config, configError });
|
|
993
|
+
} catch (error) {
|
|
994
|
+
const usageError = error instanceof UsageError;
|
|
995
|
+
env.err(String(error?.message ?? error));
|
|
996
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1000
|
+
|
|
1001
|
+
if (options.output) {
|
|
1002
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1003
|
+
// the mechanism and the threat it closes.
|
|
1004
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1005
|
+
// `drift` has no `--config` flag (`DRIFT_FLAG_HELP`) — there is no
|
|
1006
|
+
// override to pass, only the workspace's un-overridden default to guard.
|
|
1007
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, null)) return EXIT.error;
|
|
1008
|
+
env.err(
|
|
1009
|
+
`archkeep: ${result.drift.observed.projects} projects, ${result.drift.observed.edges} edges ` +
|
|
1010
|
+
`→ ${options.output}`,
|
|
1011
|
+
);
|
|
1012
|
+
} else {
|
|
1013
|
+
env.out(report);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Drift is descriptive: 0 when the comparison completes, never 1.
|
|
1017
|
+
return EXIT.ok;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* `provenance`'s `run`: resolves the command context, drives
|
|
1022
|
+
* `provenanceCommand`, writes the report where it belongs, and returns the
|
|
1023
|
+
* process's exit code.
|
|
1024
|
+
*
|
|
1025
|
+
* Provenance reads no graph and judges nothing — it describes where the run's
|
|
1026
|
+
* facts came from and which governance rows carry an origin. It is
|
|
1027
|
+
* fail-closed the way every descriptive command is: a malformed intent or
|
|
1028
|
+
* boundary config throws out of `provenanceCommand` → exit 3, so "rows
|
|
1029
|
+
* unlisted" never reads as "rows attested".
|
|
1030
|
+
*
|
|
1031
|
+
* @param {{format: string, output: string|null, paths: string[]}} options
|
|
1032
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
1033
|
+
* @returns {Promise<number>}
|
|
1034
|
+
*/
|
|
1035
|
+
async function runProvenance(options, { cwd, env }) {
|
|
1036
|
+
if (options.paths.length > 0) {
|
|
1037
|
+
env.err(`archkeep: provenance takes no positional arguments; got ${options.paths.join(", ")}`);
|
|
1038
|
+
return EXIT.usage;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
let result;
|
|
1042
|
+
try {
|
|
1043
|
+
const commandContext = resolveCommandContext(
|
|
1044
|
+
{ cwd },
|
|
1045
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
1046
|
+
);
|
|
1047
|
+
result = await provenanceCommand(commandContext);
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
const usageError = error instanceof UsageError;
|
|
1050
|
+
env.err(String(error?.message ?? error));
|
|
1051
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1055
|
+
|
|
1056
|
+
if (options.output) {
|
|
1057
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1058
|
+
// the mechanism and the threat it closes.
|
|
1059
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1060
|
+
// `provenance` has no `--config` flag (`PROVENANCE_FLAG_HELP`) — there is
|
|
1061
|
+
// no override to pass, only the workspace's un-overridden default to guard.
|
|
1062
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, null)) return EXIT.error;
|
|
1063
|
+
env.err(`archkeep: ${result.rows.length} governance rows → ${options.output}`);
|
|
1064
|
+
} else {
|
|
1065
|
+
env.out(report);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Provenance is descriptive — it never changes a verdict, so it never exits
|
|
1069
|
+
// 1. 0 when it completes; failures exit 3 up in the catch above.
|
|
1070
|
+
return EXIT.ok;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* `reconcile`'s `run`: resolves the command context, drives
|
|
1075
|
+
* `reconcileCommand`, writes the report where it belongs, and returns the
|
|
1076
|
+
* process's exit code.
|
|
1077
|
+
*
|
|
1078
|
+
* `--propose` is a boolean flag (the `--capture` pattern): it adds the ranked
|
|
1079
|
+
* candidate list to the report and result. Reconcile takes no positional
|
|
1080
|
+
* arguments, it never writes into architecture-intent.json, and it is
|
|
1081
|
+
* descriptive — 0 when the comparison completes, never 1.
|
|
1082
|
+
*
|
|
1083
|
+
* @param {{format: string, output: string|null, propose: boolean, paths: string[]}} options
|
|
1084
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
1085
|
+
* @returns {Promise<number>}
|
|
1086
|
+
*/
|
|
1087
|
+
async function runReconcile(options, { cwd, env }) {
|
|
1088
|
+
if (options.paths.length > 0) {
|
|
1089
|
+
env.err(`archkeep: reconcile takes no positional arguments; got ${options.paths.join(", ")}`);
|
|
1090
|
+
return EXIT.usage;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
let result;
|
|
1094
|
+
try {
|
|
1095
|
+
const commandContext = resolveCommandContext(
|
|
1096
|
+
{ cwd },
|
|
1097
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
1098
|
+
);
|
|
1099
|
+
result = await reconcileCommand(commandContext, {}, { propose: options.propose ?? false });
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
const usageError = error instanceof UsageError;
|
|
1102
|
+
env.err(String(error?.message ?? error));
|
|
1103
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1107
|
+
|
|
1108
|
+
if (options.output) {
|
|
1109
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1110
|
+
// the mechanism and the threat it closes.
|
|
1111
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1112
|
+
// `reconcile` has no `--config` flag (`RECONCILE_FLAG_HELP`) — there is no
|
|
1113
|
+
// override to pass, only the workspace's un-overridden default to guard.
|
|
1114
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, null)) return EXIT.error;
|
|
1115
|
+
env.err(
|
|
1116
|
+
`archkeep: ${result.reconcile.observed.projects} projects, ${result.reconcile.observed.edges} edges ` +
|
|
1117
|
+
`→ ${options.output}`,
|
|
1118
|
+
);
|
|
1119
|
+
} else {
|
|
1120
|
+
env.out(report);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Reconcile is descriptive: 0 when the comparison completes, never 1.
|
|
1124
|
+
return EXIT.ok;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* `waivers`' `run`: resolves the command context, drives `waiversCommand`,
|
|
1129
|
+
* writes the report where it belongs, and returns the process's exit code.
|
|
1130
|
+
*
|
|
1131
|
+
* A read-only command, modeled on `runDrift`: no positional arguments, the
|
|
1132
|
+
* same `text|json` formats, and exit 0 whenever the surface could be read —
|
|
1133
|
+
* it is descriptive, never the boundary-violation exit code that is `check`'s
|
|
1134
|
+
* alone.
|
|
1135
|
+
*
|
|
1136
|
+
* @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
|
|
1137
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
1138
|
+
* @returns {Promise<number>}
|
|
1139
|
+
*/
|
|
1140
|
+
async function runWaivers(options, { cwd, env }) {
|
|
1141
|
+
if (options.paths.length > 0) {
|
|
1142
|
+
env.err(`archkeep: waivers takes no positional arguments; got ${options.paths.join(", ")}`);
|
|
1143
|
+
return EXIT.usage;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
let result;
|
|
1147
|
+
try {
|
|
1148
|
+
const commandContext = resolveCommandContext(
|
|
1149
|
+
{ cwd },
|
|
1150
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
1151
|
+
);
|
|
1152
|
+
|
|
1153
|
+
// The waivers surface is part of the run's boundary law, so the law is
|
|
1154
|
+
// loaded the same way `check` loads it (`resolvePolicy`) and `--config`
|
|
1155
|
+
// wins the same way — resolved against the working directory, never
|
|
1156
|
+
// against this tool's own location, and a `profiles` registry resolves
|
|
1157
|
+
// `--config`/`boundaryConfig` as a profile NAME the same way `check`
|
|
1158
|
+
// does. A malformed law throws here, exit 3, exactly as in `check`.
|
|
1159
|
+
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
1160
|
+
|
|
1161
|
+
result = await waiversCommand(commandContext, config);
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
const usageError = error instanceof UsageError;
|
|
1164
|
+
env.err(String(error?.message ?? error));
|
|
1165
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1169
|
+
|
|
1170
|
+
if (options.output) {
|
|
1171
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1172
|
+
// the mechanism and the threat it closes.
|
|
1173
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1174
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
|
|
1175
|
+
// This clause only, not the JSON envelope: a workspace with no permanent
|
|
1176
|
+
// suppressions sees this stderr line unchanged from before. A reader who
|
|
1177
|
+
// only glances at this confirmation must not see "N waivers on the
|
|
1178
|
+
// table" and conclude that is the whole surface when a
|
|
1179
|
+
// `boundarySuppressions` row with no `expiresAt` is hiding something the
|
|
1180
|
+
// file --output just wrote down.
|
|
1181
|
+
const suppressionNote =
|
|
1182
|
+
result.waivers.suppressions.length > 0
|
|
1183
|
+
? `, ${result.waivers.suppressions.length} permanent suppression` +
|
|
1184
|
+
`${result.waivers.suppressions.length === 1 ? "" : "s"}`
|
|
1185
|
+
: "";
|
|
1186
|
+
env.err(
|
|
1187
|
+
`archkeep: ${result.waivers.waivers.length} waivers${suppressionNote} on the table ` +
|
|
1188
|
+
`→ ${options.output}`,
|
|
1189
|
+
);
|
|
1190
|
+
} else {
|
|
1191
|
+
env.out(report);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Waivers is descriptive: 0 when the surface could be read, never 1.
|
|
1195
|
+
return EXIT.ok;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* `fitness`'s `run`: resolves the command context, drives `fitnessCommand`,
|
|
1200
|
+
* writes the report where it belongs, and returns the process's exit code.
|
|
1201
|
+
*
|
|
1202
|
+
* Unlike `drift`, fitness is a VERDICT: a `fail` exits 1, an `unknown` exits 3,
|
|
1203
|
+
* and a run where everything passed (or did not apply) exits 0. The mapping
|
|
1204
|
+
* sits at the tail of this function, and `../src/commands/fitness.mjs` states
|
|
1205
|
+
* the posture — a failing fitness function is a finding, not a print job
|
|
1206
|
+
* (D-09). `check` folds the same `fail` into its own exit 1 by presence, so the
|
|
1207
|
+
* two faces agree; `check` and `fitness` are the only verbs whose verdict
|
|
1208
|
+
* carries that code.
|
|
1209
|
+
*
|
|
1210
|
+
* @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
|
|
1211
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
1212
|
+
* @returns {Promise<number>}
|
|
1213
|
+
*/
|
|
1214
|
+
async function runFitness(options, { cwd, env }) {
|
|
1215
|
+
if (options.paths.length > 0) {
|
|
1216
|
+
env.err(`archkeep: fitness takes no positional arguments; got ${options.paths.join(", ")}`);
|
|
1217
|
+
return EXIT.usage;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
let result;
|
|
1221
|
+
try {
|
|
1222
|
+
const commandContext = resolveCommandContext(
|
|
1223
|
+
{ cwd },
|
|
1224
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
1225
|
+
);
|
|
1226
|
+
|
|
1227
|
+
// Fitness is part of the run's boundary law, so the law is loaded the same
|
|
1228
|
+
// way `check` loads it (`resolvePolicy`) and `--config` wins the same
|
|
1229
|
+
// way — resolved against the working directory, never against this
|
|
1230
|
+
// tool's own location, profile-aware the same way `check` is. A malformed
|
|
1231
|
+
// law throws here, exit 3, exactly as in `check`. A profile's `block` may
|
|
1232
|
+
// carry a `fitness` key (`docs/concepts/profiles.md` now names four
|
|
1233
|
+
// block keys, fitness among them), so a profile-selected workspace folds
|
|
1234
|
+
// the declared functions the same way a file-selected one does — a
|
|
1235
|
+
// profile that declares none reaches `fitnessCommand`'s own "declares no
|
|
1236
|
+
// fitness functions" refusal below rather than a config-loading failure.
|
|
1237
|
+
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
1238
|
+
|
|
1239
|
+
result = await fitnessCommand(commandContext, { config });
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
const usageError = error instanceof UsageError;
|
|
1242
|
+
env.err(String(error?.message ?? error));
|
|
1243
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1247
|
+
|
|
1248
|
+
if (options.output) {
|
|
1249
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1250
|
+
// the mechanism and the threat it closes.
|
|
1251
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1252
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
|
|
1253
|
+
env.err(
|
|
1254
|
+
`archkeep: ${result.fitness.functions.length} fitness function` +
|
|
1255
|
+
`${result.fitness.functions.length === 1 ? "" : "s"} judged (${result.fitness.verdict}) ` +
|
|
1256
|
+
`→ ${options.output}`,
|
|
1257
|
+
);
|
|
1258
|
+
} else {
|
|
1259
|
+
env.out(report);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// `fitness` is a verdict, not a print job (D-09): `fail` exits 1, `unknown`
|
|
1263
|
+
// exits 3, and a run that completed with everything `pass` (or not
|
|
1264
|
+
// applicable) exits 0. The command's own status carries the pair, and the
|
|
1265
|
+
// JSON envelope asserts it; this mapping is the one process-level exit.
|
|
1266
|
+
return (
|
|
1267
|
+
{ ok: EXIT.ok, findings: EXIT.violations, "no-verdict": EXIT.error }[result.status] ??
|
|
1268
|
+
EXIT.error
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* `impact`'s `run`: resolves the command context, drives `impactCommand`,
|
|
1274
|
+
* writes the report where it belongs, and returns the process's exit code.
|
|
1275
|
+
*
|
|
1276
|
+
* The project name is the single positional argument.
|
|
1277
|
+
*
|
|
1278
|
+
* @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
|
|
1279
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
1280
|
+
* @returns {Promise<number>}
|
|
1281
|
+
*/
|
|
1282
|
+
async function runImpact(options, { cwd, env }) {
|
|
1283
|
+
if (options.paths.length !== 1) {
|
|
1284
|
+
env.err(
|
|
1285
|
+
`archkeep: impact takes exactly one positional argument (the project name); ` +
|
|
1286
|
+
`got ${options.paths.length}`,
|
|
1287
|
+
);
|
|
1288
|
+
return EXIT.usage;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const projectName = options.paths[0];
|
|
1292
|
+
|
|
1293
|
+
let result;
|
|
1294
|
+
try {
|
|
1295
|
+
const commandContext = resolveCommandContext(
|
|
1296
|
+
{ cwd },
|
|
1297
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
1298
|
+
);
|
|
1299
|
+
|
|
1300
|
+
// Load the boundary config when --config is given or when the workspace
|
|
1301
|
+
// declares one, so constraint-impact analysis is computed — profile-aware
|
|
1302
|
+
// the same way `check` is (`resolvePolicy`).
|
|
1303
|
+
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
1304
|
+
|
|
1305
|
+
result = impactCommand(projectName, commandContext, config);
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
const usageError = error instanceof UsageError;
|
|
1308
|
+
env.err(String(error?.message ?? error));
|
|
1309
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1313
|
+
|
|
1314
|
+
if (options.output) {
|
|
1315
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1316
|
+
// the mechanism and the threat it closes.
|
|
1317
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1318
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
|
|
1319
|
+
env.err(
|
|
1320
|
+
`archkeep: ${result.impact.dependents.length} project` +
|
|
1321
|
+
`${result.impact.dependents.length === 1 ? "" : "s"}` +
|
|
1322
|
+
`${result.impact.dependents.length === 1 ? " depends" : " depend"} on ${projectName} ` +
|
|
1323
|
+
`→ ${options.output}`,
|
|
1324
|
+
);
|
|
1325
|
+
} else {
|
|
1326
|
+
env.out(report);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Impact is descriptive: 0 when it completes, never 1.
|
|
1330
|
+
return EXIT.ok;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* `explain`'s `run`: resolves the command context, loads the boundary config,
|
|
1335
|
+
* drives `explainCommand`, writes the report, and returns the exit code.
|
|
1336
|
+
*
|
|
1337
|
+
* The site argument is the single positional argument (a `file:line:column`
|
|
1338
|
+
* string). `--config` is accepted, same as `check`, because the judgment
|
|
1339
|
+
* depends on which boundary law is in effect.
|
|
1340
|
+
*
|
|
1341
|
+
* @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
|
|
1342
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
1343
|
+
* @returns {Promise<number>}
|
|
1344
|
+
*/
|
|
1345
|
+
async function runExplain(options, { cwd, env }) {
|
|
1346
|
+
if (options.paths.length !== 1) {
|
|
1347
|
+
env.err(
|
|
1348
|
+
`archkeep: explain takes exactly one positional argument (the site <file:line:column>); ` +
|
|
1349
|
+
`got ${options.paths.length}`,
|
|
1350
|
+
);
|
|
1351
|
+
return EXIT.usage;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
const site = options.paths[0];
|
|
1355
|
+
|
|
1356
|
+
let result;
|
|
1357
|
+
try {
|
|
1358
|
+
const commandContext = resolveCommandContext(
|
|
1359
|
+
{ cwd },
|
|
1360
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
1361
|
+
);
|
|
1362
|
+
|
|
1363
|
+
// The config's location is a separate fact from the workspace root.
|
|
1364
|
+
// Same loading logic as `check` (`resolvePolicy`) — a `--config`
|
|
1365
|
+
// overrides the workspace's own `boundaryConfig`, profile-aware the same
|
|
1366
|
+
// way `check` is.
|
|
1367
|
+
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
1368
|
+
|
|
1369
|
+
result = explainCommand(site, commandContext, config);
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
const usageError = error instanceof UsageError;
|
|
1372
|
+
env.err(String(error?.message ?? error));
|
|
1373
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1377
|
+
|
|
1378
|
+
if (options.output) {
|
|
1379
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1380
|
+
// the mechanism and the threat it closes.
|
|
1381
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1382
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
|
|
1383
|
+
env.err(`archkeep: explain complete → ${options.output}`);
|
|
1384
|
+
} else {
|
|
1385
|
+
env.out(report);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// Descriptive: 0 for answered, 3 for incomplete coverage.
|
|
1389
|
+
return result.status === "ok" ? EXIT.ok : EXIT.error;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* `context`'s `run`: resolves the command context, loads the boundary config,
|
|
1394
|
+
* drives `contextCommand`, writes the report, and returns the exit code.
|
|
1395
|
+
*
|
|
1396
|
+
* The project name is the single positional argument. `--config` is accepted,
|
|
1397
|
+
* same as `check` and `explain`, because the answer depends on which boundary
|
|
1398
|
+
* law is in effect.
|
|
1399
|
+
*
|
|
1400
|
+
* @param {{format: string, output: string|null, config: string|null, plan: boolean, paths: string[]}} options
|
|
1401
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
1402
|
+
* @returns {Promise<number>}
|
|
1403
|
+
*/
|
|
1404
|
+
async function runContextCommand(options, { cwd, env }) {
|
|
1405
|
+
// Without `--plan`, context takes exactly one positional (the project name);
|
|
1406
|
+
// with `--plan`, the first positional is still the project name and the rest
|
|
1407
|
+
// are the change's scope (paths the change touches).
|
|
1408
|
+
if (options.paths.length === 0) {
|
|
1409
|
+
env.err(`archkeep: context takes a project name; got none`);
|
|
1410
|
+
return EXIT.usage;
|
|
1411
|
+
}
|
|
1412
|
+
if (options.paths.length !== 1 && !options.plan) {
|
|
1413
|
+
env.err(
|
|
1414
|
+
`archkeep: context takes exactly one positional argument (the project name); ` +
|
|
1415
|
+
`got ${options.paths.length}`,
|
|
1416
|
+
);
|
|
1417
|
+
return EXIT.usage;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const projectName = options.paths[0];
|
|
1421
|
+
const scopePaths = options.plan ? options.paths.slice(1) : [];
|
|
1422
|
+
|
|
1423
|
+
let result;
|
|
1424
|
+
try {
|
|
1425
|
+
// The command context is resolved over the WHOLE workspace. Scoping by
|
|
1426
|
+
// path is the plan command's decision (which projects the change touches),
|
|
1427
|
+
// not the preamble's: the rule verdict and the architecture snapshot must
|
|
1428
|
+
// be over the whole tree, and only reporting is narrowed. Passing no paths
|
|
1429
|
+
// here keeps the non-plan `context` path byte-for-byte identical to before.
|
|
1430
|
+
const commandContext = resolveCommandContext(
|
|
1431
|
+
{ cwd },
|
|
1432
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
1433
|
+
);
|
|
1434
|
+
|
|
1435
|
+
// The config's location is a separate fact from the workspace root.
|
|
1436
|
+
// Same loading logic as `check` and `explain` (`resolvePolicy`) — a
|
|
1437
|
+
// `--config` overrides the workspace's own `boundaryConfig`,
|
|
1438
|
+
// profile-aware the same way `check` is.
|
|
1439
|
+
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
1440
|
+
|
|
1441
|
+
result = options.plan
|
|
1442
|
+
? await planContextCommand(projectName, scopePaths, commandContext, config)
|
|
1443
|
+
: contextCommand(projectName, commandContext, config);
|
|
1444
|
+
} catch (error) {
|
|
1445
|
+
const usageError = error instanceof UsageError;
|
|
1446
|
+
env.err(String(error?.message ?? error));
|
|
1447
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1451
|
+
|
|
1452
|
+
if (options.output) {
|
|
1453
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1454
|
+
// the mechanism and the threat it closes.
|
|
1455
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1456
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
|
|
1457
|
+
env.err(`archkeep: context complete → ${options.output}`);
|
|
1458
|
+
} else {
|
|
1459
|
+
env.out(report);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// Descriptive: 0 for answered, 3 for incomplete coverage.
|
|
1463
|
+
return result.status === "ok" ? EXIT.ok : EXIT.error;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* `adr`'s `run`: reads the ADR registry at the workspace root and renders it.
|
|
1468
|
+
*
|
|
1469
|
+
* The registry lives in `docs/adr/` in the tree being described, not in this
|
|
1470
|
+
* package's own tree, so the root comes from the current working directory —
|
|
1471
|
+
* the same walking `resolveCommandContext` does, but without the whole
|
|
1472
|
+
* project-graph preamble. `adr` never exits 1: a description of what is
|
|
1473
|
+
* recorded is never a finding. An unreadable registry (a malformed record, an
|
|
1474
|
+
* unreadable file, a bad filename) throws → exit 3; an id the user asked
|
|
1475
|
+
* about that the registry does not know → exit 3, the invariant.
|
|
1476
|
+
*
|
|
1477
|
+
* The tracked-file list is read the same way every other command reads it —
|
|
1478
|
+
* `env.listFiles ?? listTrackedFiles`, so a test can inject a fake the same
|
|
1479
|
+
* way `runCheck` and its siblings do — and handed to `adrCommand` so the
|
|
1480
|
+
* registry resolves only git-tracked records (`src/governance/adr-registry.mjs`'s
|
|
1481
|
+
* header). A `git ls-files` failure here throws the same as any other
|
|
1482
|
+
* unreadable registry, mapped to exit 3 below.
|
|
1483
|
+
*
|
|
1484
|
+
* @param {{format: string, output: string|null, paths: string[]}} options
|
|
1485
|
+
* @param {{cwd: string, env: {out: Function, err: Function, listFiles?: typeof listTrackedFiles}}} runContext
|
|
1486
|
+
* @returns {Promise<number>}
|
|
1487
|
+
*/
|
|
1488
|
+
async function runAdr(options, { cwd, env }) {
|
|
1489
|
+
if (options.paths.length > 1) {
|
|
1490
|
+
env.err(
|
|
1491
|
+
`archkeep: adr takes at most one positional argument (an ADR id); ` +
|
|
1492
|
+
`got ${options.paths.join(", ")}`,
|
|
1493
|
+
);
|
|
1494
|
+
return EXIT.usage;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
const root = resolveWorkspaceRootForUsage(cwd);
|
|
1498
|
+
if (root === null) {
|
|
1499
|
+
env.err(
|
|
1500
|
+
`archkeep: adr needs a workspace root — no nx.json, archkeep.json, or .moon marker found ` +
|
|
1501
|
+
`walking up from ${cwd}`,
|
|
1502
|
+
);
|
|
1503
|
+
return EXIT.error;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
let result;
|
|
1507
|
+
try {
|
|
1508
|
+
const tracked = (env.listFiles ?? listTrackedFiles)(root);
|
|
1509
|
+
result = adrCommand(root, { id: options.paths[0] }, { tracked });
|
|
1510
|
+
} catch (error) {
|
|
1511
|
+
env.err(String(error?.message ?? error));
|
|
1512
|
+
return EXIT.error;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1516
|
+
|
|
1517
|
+
if (options.output) {
|
|
1518
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1519
|
+
// the mechanism and the threat it closes.
|
|
1520
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1521
|
+
// `adr` has no `--config` flag (`ADR_FLAG_HELP`) — there is no override to
|
|
1522
|
+
// pass, only the workspace's un-overridden default to guard.
|
|
1523
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, null)) return EXIT.error;
|
|
1524
|
+
env.err(`archkeep: adr complete → ${options.output}`);
|
|
1525
|
+
} else {
|
|
1526
|
+
env.out(report);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// Descriptive: 0 for answered, 3 for incomplete coverage.
|
|
1530
|
+
return result.status === "ok" ? EXIT.ok : EXIT.error;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* `history`'s `run`: resolves the command context, optionally captures a
|
|
1535
|
+
* snapshot of the current workspace, drives `historyCommand`, writes the
|
|
1536
|
+
* report, and returns the exit code.
|
|
1537
|
+
*
|
|
1538
|
+
* The history directory is the single positional argument.
|
|
1539
|
+
*
|
|
1540
|
+
* @param {{format: string, output: string|null, capture: boolean, config: string|null, paths: string[]}} options
|
|
1541
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
1542
|
+
* @returns {Promise<number>}
|
|
1543
|
+
*/
|
|
1544
|
+
async function runHistory(options, { cwd, env }) {
|
|
1545
|
+
if (options.paths.length !== 1) {
|
|
1546
|
+
env.err(
|
|
1547
|
+
`archkeep: history takes exactly one positional argument (the history directory); ` +
|
|
1548
|
+
`got ${options.paths.length}`,
|
|
1549
|
+
);
|
|
1550
|
+
return EXIT.usage;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
const dir = isAbsolute(options.paths[0])
|
|
1554
|
+
? resolve(options.paths[0])
|
|
1555
|
+
: resolve(cwd, options.paths[0]);
|
|
1556
|
+
|
|
1557
|
+
// A self-footgun guard: writing the history report back into the very
|
|
1558
|
+
// directory `history` reads would poison every later run (the report envelope
|
|
1559
|
+
// is a `history` envelope, which `parseBaseline` refuses as a non-`graph`
|
|
1560
|
+
// snapshot). Refuse loudly instead of eventually failing on a poisoned dir.
|
|
1561
|
+
if (options.output) {
|
|
1562
|
+
// `resolve()` on the absolute branch too — not just the raw path — the
|
|
1563
|
+
// same normalization `writeOutputReport` applies, so an absolute
|
|
1564
|
+
// `--output` carrying a `..` segment that resolves INTO the history
|
|
1565
|
+
// directory cannot slip past this guard unnormalized.
|
|
1566
|
+
const outputAbs = isAbsolute(options.output)
|
|
1567
|
+
? resolve(options.output)
|
|
1568
|
+
: resolve(cwd, options.output);
|
|
1569
|
+
if (dirname(outputAbs) === dir) {
|
|
1570
|
+
env.err(
|
|
1571
|
+
`archkeep: --output '${options.output}' is inside the history directory '${dir}' — ` +
|
|
1572
|
+
`writing the report there would be read back as a snapshot on the next run. ` +
|
|
1573
|
+
`Write it somewhere else.`,
|
|
1574
|
+
);
|
|
1575
|
+
return EXIT.usage;
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
let result;
|
|
1580
|
+
try {
|
|
1581
|
+
const commandContext = resolveCommandContext(
|
|
1582
|
+
{ cwd },
|
|
1583
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
1584
|
+
);
|
|
1585
|
+
|
|
1586
|
+
// The boundary law's fingerprint when the workspace declares one, so a
|
|
1587
|
+
// captured snapshot records the policy it was taken under — the same
|
|
1588
|
+
// config loading `graph` uses (`resolvePolicy`, profile-aware the same
|
|
1589
|
+
// way `check` is), kept in one place so a capture and a standalone
|
|
1590
|
+
// `graph` never disagree about the current policy.
|
|
1591
|
+
let fingerprint = null;
|
|
1592
|
+
if (options.capture) {
|
|
1593
|
+
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
1594
|
+
if (config) {
|
|
1595
|
+
fingerprint = computePolicyFingerprint(config);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
result = historyCommand(dir, commandContext, {
|
|
1600
|
+
capture: options.capture,
|
|
1601
|
+
policyFingerprint: fingerprint,
|
|
1602
|
+
});
|
|
1603
|
+
} catch (error) {
|
|
1604
|
+
const usageError = error instanceof UsageError;
|
|
1605
|
+
env.err(String(error?.message ?? error));
|
|
1606
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1610
|
+
|
|
1611
|
+
if (options.output) {
|
|
1612
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1613
|
+
// the mechanism and the threat it closes.
|
|
1614
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1615
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
|
|
1616
|
+
env.err(`archkeep: history complete → ${options.output}`);
|
|
1617
|
+
} else {
|
|
1618
|
+
env.out(report);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// History is descriptive: 0 when it completes, never 1.
|
|
1622
|
+
return EXIT.ok;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
/**
|
|
1626
|
+
* `debt`'s `run`: resolves the command context, drives `debtCommand`, writes
|
|
1627
|
+
* the ledger report, and returns the exit code.
|
|
1628
|
+
*
|
|
1629
|
+
* The history directory is the single positional argument — the same
|
|
1630
|
+
* consumer-managed directory `history` reads, so a ledger ages across the
|
|
1631
|
+
* same snapshots the evolution record is built from.
|
|
1632
|
+
*
|
|
1633
|
+
* @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
|
|
1634
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
1635
|
+
* @returns {Promise<number>}
|
|
1636
|
+
*/
|
|
1637
|
+
async function runDebt(options, { cwd, env }) {
|
|
1638
|
+
if (options.paths.length !== 1) {
|
|
1639
|
+
env.err(
|
|
1640
|
+
`archkeep: debt takes exactly one positional argument (the history directory); ` +
|
|
1641
|
+
`got ${options.paths.length}`,
|
|
1642
|
+
);
|
|
1643
|
+
return EXIT.usage;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
const dir = isAbsolute(options.paths[0]) ? options.paths[0] : resolve(cwd, options.paths[0]);
|
|
1647
|
+
|
|
1648
|
+
let result;
|
|
1649
|
+
try {
|
|
1650
|
+
const commandContext = resolveCommandContext(
|
|
1651
|
+
{ cwd },
|
|
1652
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
1653
|
+
);
|
|
1654
|
+
|
|
1655
|
+
// The boundary law the ledger ages waivers against — resolved the same way
|
|
1656
|
+
// `graph` and `diff` resolve it (`resolvePolicy`), so a `debt` run and a
|
|
1657
|
+
// `check` run never disagree about the current suppressions, and a
|
|
1658
|
+
// profile-selected workspace resolves the same way `check` does.
|
|
1659
|
+
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
1660
|
+
|
|
1661
|
+
result = await debtCommand(dir, commandContext, { config });
|
|
1662
|
+
} catch (error) {
|
|
1663
|
+
const usageError = error instanceof UsageError;
|
|
1664
|
+
env.err(String(error?.message ?? error));
|
|
1665
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1669
|
+
|
|
1670
|
+
if (options.output) {
|
|
1671
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1672
|
+
// the mechanism and the threat it closes.
|
|
1673
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1674
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
|
|
1675
|
+
env.err(
|
|
1676
|
+
`archkeep: ${result.ledger.total} debt entr` +
|
|
1677
|
+
`${result.ledger.total === 1 ? "y" : "ies"} → ${options.output}`,
|
|
1678
|
+
);
|
|
1679
|
+
} else {
|
|
1680
|
+
env.out(report);
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// Debt is descriptive: 0 when the ledger completes, never 1.
|
|
1684
|
+
return EXIT.ok;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* `discover`'s `run`: resolves the command context, drives `discoverCommand`,
|
|
1689
|
+
* writes the report where it belongs, and returns the process's exit code.
|
|
1690
|
+
*
|
|
1691
|
+
* `discover` takes no positional arguments — the observed side is the whole
|
|
1692
|
+
* graph. `--propose` is opt-in: a proposal is a suggestion, and a workspace
|
|
1693
|
+
* that does not ask for one must not get one.
|
|
1694
|
+
*
|
|
1695
|
+
* @param {{format: string, output: string|null, propose: boolean, paths: string[]}} options
|
|
1696
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
1697
|
+
* @returns {Promise<number>}
|
|
1698
|
+
*/
|
|
1699
|
+
async function runDiscover(options, { cwd, env }) {
|
|
1700
|
+
if (options.paths.length > 0) {
|
|
1701
|
+
env.err(`archkeep: discover takes no positional arguments; got ${options.paths.join(", ")}`);
|
|
1702
|
+
return EXIT.usage;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
let result;
|
|
1706
|
+
try {
|
|
1707
|
+
const commandContext = resolveCommandContext(
|
|
1708
|
+
{ cwd },
|
|
1709
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
1710
|
+
);
|
|
1711
|
+
|
|
1712
|
+
result = discoverCommand(commandContext, { propose: options.propose });
|
|
1713
|
+
} catch (error) {
|
|
1714
|
+
const usageError = error instanceof UsageError;
|
|
1715
|
+
env.err(String(error?.message ?? error));
|
|
1716
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1720
|
+
|
|
1721
|
+
if (options.output) {
|
|
1722
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1723
|
+
// the mechanism and the threat it closes.
|
|
1724
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1725
|
+
// `discover` has no `--config` flag (`DISCOVER_FLAG_HELP`) — there is no
|
|
1726
|
+
// override to pass, only the workspace's un-overridden default to guard.
|
|
1727
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, null)) return EXIT.error;
|
|
1728
|
+
env.err(
|
|
1729
|
+
`archkeep: ${result.discovery.projects.length} projects, ${result.discovery.edges.length} ` +
|
|
1730
|
+
`edges` +
|
|
1731
|
+
(result.proposal
|
|
1732
|
+
? `, ${result.proposal.boundaryAssertions.total} boundary assertions`
|
|
1733
|
+
: "") +
|
|
1734
|
+
` → ${options.output}`,
|
|
1735
|
+
);
|
|
1736
|
+
} else {
|
|
1737
|
+
env.out(report);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// Discover is descriptive: 0 when it completes, never 1.
|
|
1741
|
+
return result.status === "ok" ? EXIT.ok : EXIT.error;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* `health`'s `run`: resolves the command context, drives `healthCommand`,
|
|
1746
|
+
* writes the report where it belongs, and returns the process's exit code.
|
|
1747
|
+
* `health`'s `run`: resolves the command context, drives `healthCommand`,
|
|
1748
|
+
* writes the report where it belongs, and returns the process's exit code.
|
|
1749
|
+
*
|
|
1750
|
+
* `health` takes no positional arguments — it measures the whole workspace.
|
|
1751
|
+
* The snapshot directory for trends is the single optional positional
|
|
1752
|
+
* argument, the same directory `history` reads.
|
|
1753
|
+
*
|
|
1754
|
+
* @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
|
|
1755
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
1756
|
+
* @returns {Promise<number>}
|
|
1757
|
+
*/
|
|
1758
|
+
async function runHealth(options, { cwd, env }) {
|
|
1759
|
+
if (options.paths.length > 1) {
|
|
1760
|
+
env.err(
|
|
1761
|
+
`archkeep: health takes at most one positional argument (the snapshot directory for trends); ` +
|
|
1762
|
+
`got ${options.paths.length}`,
|
|
1763
|
+
);
|
|
1764
|
+
return EXIT.usage;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
const trendDir =
|
|
1768
|
+
options.paths.length === 1
|
|
1769
|
+
? isAbsolute(options.paths[0])
|
|
1770
|
+
? options.paths[0]
|
|
1771
|
+
: resolve(cwd, options.paths[0])
|
|
1772
|
+
: null;
|
|
1773
|
+
|
|
1774
|
+
let result;
|
|
1775
|
+
try {
|
|
1776
|
+
const commandContext = resolveCommandContext(
|
|
1777
|
+
{ cwd },
|
|
1778
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
1779
|
+
);
|
|
1780
|
+
|
|
1781
|
+
// The boundary law and the intent, the same loading every command does
|
|
1782
|
+
// (`resolvePolicy`) — a `--config` overrides the workspace's own
|
|
1783
|
+
// `boundaryConfig`, profile-aware the same way `check` is, and the
|
|
1784
|
+
// intent is the tracked root `architecture-intent.json` (or absent).
|
|
1785
|
+
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
1786
|
+
|
|
1787
|
+
const intent = commandContext.tracked.includes(INTENT_FILE)
|
|
1788
|
+
? await loadIntent(commandContext.root, { tracked: commandContext.tracked })
|
|
1789
|
+
: null;
|
|
1790
|
+
|
|
1791
|
+
result = healthCommand(commandContext, { config, intent, trendDir });
|
|
1792
|
+
} catch (error) {
|
|
1793
|
+
const usageError = error instanceof UsageError;
|
|
1794
|
+
env.err(String(error?.message ?? error));
|
|
1795
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1799
|
+
|
|
1800
|
+
if (options.output) {
|
|
1801
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1802
|
+
// the mechanism and the threat it closes.
|
|
1803
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1804
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
|
|
1805
|
+
env.err(`archkeep: health complete → ${options.output}`);
|
|
1806
|
+
} else {
|
|
1807
|
+
env.out(report);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// Health is descriptive: 0 when it reaches a verdict, 3 when any metric
|
|
1811
|
+
// reads unknown (the run could not fully inspect its own evidence).
|
|
1812
|
+
return result.status === "ok" ? EXIT.ok : EXIT.error;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/**
|
|
1816
|
+
* `report`'s `run`: resolves the command context and the one boundary law the
|
|
1817
|
+
* whole document is written against, drives `reportCommand`, writes the report
|
|
1818
|
+
* where it belongs, and returns the process's exit code.
|
|
1819
|
+
*
|
|
1820
|
+
* The positional argument, the flags and the exit-code lane are `health`'s —
|
|
1821
|
+
* `report` is the same posture over a wider document: descriptive, 0 when
|
|
1822
|
+
* every surface reached a verdict, 3 when any did not, 2 on a usage error. It
|
|
1823
|
+
* never exits 1, because the commands that own a finding own its exit code
|
|
1824
|
+
* (`./src/commands/report.mjs` argues both halves).
|
|
1825
|
+
*
|
|
1826
|
+
* @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
|
|
1827
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
1828
|
+
* @returns {Promise<number>}
|
|
1829
|
+
*/
|
|
1830
|
+
async function runReport(options, { cwd, env }) {
|
|
1831
|
+
if (options.paths.length > 1) {
|
|
1832
|
+
env.err(
|
|
1833
|
+
`archkeep: report takes at most one positional argument (the snapshot directory for trends); ` +
|
|
1834
|
+
`got ${options.paths.length}`,
|
|
1835
|
+
);
|
|
1836
|
+
return EXIT.usage;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
const trendDir =
|
|
1840
|
+
options.paths.length === 1
|
|
1841
|
+
? isAbsolute(options.paths[0])
|
|
1842
|
+
? options.paths[0]
|
|
1843
|
+
: resolve(cwd, options.paths[0])
|
|
1844
|
+
: null;
|
|
1845
|
+
|
|
1846
|
+
let result;
|
|
1847
|
+
try {
|
|
1848
|
+
const commandContext = resolveCommandContext(
|
|
1849
|
+
{ cwd },
|
|
1850
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
1851
|
+
);
|
|
1852
|
+
|
|
1853
|
+
// ONE law for the whole document — resolved exactly the way `check` and
|
|
1854
|
+
// `health` resolve theirs, and handed to every surface the report
|
|
1855
|
+
// composes, so no two sections can cite different laws.
|
|
1856
|
+
const { config, source } = await resolvePolicy(options, commandContext, cwd);
|
|
1857
|
+
|
|
1858
|
+
const intent = commandContext.tracked.includes(INTENT_FILE)
|
|
1859
|
+
? await loadIntent(commandContext.root, { tracked: commandContext.tracked })
|
|
1860
|
+
: null;
|
|
1861
|
+
|
|
1862
|
+
result = await reportCommand(commandContext, {
|
|
1863
|
+
config,
|
|
1864
|
+
intent,
|
|
1865
|
+
trendDir,
|
|
1866
|
+
policySource: source,
|
|
1867
|
+
});
|
|
1868
|
+
} catch (error) {
|
|
1869
|
+
const usageError = error instanceof UsageError;
|
|
1870
|
+
env.err(String(error?.message ?? error));
|
|
1871
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1875
|
+
|
|
1876
|
+
if (options.output) {
|
|
1877
|
+
// Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
|
|
1878
|
+
// the mechanism and the threat it closes.
|
|
1879
|
+
const reportText = report.endsWith("\n") ? report : `${report}\n`;
|
|
1880
|
+
if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
|
|
1881
|
+
// The confirmation names the no-verdict case, so a reader who only
|
|
1882
|
+
// glances at stderr cannot mistake a written document for an established
|
|
1883
|
+
// one (the same reason `waivers` states its permanent-suppression count
|
|
1884
|
+
// on this line).
|
|
1885
|
+
const gaps = result.result.uninspectable.length;
|
|
1886
|
+
const verdict =
|
|
1887
|
+
gaps === 0
|
|
1888
|
+
? "every surface reached a verdict"
|
|
1889
|
+
: `NO VERDICT — ${gaps} surface${gaps === 1 ? "" : "s"} could not be inspected`;
|
|
1890
|
+
env.err(`archkeep: report complete (${verdict}) → ${options.output}`);
|
|
1891
|
+
} else {
|
|
1892
|
+
env.out(report);
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// Descriptive: 0 when every surface reached a verdict, 3 when any evidence
|
|
1896
|
+
// could not be inspected. Never 1.
|
|
1897
|
+
return result.status === "ok" ? EXIT.ok : EXIT.error;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
/**
|
|
1901
|
+
* `check`'s own flags, described once — `usage()` renders this straight into
|
|
1902
|
+
* the Options block, and `flags` below (what `parseArgs` needs) is derived
|
|
1903
|
+
* from it rather than kept as a second list that could name a flag `--help`
|
|
1904
|
+
* does not, or the reverse.
|
|
1905
|
+
*
|
|
1906
|
+
* `--evidence-out` belongs to THIS table and no other. It shipped pasted into
|
|
1907
|
+
* six descriptive commands' tables as well, where nothing read the parsed
|
|
1908
|
+
* `evidenceOut` and nothing wrote a bundle: the flag parsed, the run exited 0,
|
|
1909
|
+
* and the directory the author had just created stayed empty — which reads
|
|
1910
|
+
* exactly like "your evidence is fine". A custom rule is judged only by
|
|
1911
|
+
* `check`, so only `check` may offer the window onto what it was handed, and
|
|
1912
|
+
* `src/custom-rules.check.integration.test.mjs` holds that over every row of
|
|
1913
|
+
* `COMMANDS` rather than over a list written beside it.
|
|
1914
|
+
*
|
|
1915
|
+
* @type {readonly FlagHelp[]}
|
|
1916
|
+
*/
|
|
1917
|
+
const CHECK_FLAG_HELP = Object.freeze([
|
|
1918
|
+
Object.freeze({
|
|
1919
|
+
flag: "--format",
|
|
1920
|
+
key: "format",
|
|
1921
|
+
arg: "text|sarif|json",
|
|
1922
|
+
describe: Object.freeze([
|
|
1923
|
+
"Terminal report (default), SARIF 2.1.0 for GitHub",
|
|
1924
|
+
"code scanning, or the versioned JSON envelope",
|
|
1925
|
+
"docs/reference/json-output.md documents",
|
|
1926
|
+
]),
|
|
1927
|
+
}),
|
|
1928
|
+
Object.freeze({
|
|
1929
|
+
flag: "--output",
|
|
1930
|
+
key: "output",
|
|
1931
|
+
arg: "<file>",
|
|
1932
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
1933
|
+
}),
|
|
1934
|
+
Object.freeze({
|
|
1935
|
+
flag: "--config",
|
|
1936
|
+
key: "config",
|
|
1937
|
+
arg: "<file>",
|
|
1938
|
+
describe: ({ boundaryConfig, inline }) =>
|
|
1939
|
+
Object.freeze([
|
|
1940
|
+
"Read the boundary law from here instead of",
|
|
1941
|
+
inline
|
|
1942
|
+
? "the inline boundaryConfig in archkeep.json"
|
|
1943
|
+
: `<workspace root>/${boundaryConfig}`,
|
|
1944
|
+
]),
|
|
1945
|
+
}),
|
|
1946
|
+
Object.freeze({
|
|
1947
|
+
flag: "--evidence-out",
|
|
1948
|
+
key: "evidenceOut",
|
|
1949
|
+
arg: "<dir>",
|
|
1950
|
+
describe: Object.freeze([
|
|
1951
|
+
"Also write each custom rule's evidence bundle",
|
|
1952
|
+
"into this existing directory, as <rule>.json —",
|
|
1953
|
+
"the exact document the rule was judged over",
|
|
1954
|
+
]),
|
|
1955
|
+
}),
|
|
1956
|
+
]);
|
|
1957
|
+
|
|
1958
|
+
/**
|
|
1959
|
+
* `graph`'s flags: text or JSON envelope, optional file output.
|
|
1960
|
+
*
|
|
1961
|
+
* @type {readonly FlagHelp[]}
|
|
1962
|
+
*/
|
|
1963
|
+
const GRAPH_FLAG_HELP = Object.freeze([
|
|
1964
|
+
Object.freeze({
|
|
1965
|
+
flag: "--format",
|
|
1966
|
+
key: "format",
|
|
1967
|
+
arg: "text|json",
|
|
1968
|
+
describe: Object.freeze([
|
|
1969
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
1970
|
+
"docs/reference/json-output.md documents",
|
|
1971
|
+
]),
|
|
1972
|
+
}),
|
|
1973
|
+
Object.freeze({
|
|
1974
|
+
flag: "--output",
|
|
1975
|
+
key: "output",
|
|
1976
|
+
arg: "<file>",
|
|
1977
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
1978
|
+
}),
|
|
1979
|
+
]);
|
|
1980
|
+
|
|
1981
|
+
/**
|
|
1982
|
+
* `diff`'s flags: text or JSON envelope, optional file output.
|
|
1983
|
+
* The baseline file is a positional argument (a file, not a git ref).
|
|
1984
|
+
*
|
|
1985
|
+
* @type {readonly FlagHelp[]}
|
|
1986
|
+
*/
|
|
1987
|
+
const DIFF_FLAG_HELP = Object.freeze([
|
|
1988
|
+
Object.freeze({
|
|
1989
|
+
flag: "--format",
|
|
1990
|
+
key: "format",
|
|
1991
|
+
arg: "text|json",
|
|
1992
|
+
describe: Object.freeze([
|
|
1993
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
1994
|
+
"docs/reference/json-output.md documents",
|
|
1995
|
+
]),
|
|
1996
|
+
}),
|
|
1997
|
+
Object.freeze({
|
|
1998
|
+
flag: "--output",
|
|
1999
|
+
key: "output",
|
|
2000
|
+
arg: "<file>",
|
|
2001
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2002
|
+
}),
|
|
2003
|
+
Object.freeze({
|
|
2004
|
+
flag: "--config",
|
|
2005
|
+
key: "config",
|
|
2006
|
+
arg: "<file>",
|
|
2007
|
+
describe: Object.freeze([
|
|
2008
|
+
"Read the boundary law from here instead of",
|
|
2009
|
+
"<workspace root>/module-boundaries.config.mjs",
|
|
2010
|
+
]),
|
|
2011
|
+
}),
|
|
2012
|
+
]);
|
|
2013
|
+
|
|
2014
|
+
/**
|
|
2015
|
+
* `drift`'s flags: text or JSON envelope, optional file output. The intent is
|
|
2016
|
+
* always read from the tracked root `architecture-intent.json` — the same one
|
|
2017
|
+
* `check` judges — so there is no `--config` flag: a descriptive comparison
|
|
2018
|
+
* of the observed tree against the declared intent needs no boundary law.
|
|
2019
|
+
*
|
|
2020
|
+
* @type {readonly FlagHelp[]}
|
|
2021
|
+
*/
|
|
2022
|
+
const RECONCILE_FLAG_HELP = Object.freeze([
|
|
2023
|
+
Object.freeze({
|
|
2024
|
+
flag: "--format",
|
|
2025
|
+
key: "format",
|
|
2026
|
+
arg: "text|json",
|
|
2027
|
+
describe: Object.freeze([
|
|
2028
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
2029
|
+
"docs/reference/json-output.md documents",
|
|
2030
|
+
]),
|
|
2031
|
+
}),
|
|
2032
|
+
Object.freeze({
|
|
2033
|
+
flag: "--output",
|
|
2034
|
+
key: "output",
|
|
2035
|
+
arg: "<file>",
|
|
2036
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2037
|
+
}),
|
|
2038
|
+
Object.freeze({
|
|
2039
|
+
flag: "--propose",
|
|
2040
|
+
key: "propose",
|
|
2041
|
+
arg: "",
|
|
2042
|
+
describe: Object.freeze([
|
|
2043
|
+
"Emit a ranked candidate list of model edits,",
|
|
2044
|
+
"marked proposed — never written into",
|
|
2045
|
+
"architecture-intent.json",
|
|
2046
|
+
]),
|
|
2047
|
+
}),
|
|
2048
|
+
]);
|
|
2049
|
+
|
|
2050
|
+
/**
|
|
2051
|
+
* `discover`'s flags: text or JSON envelope, optional file output, and an
|
|
2052
|
+
* opt-in `--propose` that derives candidate architecture. `--propose` is
|
|
2053
|
+
* explicit because a proposal is a suggestion: a workspace that does not ask
|
|
2054
|
+
* for one must not get one.
|
|
2055
|
+
*
|
|
2056
|
+
* @type {readonly FlagHelp[]}
|
|
2057
|
+
*/
|
|
2058
|
+
const DISCOVER_FLAG_HELP = Object.freeze([
|
|
2059
|
+
Object.freeze({
|
|
2060
|
+
flag: "--propose",
|
|
2061
|
+
key: "propose",
|
|
2062
|
+
arg: "",
|
|
2063
|
+
describe: Object.freeze([
|
|
2064
|
+
"Also derive candidate components, boundaries, tags and rules from",
|
|
2065
|
+
"the observed facts — every candidate marked proposed and not",
|
|
2066
|
+
"authoritative; nothing is ever written",
|
|
2067
|
+
]),
|
|
2068
|
+
}),
|
|
2069
|
+
Object.freeze({
|
|
2070
|
+
flag: "--format",
|
|
2071
|
+
key: "format",
|
|
2072
|
+
arg: "text|json",
|
|
2073
|
+
describe: Object.freeze([
|
|
2074
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
2075
|
+
"docs/reference/json-output.md documents",
|
|
2076
|
+
]),
|
|
2077
|
+
}),
|
|
2078
|
+
Object.freeze({
|
|
2079
|
+
flag: "--output",
|
|
2080
|
+
key: "output",
|
|
2081
|
+
arg: "<file>",
|
|
2082
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2083
|
+
}),
|
|
2084
|
+
]);
|
|
2085
|
+
|
|
2086
|
+
const DRIFT_FLAG_HELP = Object.freeze([
|
|
2087
|
+
Object.freeze({
|
|
2088
|
+
flag: "--format",
|
|
2089
|
+
key: "format",
|
|
2090
|
+
arg: "text|json",
|
|
2091
|
+
describe: Object.freeze([
|
|
2092
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
2093
|
+
"docs/reference/json-output.md documents",
|
|
2094
|
+
]),
|
|
2095
|
+
}),
|
|
2096
|
+
Object.freeze({
|
|
2097
|
+
flag: "--output",
|
|
2098
|
+
key: "output",
|
|
2099
|
+
arg: "<file>",
|
|
2100
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2101
|
+
}),
|
|
2102
|
+
]);
|
|
2103
|
+
|
|
2104
|
+
/**
|
|
2105
|
+
* `provenance`'s flags: text or JSON envelope, optional file output. The
|
|
2106
|
+
* command itself has no positional arguments — it reads whichever workspace
|
|
2107
|
+
* the working directory is inside, so there is no `--config`: the rows it
|
|
2108
|
+
* reports come from that workspace's OWN declared intent and boundary config,
|
|
2109
|
+
* and a flag overriding either would report a provenance this run never
|
|
2110
|
+
* judged.
|
|
2111
|
+
*
|
|
2112
|
+
* @type {readonly FlagHelp[]}
|
|
2113
|
+
*/
|
|
2114
|
+
const PROVENANCE_FLAG_HELP = Object.freeze([
|
|
2115
|
+
Object.freeze({
|
|
2116
|
+
flag: "--format",
|
|
2117
|
+
key: "format",
|
|
2118
|
+
arg: "text|json",
|
|
2119
|
+
describe: Object.freeze([
|
|
2120
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
2121
|
+
"docs/reference/json-output.md documents",
|
|
2122
|
+
]),
|
|
2123
|
+
}),
|
|
2124
|
+
Object.freeze({
|
|
2125
|
+
flag: "--output",
|
|
2126
|
+
key: "output",
|
|
2127
|
+
arg: "<file>",
|
|
2128
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2129
|
+
}),
|
|
2130
|
+
]);
|
|
2131
|
+
|
|
2132
|
+
/**
|
|
2133
|
+
* `waivers`' flags: text or JSON envelope, and optional file output — exactly
|
|
2134
|
+
* the descriptive-command pair (`text|json`, no SARIF: a waivers report is a
|
|
2135
|
+
* surface, not a findings container). Plus `--config` in the `diff` shape:
|
|
2136
|
+
* the waivers surface is part of the boundary law, so the law in effect for
|
|
2137
|
+
* one run is the same `--config` pick that run's `check` would make — a flag
|
|
2138
|
+
* `drift` deliberately lacks, because no drift FINDING is decided by the
|
|
2139
|
+
* boundary law. Drift does read it, for one non-verdict axis only: the fitness
|
|
2140
|
+
* half of a row's `decisionRef` resolves against the ids that law declares
|
|
2141
|
+
* (`runDrift`, and `./src/commands/drift.mjs`'s header). That axis changes no
|
|
2142
|
+
* finding and no exit code, so there is no run whose verdict a `--config` pick
|
|
2143
|
+
* here could move.
|
|
2144
|
+
*
|
|
2145
|
+
* @type {readonly FlagHelp[]}
|
|
2146
|
+
*/
|
|
2147
|
+
const WAIVERS_FLAG_HELP = DIFF_FLAG_HELP;
|
|
2148
|
+
|
|
2149
|
+
/**
|
|
2150
|
+
* `fitness`'s flags: text or JSON envelope, optional file output, and the
|
|
2151
|
+
* `--config` override every policy-reading command shares.
|
|
2152
|
+
*
|
|
2153
|
+
* @type {readonly FlagHelp[]}
|
|
2154
|
+
*/
|
|
2155
|
+
const FITNESS_FLAG_HELP = Object.freeze([
|
|
2156
|
+
Object.freeze({
|
|
2157
|
+
flag: "--format",
|
|
2158
|
+
key: "format",
|
|
2159
|
+
arg: "text|json",
|
|
2160
|
+
describe: Object.freeze([
|
|
2161
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
2162
|
+
"docs/reference/json-output.md documents",
|
|
2163
|
+
]),
|
|
2164
|
+
}),
|
|
2165
|
+
Object.freeze({
|
|
2166
|
+
flag: "--output",
|
|
2167
|
+
key: "output",
|
|
2168
|
+
arg: "<file>",
|
|
2169
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2170
|
+
}),
|
|
2171
|
+
Object.freeze({
|
|
2172
|
+
flag: "--config",
|
|
2173
|
+
key: "config",
|
|
2174
|
+
arg: "<file>",
|
|
2175
|
+
describe: (options) => [
|
|
2176
|
+
"Judge the fitness declared in this file",
|
|
2177
|
+
`(the workspace names ${options.boundaryConfig}${
|
|
2178
|
+
options.inline ? " — an inline policy object" : ""
|
|
2179
|
+
})`,
|
|
2180
|
+
],
|
|
2181
|
+
}),
|
|
2182
|
+
]);
|
|
2183
|
+
|
|
2184
|
+
/**
|
|
2185
|
+
* `history`'s flags: text or JSON envelope, optional file output, and the
|
|
2186
|
+
* boolean `--capture` that appends a snapshot of the current workspace
|
|
2187
|
+
* before building the record.
|
|
2188
|
+
*
|
|
2189
|
+
* @type {readonly FlagHelp[]}
|
|
2190
|
+
*/
|
|
2191
|
+
const HISTORY_FLAG_HELP = Object.freeze([
|
|
2192
|
+
Object.freeze({
|
|
2193
|
+
flag: "--format",
|
|
2194
|
+
key: "format",
|
|
2195
|
+
arg: "text|json",
|
|
2196
|
+
describe: Object.freeze([
|
|
2197
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
2198
|
+
"docs/reference/json-output.md documents",
|
|
2199
|
+
]),
|
|
2200
|
+
}),
|
|
2201
|
+
Object.freeze({
|
|
2202
|
+
flag: "--output",
|
|
2203
|
+
key: "output",
|
|
2204
|
+
arg: "<file>",
|
|
2205
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2206
|
+
}),
|
|
2207
|
+
Object.freeze({
|
|
2208
|
+
flag: "--capture",
|
|
2209
|
+
key: "capture",
|
|
2210
|
+
arg: "",
|
|
2211
|
+
describe: Object.freeze([
|
|
2212
|
+
"Write a snapshot of the current workspace into",
|
|
2213
|
+
"the history directory, then build the record",
|
|
2214
|
+
]),
|
|
2215
|
+
}),
|
|
2216
|
+
Object.freeze({
|
|
2217
|
+
flag: "--config",
|
|
2218
|
+
key: "config",
|
|
2219
|
+
arg: "<file>",
|
|
2220
|
+
describe: ({ boundaryConfig, inline }) =>
|
|
2221
|
+
Object.freeze([
|
|
2222
|
+
"Read the boundary law from here instead of",
|
|
2223
|
+
inline
|
|
2224
|
+
? "the inline boundaryConfig in archkeep.json"
|
|
2225
|
+
: `<workspace root>/${boundaryConfig}`,
|
|
2226
|
+
]),
|
|
2227
|
+
}),
|
|
2228
|
+
]);
|
|
2229
|
+
|
|
2230
|
+
/**
|
|
2231
|
+
* `health`'s flags: text or JSON envelope, optional file output. The optional
|
|
2232
|
+
* positional argument is the snapshot directory for trends, the same directory
|
|
2233
|
+
* `history` reads.
|
|
2234
|
+
*
|
|
2235
|
+
* @type {readonly FlagHelp[]}
|
|
2236
|
+
*/
|
|
2237
|
+
const HEALTH_FLAG_HELP = Object.freeze([
|
|
2238
|
+
Object.freeze({
|
|
2239
|
+
flag: "--format",
|
|
2240
|
+
key: "format",
|
|
2241
|
+
arg: "text|json",
|
|
2242
|
+
describe: Object.freeze([
|
|
2243
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
2244
|
+
"docs/reference/json-output.md documents",
|
|
2245
|
+
]),
|
|
2246
|
+
}),
|
|
2247
|
+
Object.freeze({
|
|
2248
|
+
flag: "--output",
|
|
2249
|
+
key: "output",
|
|
2250
|
+
arg: "<file>",
|
|
2251
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2252
|
+
}),
|
|
2253
|
+
Object.freeze({
|
|
2254
|
+
flag: "--config",
|
|
2255
|
+
key: "config",
|
|
2256
|
+
arg: "<file>",
|
|
2257
|
+
describe: ({ boundaryConfig, inline }) =>
|
|
2258
|
+
Object.freeze([
|
|
2259
|
+
"Read the boundary law from here instead of",
|
|
2260
|
+
inline
|
|
2261
|
+
? "the inline boundaryConfig in archkeep.json"
|
|
2262
|
+
: `<workspace root>/${boundaryConfig}`,
|
|
2263
|
+
]),
|
|
2264
|
+
}),
|
|
2265
|
+
]);
|
|
2266
|
+
|
|
2267
|
+
/**
|
|
2268
|
+
* `report`'s flags: the same three `health` takes, for the same reasons — the
|
|
2269
|
+
* governance document is written against one boundary law, and `--config`
|
|
2270
|
+
* (or a profile name, in a workspace that names a `profiles` registry) is how
|
|
2271
|
+
* a caller says which. The optional positional argument is the snapshot
|
|
2272
|
+
* directory for trends, the same directory `history` reads.
|
|
2273
|
+
*
|
|
2274
|
+
* @type {readonly FlagHelp[]}
|
|
2275
|
+
*/
|
|
2276
|
+
const REPORT_FLAG_HELP = Object.freeze([
|
|
2277
|
+
Object.freeze({
|
|
2278
|
+
flag: "--format",
|
|
2279
|
+
key: "format",
|
|
2280
|
+
arg: "text|json",
|
|
2281
|
+
describe: Object.freeze([
|
|
2282
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
2283
|
+
"docs/reference/json-output.md documents",
|
|
2284
|
+
]),
|
|
2285
|
+
}),
|
|
2286
|
+
Object.freeze({
|
|
2287
|
+
flag: "--output",
|
|
2288
|
+
key: "output",
|
|
2289
|
+
arg: "<file>",
|
|
2290
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2291
|
+
}),
|
|
2292
|
+
Object.freeze({
|
|
2293
|
+
flag: "--config",
|
|
2294
|
+
key: "config",
|
|
2295
|
+
arg: "<file>",
|
|
2296
|
+
describe: ({ boundaryConfig, inline }) =>
|
|
2297
|
+
Object.freeze([
|
|
2298
|
+
"Read the boundary law from here instead of",
|
|
2299
|
+
inline
|
|
2300
|
+
? "the inline boundaryConfig in archkeep.json"
|
|
2301
|
+
: `<workspace root>/${boundaryConfig}`,
|
|
2302
|
+
]),
|
|
2303
|
+
}),
|
|
2304
|
+
]);
|
|
2305
|
+
|
|
2306
|
+
/**
|
|
2307
|
+
* `debt`'s flags: text or JSON envelope, optional file output, and the same
|
|
2308
|
+
* `--config` the other descriptive commands take, so a ledger ages the
|
|
2309
|
+
* suppressions of the exact law it was run under.
|
|
2310
|
+
*
|
|
2311
|
+
* @type {readonly FlagHelp[]}
|
|
2312
|
+
*/
|
|
2313
|
+
const DEBT_FLAG_HELP = Object.freeze([
|
|
2314
|
+
Object.freeze({
|
|
2315
|
+
flag: "--format",
|
|
2316
|
+
key: "format",
|
|
2317
|
+
arg: "text|json",
|
|
2318
|
+
describe: Object.freeze([
|
|
2319
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
2320
|
+
"docs/reference/json-output.md documents",
|
|
2321
|
+
]),
|
|
2322
|
+
}),
|
|
2323
|
+
Object.freeze({
|
|
2324
|
+
flag: "--output",
|
|
2325
|
+
key: "output",
|
|
2326
|
+
arg: "<file>",
|
|
2327
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2328
|
+
}),
|
|
2329
|
+
Object.freeze({
|
|
2330
|
+
flag: "--config",
|
|
2331
|
+
key: "config",
|
|
2332
|
+
arg: "<file>",
|
|
2333
|
+
describe: ({ boundaryConfig, inline }) =>
|
|
2334
|
+
Object.freeze([
|
|
2335
|
+
"Read the boundary law from here instead of",
|
|
2336
|
+
inline
|
|
2337
|
+
? "the inline boundaryConfig in archkeep.json"
|
|
2338
|
+
: `<workspace root>/${boundaryConfig}`,
|
|
2339
|
+
]),
|
|
2340
|
+
}),
|
|
2341
|
+
]);
|
|
2342
|
+
|
|
2343
|
+
/**
|
|
2344
|
+
* `impact`'s flags: text or JSON envelope, optional file output.
|
|
2345
|
+
* The project name is a positional argument.
|
|
2346
|
+
*
|
|
2347
|
+
* @type {readonly FlagHelp[]}
|
|
2348
|
+
*/
|
|
2349
|
+
const IMPACT_FLAG_HELP = Object.freeze([
|
|
2350
|
+
Object.freeze({
|
|
2351
|
+
flag: "--format",
|
|
2352
|
+
key: "format",
|
|
2353
|
+
arg: "text|json",
|
|
2354
|
+
describe: Object.freeze([
|
|
2355
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
2356
|
+
"docs/reference/json-output.md documents",
|
|
2357
|
+
]),
|
|
2358
|
+
}),
|
|
2359
|
+
Object.freeze({
|
|
2360
|
+
flag: "--output",
|
|
2361
|
+
key: "output",
|
|
2362
|
+
arg: "<file>",
|
|
2363
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2364
|
+
}),
|
|
2365
|
+
Object.freeze({
|
|
2366
|
+
flag: "--config",
|
|
2367
|
+
key: "config",
|
|
2368
|
+
arg: "<file>",
|
|
2369
|
+
describe: Object.freeze([
|
|
2370
|
+
"Read the boundary law from here instead of",
|
|
2371
|
+
"<workspace root>/module-boundaries.config.mjs",
|
|
2372
|
+
]),
|
|
2373
|
+
}),
|
|
2374
|
+
]);
|
|
2375
|
+
|
|
2376
|
+
/**
|
|
2377
|
+
* `explain`'s flags: text or JSON envelope, optional file output.
|
|
2378
|
+
* The site argument is positional. `--config` overrides the boundary law,
|
|
2379
|
+
* same as `check`, because the judgment depends on which rules are in effect.
|
|
2380
|
+
*
|
|
2381
|
+
* @type {readonly FlagHelp[]}
|
|
2382
|
+
*/
|
|
2383
|
+
const EXPLAIN_FLAG_HELP = Object.freeze([
|
|
2384
|
+
Object.freeze({
|
|
2385
|
+
flag: "--format",
|
|
2386
|
+
key: "format",
|
|
2387
|
+
arg: "text|json",
|
|
2388
|
+
describe: Object.freeze([
|
|
2389
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
2390
|
+
"docs/reference/json-output.md documents",
|
|
2391
|
+
]),
|
|
2392
|
+
}),
|
|
2393
|
+
Object.freeze({
|
|
2394
|
+
flag: "--output",
|
|
2395
|
+
key: "output",
|
|
2396
|
+
arg: "<file>",
|
|
2397
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2398
|
+
}),
|
|
2399
|
+
Object.freeze({
|
|
2400
|
+
flag: "--config",
|
|
2401
|
+
key: "config",
|
|
2402
|
+
arg: "<file>",
|
|
2403
|
+
describe: ({ boundaryConfig, inline }) =>
|
|
2404
|
+
Object.freeze([
|
|
2405
|
+
"Read the boundary law from here instead of",
|
|
2406
|
+
inline
|
|
2407
|
+
? "the inline boundaryConfig in archkeep.json"
|
|
2408
|
+
: `<workspace root>/${boundaryConfig}`,
|
|
2409
|
+
]),
|
|
2410
|
+
}),
|
|
2411
|
+
]);
|
|
2412
|
+
|
|
2413
|
+
/**
|
|
2414
|
+
* `context`'s flags: text or JSON envelope, optional file output.
|
|
2415
|
+
* The project name is a positional argument. `--config` overrides the boundary
|
|
2416
|
+
* law, same as `check` and `explain`, because the answer depends on which
|
|
2417
|
+
* rules are in effect.
|
|
2418
|
+
*
|
|
2419
|
+
* @type {readonly FlagHelp[]}
|
|
2420
|
+
*/
|
|
2421
|
+
const CONTEXT_FLAG_HELP = Object.freeze([
|
|
2422
|
+
Object.freeze({
|
|
2423
|
+
flag: "--plan",
|
|
2424
|
+
key: "plan",
|
|
2425
|
+
arg: "",
|
|
2426
|
+
describe: Object.freeze([
|
|
2427
|
+
"Request the agent planning context: current",
|
|
2428
|
+
"architecture, applicable policy (with Intent),",
|
|
2429
|
+
"impact, current violations, drift, coverage, and",
|
|
2430
|
+
"the commands that verify the change. Trailing",
|
|
2431
|
+
"paths scope the change; deterministic and",
|
|
2432
|
+
"never an LLM plan — Archkeep produces facts,",
|
|
2433
|
+
"agents produce plans.",
|
|
2434
|
+
]),
|
|
2435
|
+
}),
|
|
2436
|
+
Object.freeze({
|
|
2437
|
+
flag: "--format",
|
|
2438
|
+
key: "format",
|
|
2439
|
+
arg: "text|json",
|
|
2440
|
+
describe: Object.freeze([
|
|
2441
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
2442
|
+
"docs/reference/json-output.md documents",
|
|
2443
|
+
]),
|
|
2444
|
+
}),
|
|
2445
|
+
Object.freeze({
|
|
2446
|
+
flag: "--output",
|
|
2447
|
+
key: "output",
|
|
2448
|
+
arg: "<file>",
|
|
2449
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2450
|
+
}),
|
|
2451
|
+
Object.freeze({
|
|
2452
|
+
flag: "--config",
|
|
2453
|
+
key: "config",
|
|
2454
|
+
arg: "<file>",
|
|
2455
|
+
describe: ({ boundaryConfig, inline }) =>
|
|
2456
|
+
Object.freeze([
|
|
2457
|
+
"Read the boundary law from here instead of",
|
|
2458
|
+
inline
|
|
2459
|
+
? "the inline boundaryConfig in archkeep.json"
|
|
2460
|
+
: `<workspace root>/${boundaryConfig}`,
|
|
2461
|
+
]),
|
|
2462
|
+
}),
|
|
2463
|
+
]);
|
|
2464
|
+
|
|
2465
|
+
/**
|
|
2466
|
+
* `adr`'s flags: text or JSON envelope, optional file output. The registry is
|
|
2467
|
+
* always read from the tracked `docs/adr/` at the workspace root — there is no
|
|
2468
|
+
* `--config` flag, because `adr` describes what is recorded, and a description
|
|
2469
|
+
* needs no boundary law.
|
|
2470
|
+
*
|
|
2471
|
+
* @type {readonly FlagHelp[]}
|
|
2472
|
+
*/
|
|
2473
|
+
const ADR_FLAG_HELP = Object.freeze([
|
|
2474
|
+
Object.freeze({
|
|
2475
|
+
flag: "--format",
|
|
2476
|
+
key: "format",
|
|
2477
|
+
arg: "text|json",
|
|
2478
|
+
describe: Object.freeze([
|
|
2479
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
2480
|
+
"docs/reference/json-output.md documents",
|
|
2481
|
+
]),
|
|
2482
|
+
}),
|
|
2483
|
+
Object.freeze({
|
|
2484
|
+
flag: "--output",
|
|
2485
|
+
key: "output",
|
|
2486
|
+
arg: "<file>",
|
|
2487
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
2488
|
+
}),
|
|
2489
|
+
]);
|
|
2490
|
+
|
|
2491
|
+
/**
|
|
2492
|
+
* The command table `usage()` and `runCli` both read from — a command added
|
|
2493
|
+
* later is a new entry here, not a new branch in either. `args` is the
|
|
2494
|
+
* placeholder `usage()` prints after the command name; `flagHelp` is the
|
|
2495
|
+
* source both `usage()`'s Options block and `flags` (below) render from;
|
|
2496
|
+
* `defaults`/`formats` are the rest of `parseArgs`'s spec; `run` is what
|
|
2497
|
+
* `runCli` calls once argv has been split.
|
|
2498
|
+
*/
|
|
2499
|
+
const COMMANDS = Object.freeze({
|
|
2500
|
+
check: Object.freeze({
|
|
2501
|
+
name: "check",
|
|
2502
|
+
args: "[<path>...]",
|
|
2503
|
+
summary: "Check imports against the boundary rules",
|
|
2504
|
+
flagHelp: CHECK_FLAG_HELP,
|
|
2505
|
+
flags: Object.freeze(Object.fromEntries(CHECK_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2506
|
+
defaults: Object.freeze({ format: "text", output: null, config: null, evidenceOut: null }),
|
|
2507
|
+
formats: CHECK_FORMATS,
|
|
2508
|
+
run: runCheck,
|
|
2509
|
+
}),
|
|
2510
|
+
graph: Object.freeze({
|
|
2511
|
+
name: "graph",
|
|
2512
|
+
args: "",
|
|
2513
|
+
summary: "Print the project graph as a deterministic snapshot",
|
|
2514
|
+
flagHelp: GRAPH_FLAG_HELP,
|
|
2515
|
+
flags: Object.freeze(Object.fromEntries(GRAPH_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2516
|
+
defaults: Object.freeze({ format: "text", output: null }),
|
|
2517
|
+
formats: DESCRIBABLE_FORMATS,
|
|
2518
|
+
run: runGraph,
|
|
2519
|
+
}),
|
|
2520
|
+
diff: Object.freeze({
|
|
2521
|
+
name: "diff",
|
|
2522
|
+
args: "<baseline>",
|
|
2523
|
+
summary: "Compare two graph snapshots edge by edge",
|
|
2524
|
+
flagHelp: DIFF_FLAG_HELP,
|
|
2525
|
+
flags: Object.freeze(Object.fromEntries(DIFF_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2526
|
+
defaults: Object.freeze({ format: "text", output: null, config: null }),
|
|
2527
|
+
formats: DESCRIBABLE_FORMATS,
|
|
2528
|
+
run: runDiff,
|
|
2529
|
+
}),
|
|
2530
|
+
discover: Object.freeze({
|
|
2531
|
+
name: "discover",
|
|
2532
|
+
args: "[--propose]",
|
|
2533
|
+
summary: "Report observed facts, and optionally propose candidate architecture",
|
|
2534
|
+
flagHelp: DISCOVER_FLAG_HELP,
|
|
2535
|
+
flags: Object.freeze(Object.fromEntries(DISCOVER_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2536
|
+
defaults: Object.freeze({ format: "text", output: null, propose: false }),
|
|
2537
|
+
formats: DESCRIBABLE_FORMATS,
|
|
2538
|
+
booleans: Object.freeze(["propose"]),
|
|
2539
|
+
run: runDiscover,
|
|
2540
|
+
}),
|
|
2541
|
+
drift: Object.freeze({
|
|
2542
|
+
name: "drift",
|
|
2543
|
+
args: "",
|
|
2544
|
+
summary: "Compare the observed architecture against the declared intent",
|
|
2545
|
+
flagHelp: DRIFT_FLAG_HELP,
|
|
2546
|
+
flags: Object.freeze(Object.fromEntries(DRIFT_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2547
|
+
defaults: Object.freeze({ format: "text", output: null }),
|
|
2548
|
+
formats: DESCRIBABLE_FORMATS,
|
|
2549
|
+
run: runDrift,
|
|
2550
|
+
}),
|
|
2551
|
+
reconcile: Object.freeze({
|
|
2552
|
+
name: "reconcile",
|
|
2553
|
+
args: "",
|
|
2554
|
+
summary: "Compare the declared intent against the observed architecture",
|
|
2555
|
+
flagHelp: RECONCILE_FLAG_HELP,
|
|
2556
|
+
flags: Object.freeze(Object.fromEntries(RECONCILE_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2557
|
+
defaults: Object.freeze({ format: "text", output: null, propose: false }),
|
|
2558
|
+
formats: DESCRIBABLE_FORMATS,
|
|
2559
|
+
booleans: Object.freeze(["propose"]),
|
|
2560
|
+
run: runReconcile,
|
|
2561
|
+
}),
|
|
2562
|
+
waivers: Object.freeze({
|
|
2563
|
+
name: "waivers",
|
|
2564
|
+
args: "",
|
|
2565
|
+
summary: "List the boundary waivers and permanent suppressions on the table",
|
|
2566
|
+
flagHelp: WAIVERS_FLAG_HELP,
|
|
2567
|
+
flags: Object.freeze(Object.fromEntries(WAIVERS_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2568
|
+
defaults: Object.freeze({ format: "text", output: null, config: null }),
|
|
2569
|
+
formats: DESCRIBABLE_FORMATS,
|
|
2570
|
+
run: runWaivers,
|
|
2571
|
+
}),
|
|
2572
|
+
fitness: Object.freeze({
|
|
2573
|
+
name: "fitness",
|
|
2574
|
+
args: "",
|
|
2575
|
+
summary: "Judge every declared fitness function against the workspace",
|
|
2576
|
+
flagHelp: FITNESS_FLAG_HELP,
|
|
2577
|
+
flags: Object.freeze(Object.fromEntries(FITNESS_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2578
|
+
defaults: Object.freeze({ format: "text", output: null, config: null }),
|
|
2579
|
+
formats: DESCRIBABLE_FORMATS,
|
|
2580
|
+
run: runFitness,
|
|
2581
|
+
}),
|
|
2582
|
+
history: Object.freeze({
|
|
2583
|
+
name: "history",
|
|
2584
|
+
args: "<dir>",
|
|
2585
|
+
summary: "Describe how the architecture evolved across snapshots",
|
|
2586
|
+
flagHelp: HISTORY_FLAG_HELP,
|
|
2587
|
+
flags: Object.freeze(Object.fromEntries(HISTORY_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2588
|
+
defaults: Object.freeze({ format: "text", output: null, capture: false, config: null }),
|
|
2589
|
+
formats: DESCRIBABLE_FORMATS,
|
|
2590
|
+
booleans: Object.freeze(["capture"]),
|
|
2591
|
+
run: runHistory,
|
|
2592
|
+
}),
|
|
2593
|
+
health: Object.freeze({
|
|
2594
|
+
name: "health",
|
|
2595
|
+
args: "[<snapshot-dir>]",
|
|
2596
|
+
summary: "Describe architecture health metrics and trends",
|
|
2597
|
+
flagHelp: HEALTH_FLAG_HELP,
|
|
2598
|
+
flags: Object.freeze(Object.fromEntries(HEALTH_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2599
|
+
defaults: Object.freeze({ format: "text", output: null, config: null }),
|
|
2600
|
+
formats: DESCRIBABLE_FORMATS,
|
|
2601
|
+
run: runHealth,
|
|
2602
|
+
}),
|
|
2603
|
+
report: Object.freeze({
|
|
2604
|
+
name: "report",
|
|
2605
|
+
args: "[<snapshot-dir>]",
|
|
2606
|
+
summary: "One governance document: how healthy the architecture is, and why",
|
|
2607
|
+
flagHelp: REPORT_FLAG_HELP,
|
|
2608
|
+
flags: Object.freeze(Object.fromEntries(REPORT_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2609
|
+
defaults: Object.freeze({ format: "text", output: null, config: null }),
|
|
2610
|
+
formats: DESCRIBABLE_FORMATS,
|
|
2611
|
+
run: runReport,
|
|
2612
|
+
}),
|
|
2613
|
+
debt: Object.freeze({
|
|
2614
|
+
name: "debt",
|
|
2615
|
+
args: "<dir>",
|
|
2616
|
+
summary: "Print the architecture-debt ledger across snapshots",
|
|
2617
|
+
flagHelp: DEBT_FLAG_HELP,
|
|
2618
|
+
flags: Object.freeze(Object.fromEntries(DEBT_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2619
|
+
defaults: Object.freeze({ format: "text", output: null, config: null }),
|
|
2620
|
+
formats: DESCRIBABLE_FORMATS,
|
|
2621
|
+
run: runDebt,
|
|
2622
|
+
}),
|
|
2623
|
+
impact: Object.freeze({
|
|
2624
|
+
name: "impact",
|
|
2625
|
+
args: "<project>",
|
|
2626
|
+
summary: "List projects that depend on the named project",
|
|
2627
|
+
flagHelp: IMPACT_FLAG_HELP,
|
|
2628
|
+
flags: Object.freeze(Object.fromEntries(IMPACT_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2629
|
+
defaults: Object.freeze({ format: "text", output: null, config: null }),
|
|
2630
|
+
formats: DESCRIBABLE_FORMATS,
|
|
2631
|
+
run: runImpact,
|
|
2632
|
+
}),
|
|
2633
|
+
explain: Object.freeze({
|
|
2634
|
+
name: "explain",
|
|
2635
|
+
args: "<file:line:column>",
|
|
2636
|
+
summary: "Explain the judgment for one import site",
|
|
2637
|
+
flagHelp: EXPLAIN_FLAG_HELP,
|
|
2638
|
+
flags: Object.freeze(Object.fromEntries(EXPLAIN_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2639
|
+
defaults: Object.freeze({ format: "text", output: null, config: null }),
|
|
2640
|
+
formats: DESCRIBABLE_FORMATS,
|
|
2641
|
+
run: runExplain,
|
|
2642
|
+
}),
|
|
2643
|
+
context: Object.freeze({
|
|
2644
|
+
name: "context",
|
|
2645
|
+
args: "<project> [--plan [<path>...]]",
|
|
2646
|
+
summary: "Show the architecture constraints that apply to a project",
|
|
2647
|
+
flagHelp: CONTEXT_FLAG_HELP,
|
|
2648
|
+
flags: Object.freeze(Object.fromEntries(CONTEXT_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2649
|
+
defaults: Object.freeze({ format: "text", output: null, config: null, plan: false }),
|
|
2650
|
+
formats: DESCRIBABLE_FORMATS,
|
|
2651
|
+
booleans: Object.freeze(["plan"]),
|
|
2652
|
+
run: runContextCommand,
|
|
2653
|
+
}),
|
|
2654
|
+
provenance: Object.freeze({
|
|
2655
|
+
name: "provenance",
|
|
2656
|
+
args: "",
|
|
2657
|
+
summary: "Describe where this run's facts came from and which rows carry an origin",
|
|
2658
|
+
flagHelp: PROVENANCE_FLAG_HELP,
|
|
2659
|
+
flags: Object.freeze(Object.fromEntries(PROVENANCE_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2660
|
+
defaults: Object.freeze({ format: "text", output: null }),
|
|
2661
|
+
formats: DESCRIBABLE_FORMATS,
|
|
2662
|
+
run: runProvenance,
|
|
2663
|
+
}),
|
|
2664
|
+
adr: Object.freeze({
|
|
2665
|
+
name: "adr",
|
|
2666
|
+
args: "[<id>]",
|
|
2667
|
+
summary: "List recorded architecture decisions and what each binds",
|
|
2668
|
+
flagHelp: ADR_FLAG_HELP,
|
|
2669
|
+
flags: Object.freeze(Object.fromEntries(ADR_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
2670
|
+
defaults: Object.freeze({ format: "text", output: null }),
|
|
2671
|
+
formats: DESCRIBABLE_FORMATS,
|
|
2672
|
+
run: runAdr,
|
|
2673
|
+
}),
|
|
2674
|
+
});
|
|
2675
|
+
|
|
2676
|
+
/**
|
|
2677
|
+
* Every command name, in declaration order — the roster `COMMANDS` already
|
|
2678
|
+
* holds, exported so a gate can be exhaustive over it rather than over a
|
|
2679
|
+
* second list that agrees with this one only until someone adds a command.
|
|
2680
|
+
* `src/report/envelope-shape.integration.test.mjs` is the caller: it holds
|
|
2681
|
+
* the JSON envelope's field roster for every command, and derives "every
|
|
2682
|
+
* command" from here for the same reason `scripts/check-packages.mjs` parses
|
|
2683
|
+
* `ci.yml` instead of carrying a copy of its target list. A command added
|
|
2684
|
+
* below with no roster entry fails that test on the day it lands, which is
|
|
2685
|
+
* the only moment its shape is cheap to record.
|
|
2686
|
+
*
|
|
2687
|
+
* The table itself stays unexported: `run` holds this module's own functions,
|
|
2688
|
+
* and exporting it would make every command's implementation reachable as
|
|
2689
|
+
* package API.
|
|
2690
|
+
*/
|
|
2691
|
+
export const COMMAND_NAMES = Object.freeze(Object.keys(COMMANDS));
|
|
2692
|
+
|
|
2693
|
+
/**
|
|
2694
|
+
* Runs the CLI and returns its exit code.
|
|
2695
|
+
*
|
|
2696
|
+
* `env` is everything the command touches outside itself: its two streams, the
|
|
2697
|
+
* working directory that decides which tree is judged, and the Nx and git seams
|
|
2698
|
+
* `check` reaches through. A test supplies all four and reads the verdict
|
|
2699
|
+
* without capturing a process or standing up a workspace.
|
|
2700
|
+
*
|
|
2701
|
+
* The first positional argument names a command from `COMMANDS` — UNLESS it
|
|
2702
|
+
* names a path that exists, in which case there was never a command word at
|
|
2703
|
+
* all and the whole argv is `check`'s own: `archkeep <path>` runs `check`
|
|
2704
|
+
* scoped to it, the same as `archkeep check <path>` does. That is what keeps
|
|
2705
|
+
* `archkeep <path>...` working the way it always has, and it is why the check
|
|
2706
|
+
* happens before the "not a registered command" branch — an existing path is
|
|
2707
|
+
* a path, never an unknown command.
|
|
2708
|
+
*
|
|
2709
|
+
* @param {string[]} argv Arguments after the script name.
|
|
2710
|
+
* @param {{out: (text: string) => void, err: (text: string) => void, cwd?: string,
|
|
2711
|
+
* readGraph?: Function, listFiles?: Function}} env
|
|
2712
|
+
* @returns {Promise<number>} one of `EXIT`.
|
|
2713
|
+
*/
|
|
2714
|
+
export async function runCli(argv, env) {
|
|
2715
|
+
const cwd = env.cwd ?? process.cwd();
|
|
2716
|
+
// Resolved lazily and only where a message needs it, so the happy path pays
|
|
2717
|
+
// one root-marker read and a clean run pays none.
|
|
2718
|
+
const help = () => usage(optionsForUsage(cwd));
|
|
2719
|
+
|
|
2720
|
+
if (argv[0] === "--help" || argv[0] === "-h") {
|
|
2721
|
+
env.out(help());
|
|
2722
|
+
return EXIT.ok;
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
const [maybeCommand, ...maybeRest] = argv;
|
|
2726
|
+
let commandName;
|
|
2727
|
+
let rest;
|
|
2728
|
+
if (maybeCommand === undefined) {
|
|
2729
|
+
commandName = undefined;
|
|
2730
|
+
rest = [];
|
|
2731
|
+
} else if (Object.hasOwn(COMMANDS, maybeCommand)) {
|
|
2732
|
+
commandName = maybeCommand;
|
|
2733
|
+
rest = maybeRest;
|
|
2734
|
+
} else if (
|
|
2735
|
+
maybeCommand !== "" &&
|
|
2736
|
+
existsSync(isAbsolute(maybeCommand) ? maybeCommand : join(cwd, maybeCommand))
|
|
2737
|
+
) {
|
|
2738
|
+
// `maybeCommand !== ""` matters because `join(cwd, "")` is `cwd` itself,
|
|
2739
|
+
// which always exists — an empty first argument would otherwise read as
|
|
2740
|
+
// "the workspace root as a path" and run a whole-workspace check instead
|
|
2741
|
+
// of falling through to the unknown-command refusal below, the same way
|
|
2742
|
+
// any other word that is neither a command nor a real path does.
|
|
2743
|
+
commandName = "check";
|
|
2744
|
+
rest = argv;
|
|
2745
|
+
} else {
|
|
2746
|
+
commandName = maybeCommand;
|
|
2747
|
+
rest = maybeRest;
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
if (commandName === undefined) {
|
|
2751
|
+
env.err("archkeep: no command given.");
|
|
2752
|
+
env.err(help());
|
|
2753
|
+
return EXIT.usage;
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
// `Object.hasOwn` rather than a bare lookup: `COMMANDS[commandName]` for an
|
|
2757
|
+
// inherited key like `toString`, `__proto__` or `constructor` would return
|
|
2758
|
+
// a function or object from `Object.prototype` instead of `undefined`,
|
|
2759
|
+
// pass the `!command` check below, and crash on `command.run` — a
|
|
2760
|
+
// TypeError and exit 1, not the usage error this branch exists to give.
|
|
2761
|
+
const command = Object.hasOwn(COMMANDS, commandName) ? COMMANDS[commandName] : undefined;
|
|
2762
|
+
if (!command) {
|
|
2763
|
+
env.err(
|
|
2764
|
+
`archkeep: unknown command '${commandName}'. Valid commands: ${Object.keys(COMMANDS).join(", ")}.`,
|
|
2765
|
+
);
|
|
2766
|
+
env.err(help());
|
|
2767
|
+
return EXIT.usage;
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
let options;
|
|
2771
|
+
try {
|
|
2772
|
+
options = parseArgs(rest, command);
|
|
2773
|
+
} catch (error) {
|
|
2774
|
+
env.err(`archkeep: ${error.message}`);
|
|
2775
|
+
env.err(help());
|
|
2776
|
+
return EXIT.usage;
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
return command.run(options, { cwd, env });
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
// Run only when invoked as a program, so importing this module for its exit
|
|
2783
|
+
// codes or `runCli` does not execute a command as a side effect. `src/entry-point.mjs`
|
|
2784
|
+
// says why that question is asked on real paths rather than on URLs.
|
|
2785
|
+
if (isProgramEntry(import.meta.url)) {
|
|
2786
|
+
process.exit(
|
|
2787
|
+
await runCli(process.argv.slice(2), {
|
|
2788
|
+
out: (text) => process.stdout.write(`${text}\n`),
|
|
2789
|
+
err: (text) => process.stderr.write(`${text}\n`),
|
|
2790
|
+
}),
|
|
2791
|
+
);
|
|
2792
|
+
}
|