@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.
Files changed (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. 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
+ }