@ecoma-io/archkeep 0.24.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/cli.mjs +166 -506
  2. package/commands.mjs +7 -1
  3. package/package.json +1 -1
  4. package/src/architecture-intent/judge.mjs +1 -1
  5. package/src/architecture-intent/model.mjs +34 -0
  6. package/src/canonical.mjs +2 -1
  7. package/src/commands/adr-for-workspace.mjs +63 -0
  8. package/src/commands/analyze-capability.mjs +13 -0
  9. package/src/commands/change-intent.mjs +11 -0
  10. package/src/commands/change.mjs +315 -55
  11. package/src/commands/check-capability.mjs +13 -0
  12. package/src/commands/check.mjs +13 -3
  13. package/src/commands/compare-capability.mjs +20 -0
  14. package/src/commands/completeness.mjs +12 -0
  15. package/src/commands/context-command.mjs +31 -0
  16. package/src/commands/coverage-verdict.mjs +15 -2
  17. package/src/commands/debt.mjs +30 -0
  18. package/src/commands/decisions.mjs +59 -10
  19. package/src/commands/delta-snapshot.mjs +25 -4
  20. package/src/commands/delta.mjs +301 -67
  21. package/src/commands/diff.mjs +48 -9
  22. package/src/commands/discover.mjs +37 -0
  23. package/src/commands/drift.mjs +45 -0
  24. package/src/commands/evolution.mjs +22 -2
  25. package/src/commands/explain-capability.mjs +11 -0
  26. package/src/commands/explain.mjs +24 -0
  27. package/src/commands/fitness.mjs +119 -10
  28. package/src/commands/govern-capability.mjs +16 -0
  29. package/src/commands/graph.mjs +39 -0
  30. package/src/commands/health.mjs +34 -0
  31. package/src/commands/history.mjs +119 -2
  32. package/src/commands/impact.mjs +23 -0
  33. package/src/commands/inspect-capability.mjs +16 -0
  34. package/src/commands/plan-context-command.mjs +35 -0
  35. package/src/commands/policy.mjs +42 -4
  36. package/src/commands/provenance-command.mjs +15 -24
  37. package/src/commands/report.mjs +38 -0
  38. package/src/commands/rules-capability.mjs +16 -0
  39. package/src/commands/rules.mjs +3 -1
  40. package/src/commands/scenario.mjs +23 -0
  41. package/src/commands/snapshot-meta.mjs +33 -0
  42. package/src/commands/trajectory.mjs +35 -0
  43. package/src/commands/waivers.mjs +30 -0
  44. package/src/corpus/goldens/.gitkeep +0 -0
  45. package/src/corpus/goldens/adr.json +62 -0
  46. package/src/corpus/goldens/adr.text +6 -0
  47. package/src/corpus/goldens/change.json +98 -0
  48. package/src/corpus/goldens/change.text +6 -0
  49. package/src/corpus/goldens/check.json +90 -0
  50. package/src/corpus/goldens/check.sarif +479 -0
  51. package/src/corpus/goldens/check.text +9 -0
  52. package/src/corpus/goldens/context.json +47 -0
  53. package/src/corpus/goldens/context.text +6 -0
  54. package/src/corpus/goldens/debt.json +59 -0
  55. package/src/corpus/goldens/debt.text +7 -0
  56. package/src/corpus/goldens/decisions.json +74 -0
  57. package/src/corpus/goldens/decisions.text +7 -0
  58. package/src/corpus/goldens/delta.json +94 -0
  59. package/src/corpus/goldens/delta.sarif +472 -0
  60. package/src/corpus/goldens/delta.text +5 -0
  61. package/src/corpus/goldens/diff.json +58 -0
  62. package/src/corpus/goldens/diff.text +4 -0
  63. package/src/corpus/goldens/discover.json +77 -0
  64. package/src/corpus/goldens/discover.text +10 -0
  65. package/src/corpus/goldens/drift.json +43 -0
  66. package/src/corpus/goldens/drift.text +3 -0
  67. package/src/corpus/goldens/evolution.json +328 -0
  68. package/src/corpus/goldens/evolution.text +32 -0
  69. package/src/corpus/goldens/explain.json +60 -0
  70. package/src/corpus/goldens/explain.text +7 -0
  71. package/src/corpus/goldens/fitness.json +45 -0
  72. package/src/corpus/goldens/fitness.text +3 -0
  73. package/src/corpus/goldens/graph.json +78 -0
  74. package/src/corpus/goldens/graph.text +10 -0
  75. package/src/corpus/goldens/health.json +72 -0
  76. package/src/corpus/goldens/health.text +10 -0
  77. package/src/corpus/goldens/help.text +104 -0
  78. package/src/corpus/goldens/history.json +43 -0
  79. package/src/corpus/goldens/history.text +4 -0
  80. package/src/corpus/goldens/impact.json +327 -0
  81. package/src/corpus/goldens/impact.text +10 -0
  82. package/src/corpus/goldens/provenance.json +453 -0
  83. package/src/corpus/goldens/provenance.text +14 -0
  84. package/src/corpus/goldens/reconcile.json +168 -0
  85. package/src/corpus/goldens/reconcile.text +3 -0
  86. package/src/corpus/goldens/report.json +168 -0
  87. package/src/corpus/goldens/report.text +42 -0
  88. package/src/corpus/goldens/rules verify.json +0 -0
  89. package/src/corpus/goldens/rules verify.text +0 -0
  90. package/src/corpus/goldens/scenario.json +441 -0
  91. package/src/corpus/goldens/scenario.text +28 -0
  92. package/src/corpus/goldens/trajectory.json +83 -0
  93. package/src/corpus/goldens/trajectory.text +10 -0
  94. package/src/corpus/goldens/usage-error.text +105 -0
  95. package/src/corpus/goldens/waivers.json +40 -0
  96. package/src/corpus/goldens/waivers.text +1 -0
  97. package/src/corpus/goldens-lsp/initialize.result.json +15 -0
  98. package/src/corpus/goldens-lsp/publish-cleared-empty.json +4 -0
  99. package/src/corpus/goldens-lsp/publish-fixed-empty.json +5 -0
  100. package/src/corpus/goldens-lsp/publish-unparseable.json +54 -0
  101. package/src/corpus/goldens-lsp/publish-violation.json +22 -0
  102. package/src/corpus/goldens-lsp/register-watched-files.json +72 -0
  103. package/src/custom-rules/values.mjs +1 -4
  104. package/src/go-work.mjs +8 -23
  105. package/src/governance/evolution-event.mjs +15 -0
  106. package/src/governance/evolution-store.mjs +59 -31
  107. package/src/governance/fitness-registry.mjs +14 -1
  108. package/src/governance/provenance-record.mjs +0 -152
  109. package/src/governance/verdict.mjs +62 -7
  110. package/src/intent/intent-manifest.json +22 -16
  111. package/src/lsp/server.mjs +5 -8
  112. package/src/lsp/workspace-index.mjs +55 -220
  113. package/src/options.mjs +10 -0
  114. package/src/providers/native/discover.mjs +13 -12
  115. package/src/providers/native/model.mjs +8 -3
  116. package/src/providers/nx-static.mjs +231 -0
  117. package/src/report/json.mjs +7 -4
  118. package/src/report/sarif.mjs +8 -3
  119. package/src/rules/edge-constraints.mjs +9 -0
  120. package/src/rules/index.mjs +26 -10
  121. package/src/rules/messages.mjs +64 -14
  122. package/src/rules/specifiers.mjs +21 -20
  123. package/src/tsconfig-paths.mjs +8 -14
  124. package/src/values.mjs +11 -0
  125. package/src/verdict.mjs +127 -16
@@ -82,6 +82,7 @@ import {
82
82
  classifyEvolution,
83
83
  edgeEvolutionIdentity,
84
84
  eventDedupeKey,
85
+ EVENT_DISPOSITIONS,
85
86
  eventId,
86
87
  EVOLUTION_EVENT_SCHEMA_VERSION,
87
88
  } from "../governance/evolution-event.mjs";
@@ -838,14 +839,33 @@ function buildTransitionEvent(from, to, transition, comparison) {
838
839
  * axis at any transition is surfaced as `{available: false, reason}` naming
839
840
  * the transition index, never folded into a fabricated clean aggregate.
840
841
  *
842
+ * The disposition loop latches the event vocabulary: a comparison whose
843
+ * disposition is unknown or absent THROWS naming it — the `?? 1` it replaced
844
+ * defaulted a stranger to rank 1, accepted's own rank, so a garbled
845
+ * disposition could never be the summary's worst. Production dispositions
846
+ * arrive validated (`classifyEvolution`, `readEvents`), so the throw fires
847
+ * only where archkeep itself is buggy — the same latch `deltaDisposition`
848
+ * holds.
849
+ *
841
850
  * @param {object[]} comparisons The per-transition comparison objects.
842
851
  * @returns {object} The summary.
852
+ * @throws {Error} On a comparison whose `disposition` is outside
853
+ * `EVENT_DISPOSITIONS`, naming the value.
843
854
  */
844
- function buildEvolutionSummary(comparisons) {
855
+ export function buildEvolutionSummary(comparisons) {
845
856
  const dispositionRank = { accepted: 1, rejected: 2, "no-verdict": 3 };
846
857
  let disposition = "accepted";
847
858
  for (const comparison of comparisons) {
848
- if ((dispositionRank[comparison.disposition] ?? 1) > dispositionRank[disposition]) {
859
+ if (!EVENT_DISPOSITIONS.includes(comparison.disposition)) {
860
+ throw new Error(
861
+ `the evolution summary folds dispositions by rank, and ${JSON.stringify(
862
+ comparison.disposition,
863
+ )} is not one of [${EVENT_DISPOSITIONS.join(", ")}] — an unknown disposition would ` +
864
+ `default to rank 1, accepted's own rank, and could never be the summary's worst. ` +
865
+ `This is a bug in archkeep, not a fact about the workspace.`,
866
+ );
867
+ }
868
+ if (dispositionRank[comparison.disposition] > dispositionRank[disposition]) {
849
869
  disposition = comparison.disposition;
850
870
  }
851
871
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `explain` capability facade — the word's verb roster as explicit named
3
+ * re-exports: `explain`.
4
+ *
5
+ * Pure surface module: no judgment, no defaults, no logic — every export is
6
+ * a re-export of a command module. `cli.mjs` routes its verb imports through
7
+ * the facade so the vocabulary's code referent is visible to the import
8
+ * graph, not just to a comment (PD-18,
9
+ * ../../../../docs/architecture/refactor/DECISIONS.md).
10
+ */
11
+ export { explain } from "./explain.mjs";
@@ -86,8 +86,10 @@ import { findConstraintsFor } from "../rules/tags.mjs";
86
86
  import { findProjectForPath, createProjectRootMappings } from "../rules/specifiers.mjs";
87
87
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
88
88
  import { coverageVerdict } from "./coverage-verdict.mjs";
89
+ import { resolveCommandContext } from "./context.mjs";
89
90
  import { formatExplainReport } from "../report/explain-text.mjs";
90
91
  import { resolveProvenance } from "./provenance.mjs";
92
+ import { resolvePolicy } from "./policy.mjs";
91
93
  import { readAdrContext } from "./adr.mjs";
92
94
  import { lineage } from "../governance/decision-graph.mjs";
93
95
  import { unresolvedDecisionRefNote } from "./provenance-command.mjs";
@@ -613,3 +615,25 @@ export function explainCommand(site, commandContext, config, options = {}) {
613
615
  },
614
616
  };
615
617
  }
618
+
619
+ /**
620
+ * `explain` as the CLI drives it: the shared preamble — command context,
621
+ * then the boundary law — resolved here so `../../cli.mjs`'s driver only
622
+ * wires options, IO seams, and where output lands (`./README.md`). The
623
+ * engine this returns from is `explainCommand` above, unchanged.
624
+ *
625
+ * @param {{config: string|null, paths: string[]}} options This run's parsed
626
+ * flags; `paths[0]` is the site, a `file:line:column` string.
627
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
628
+ * seams a test injects, the same ones `check` takes.
629
+ * @returns {Promise<object>} `explainCommand`'s result, unmodified.
630
+ */
631
+ export async function explain(options, { cwd, readGraph, listFiles }) {
632
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
633
+ // The config's location is a separate fact from the workspace root.
634
+ // Same loading logic as `check` (`resolvePolicy`) — a `--config`
635
+ // overrides the workspace's own `boundaryConfig`, profile-aware the same
636
+ // way `check` is.
637
+ const { config } = await resolvePolicy(options, commandContext, cwd);
638
+ return explainCommand(options.paths[0], commandContext, config);
639
+ }
@@ -49,13 +49,18 @@ import { blindSpotRows } from "../analysis/source-util.mjs";
49
49
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
50
50
  import { formatFitnessSection } from "../report/text.mjs";
51
51
  import { resolveProvenance } from "./provenance.mjs";
52
+ import { resolveCommandContext } from "./context.mjs";
52
53
  import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
53
54
  import { driftForCheck } from "./drift.mjs";
55
+ import { resolvePolicy } from "./policy.mjs";
54
56
  import {
55
57
  evaluateFitness,
56
58
  fitnessSnapshot,
57
59
  fitnessVerdictFor,
58
60
  } from "../governance/fitness-registry.mjs";
61
+ import { VERDICTS, isVerdict } from "../governance/verdict.mjs";
62
+ import { buildDecision } from "../report/evidence.mjs";
63
+ import { describe } from "../values.mjs";
59
64
 
60
65
  /**
61
66
  * The `fitness` command's own text report: the same verdict table `check`'s
@@ -104,6 +109,55 @@ export function declaresFitness(config) {
104
109
  return config !== null && config !== undefined && config.fitness !== undefined;
105
110
  }
106
111
 
112
+ /**
113
+ * The `fitness` exit fold, lifted out of `fitnessCommand` as a pure function
114
+ * so the mapping from the overall verdict to the status is a fact a test can
115
+ * pin (`./change.mjs`'s `reconcileDisposition` is the arrangement's
116
+ * precedent). D-09, as below: `fail` → `findings`/1, `unknown` →
117
+ * `no-verdict`/3, `pass` and an all-`not_applicable` run alike → `ok`/0.
118
+ *
119
+ * The input latch runs first: `overall.verdict` is the handshake with
120
+ * `fitnessVerdictFor`, and a value the fold does not recognize matches
121
+ * neither the `fail` nor the `unknown` arm — it would fall to `ok`, a clean
122
+ * exit over a run the fold could not read. A verdict outside the four states
123
+ * is refused as a no-verdict whose single reason names it (`refused`), never
124
+ * folded past. The literals stay hand-rolled per site by decision — the
125
+ * carrier folds are the pinned baseline INV-2 names, not a table to be
126
+ * converged (`docs/architecture/refactor/AUTHORITY-MAP.md`).
127
+ *
128
+ * `overall.decisions` is validated for the same reason the delta fold
129
+ * validates buckets the fold itself does not count first: `result.functions`
130
+ * and the text table render them downstream of this fold, so a malformed one
131
+ * is refused while there is still a verdict to withhold.
132
+ *
133
+ * @param {{verdict: string, decisions: object[]}} overall From
134
+ * `fitnessVerdictFor`.
135
+ * @returns {{status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3,
136
+ * refused?: undefined}|
137
+ * {status: "no-verdict", exitCode: 3, refused: string}}
138
+ */
139
+ export function fitnessFold(overall) {
140
+ if (!isVerdict(overall?.verdict)) {
141
+ return {
142
+ status: "no-verdict",
143
+ exitCode: 3,
144
+ refused: `the fitness fold maps "overall.verdict", which is ${describe(overall?.verdict)} — a verdict outside ${VERDICTS.join(", ")} matches neither the fail nor the unknown lane, and reads "ok". This is a bug in archkeep, not a fact about the workspace.`,
145
+ };
146
+ }
147
+ if (!Array.isArray(overall?.decisions)) {
148
+ return {
149
+ status: "no-verdict",
150
+ exitCode: 3,
151
+ refused: `the fitness fold reads "overall.decisions", which is ${describe(overall?.decisions)} — the verdict table the run reports is built from it below, and a list that is not an array renders as an empty table. This is a bug in archkeep, not a fact about the workspace.`,
152
+ };
153
+ }
154
+ return overall.verdict === "fail"
155
+ ? { status: "findings", exitCode: 1 }
156
+ : overall.verdict === "unknown"
157
+ ? { status: "no-verdict", exitCode: 3 }
158
+ : { status: "ok", exitCode: 0 };
159
+ }
160
+
107
161
  /**
108
162
  * Runs the `fitness` command: loads the boundary policy, evaluates every
109
163
  * declared function against the workspace's facts, and renders the verdict
@@ -115,8 +169,8 @@ export function declaresFitness(config) {
115
169
  *
116
170
  * @param {object} commandContext From `resolveCommandContext`.
117
171
  * @param {{config?: object|null}} [io] The loaded policy, injectable for tests.
118
- * @returns {Promise<{status: "ok"|"findings"|"no-verdict", fitness?: object,
119
- * coverage: object, report: {text: string, json: string}}>}
172
+ * @returns {Promise<{status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3,
173
+ * fitness?: object, coverage: object, report: {text: string, json: string}}>}
120
174
  * `status: "no-verdict"` from the coverage refusal carries no `fitness`
121
175
  * payload — the verdict was withheld, and the envelope's `coverage` block is
122
176
  * the whole answer (#608).
@@ -207,13 +261,40 @@ export async function fitnessCommand(commandContext, io = {}) {
207
261
  // every function is `not_applicable` are both `ok`: nothing failed and
208
262
  // nothing stayed undetermined. The status↔exitCode pair is asserted by
209
263
  // `jsonEnvelope` (3-on-no-verdict), so a wrong mapping here cannot ship.
210
- /** @type {{status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3}} */
211
- const { status, exitCode } =
212
- overall.verdict === "fail"
213
- ? { status: "findings", exitCode: 1 }
214
- : overall.verdict === "unknown"
215
- ? { status: "no-verdict", exitCode: 3 }
216
- : { status: "ok", exitCode: 0 };
264
+ // `fitnessFold` above owns the mapping and its input latch; a refusal
265
+ // withholds the verdict table exactly the coverage refusal does, and the
266
+ // decision it carries names the input the fold could not read.
267
+ const fold = fitnessFold(overall);
268
+ if (fold.refused !== undefined) {
269
+ return {
270
+ status: fold.status,
271
+ exitCode: fold.exitCode,
272
+ coverage: { ...coverage, notes: [...coverage.notes, fold.refused] },
273
+ report: {
274
+ text: `fitness: no verdict — ${fold.refused}\n`,
275
+ json: renderJson(
276
+ jsonEnvelope({
277
+ command: "fitness",
278
+ context,
279
+ status: fold.status,
280
+ exitCode: fold.exitCode,
281
+ coverage: { ...coverage, notes: [...coverage.notes, fold.refused] },
282
+ // The refusal-withheld payload, stated the way `coverageRefusal`
283
+ // states it: `jsonEnvelope` requires the key, the refusal has no
284
+ // result to report.
285
+ result: undefined,
286
+ decision: buildDecision({
287
+ status: fold.status,
288
+ coverageComplete: true,
289
+ findings: 0,
290
+ reason: fold.refused,
291
+ }),
292
+ }),
293
+ ),
294
+ },
295
+ };
296
+ }
297
+ const { status, exitCode } = fold;
217
298
  const result = { verdict: overall.verdict, functions: decisions };
218
299
 
219
300
  const report = {
@@ -223,5 +304,33 @@ export async function fitnessCommand(commandContext, io = {}) {
223
304
  ),
224
305
  };
225
306
 
226
- return { status, fitness: result, coverage, report };
307
+ return { status, exitCode, fitness: result, coverage, report };
308
+ }
309
+
310
+ /**
311
+ * `fitness` as the CLI drives it: the shared preamble — command context,
312
+ * then the boundary law — resolved here so `../../cli.mjs`'s driver only
313
+ * wires options, IO seams, and where output lands (`./README.md`). The
314
+ * engine this returns from is `fitnessCommand` above, unchanged.
315
+ *
316
+ * @param {{config: string|null, paths: string[]}} options This run's parsed
317
+ * flags.
318
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
319
+ * seams a test injects, the same ones `check` takes.
320
+ * @returns {Promise<object>} `fitnessCommand`'s result, unmodified.
321
+ */
322
+ export async function fitness(options, { cwd, readGraph, listFiles }) {
323
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
324
+ // Fitness is part of the run's boundary law, so the law is loaded the same
325
+ // way `check` loads it (`resolvePolicy`) and `--config` wins the same
326
+ // way — resolved against the working directory, never against this
327
+ // tool's own location, profile-aware the same way `check` is. A malformed
328
+ // law throws here, exit 3, exactly as in `check`. A profile's `block` may
329
+ // carry a `fitness` key (`docs/concepts/profiles.md` names four block
330
+ // keys, fitness among them), so a profile-selected workspace folds the
331
+ // declared functions the same way a file-selected one does — a profile
332
+ // that declares none reaches `fitnessCommand`'s own "declares no fitness
333
+ // functions" refusal rather than a config-loading failure.
334
+ const { config } = await resolvePolicy(options, commandContext, cwd);
335
+ return fitnessCommand(commandContext, { config });
227
336
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `govern` capability facade — the word's verb roster as explicit named
3
+ * re-exports: `waivers`, `report`, `debt`, `provenance`, `decisions`, `adr`.
4
+ *
5
+ * Pure surface module: no judgment, no defaults, no logic — every export is
6
+ * a re-export of a command module. `cli.mjs` routes its verb imports through
7
+ * the facade so the vocabulary's code referent is visible to the import
8
+ * graph, not just to a comment (PD-18,
9
+ * ../../../../docs/architecture/refactor/DECISIONS.md).
10
+ */
11
+ export { waivers } from "./waivers.mjs";
12
+ export { report } from "./report.mjs";
13
+ export { debt } from "./debt.mjs";
14
+ export { provenanceCommand } from "./provenance-command.mjs";
15
+ export { decisions } from "./decisions.mjs";
16
+ export { adrCommand } from "./adr.mjs";
@@ -41,7 +41,9 @@ import { DEFAULT_WORKSPACE_LAYOUT } from "../rules/specifiers.mjs";
41
41
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
42
42
  import { formatGraphReport } from "../report/graph-text.mjs";
43
43
  import { coverageIncompleteReasons } from "../verdict.mjs";
44
+ import { resolveCommandContext } from "./context.mjs";
44
45
  import { coverageVerdict } from "./coverage-verdict.mjs";
46
+ import { resolveDescribedPolicy } from "./policy.mjs";
45
47
  import { resolveProvenance } from "./provenance.mjs";
46
48
 
47
49
  /**
@@ -342,3 +344,40 @@ export function graphCommand(commandContext, { config = null } = {}) {
342
344
  },
343
345
  };
344
346
  }
347
+
348
+ /**
349
+ * `graph` as the CLI drives it: the shared preamble — command context, then
350
+ * the workspace's described policy — resolved here, so `../../cli.mjs`'s
351
+ * driver only wires options, IO seams, and where output lands
352
+ * (`./README.md`). The engine this returns from is `graphCommand` above,
353
+ * unchanged.
354
+ *
355
+ * The policy is DESCRIBED, not judged: `graph` describes the project graph,
356
+ * not the boundary law — it reads no constraint row and judges nothing
357
+ * against one — so a workspace that has not written a law yet must not be
358
+ * refused here. Every arm of that decision — what is skipped is the load of
359
+ * a file that is NOT THERE, the `boundaryConfigDeclared` bit that keeps the
360
+ * guard to the un-overridden default, and why a law someone named and then
361
+ * deleted stays loud — lives in `resolveDescribedPolicy`
362
+ * (`./policy.mjs`) rather than here, so the descriptive commands and the
363
+ * MCP face that serves them cannot disagree about what "no law declared"
364
+ * means.
365
+ *
366
+ * @param {{config: string|null, paths: string[]}} options This run's parsed
367
+ * flags — `graph` has no `--config` flag, so `config` is always `null`
368
+ * and the workspace's own default is what resolves.
369
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
370
+ * seams a test injects, the same ones `check` takes.
371
+ * @returns {Promise<object>} `graphCommand`'s result, unmodified.
372
+ */
373
+ export async function graph(options, { cwd, readGraph, listFiles }) {
374
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
375
+ // The fingerprint is why the config loads even though `graph` judges
376
+ // nothing: `diff` warns when the policy changed between runs, and a
377
+ // profile-selected workspace's `boundaryConfig` names a profile rather
378
+ // than a file, resolved the same way `check` resolves it
379
+ // (`resolveDescribedPolicy`), so the fingerprint moves with a profile edit
380
+ // the same way it already does with a file or inline-object edit.
381
+ const { config } = await resolveDescribedPolicy(options, commandContext, cwd);
382
+ return graphCommand(commandContext, { config });
383
+ }
@@ -49,10 +49,12 @@ import {
49
49
  unresolvableLiteralCount,
50
50
  } from "../analysis/source-util.mjs";
51
51
  import { buildDependencies, buildProjects } from "./graph.mjs";
52
+ import { isAbsolute, resolve } from "node:path";
52
53
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
53
54
  import { formatHealthReport } from "../report/health-text.mjs";
54
55
  import { resolveProvenance } from "./provenance.mjs";
55
56
  import { readSnapshots } from "./history.mjs";
57
+ import { resolveCommandContext } from "./context.mjs";
56
58
  import {
57
59
  boundaryMetrics,
58
60
  couplingMetrics,
@@ -61,6 +63,8 @@ import {
61
63
  structuralMetrics,
62
64
  } from "../governance/metrics.mjs";
63
65
  import { judgeIntent } from "../architecture-intent/judge.mjs";
66
+ import { loadIntentIfTracked } from "../architecture-intent/model.mjs";
67
+ import { resolvePolicy } from "./policy.mjs";
64
68
 
65
69
  /**
66
70
  * Computes the intent verdict the fitness metric reads — the same `judgeIntent`
@@ -222,3 +226,33 @@ export function healthCommand(commandContext, io = {}) {
222
226
  },
223
227
  };
224
228
  }
229
+
230
+ /**
231
+ * `health` as the CLI drives it: the shared preamble — command context, the
232
+ * boundary law, the tracked intent — resolved here, so `../../cli.mjs`'s
233
+ * driver only wires options, IO seams, and where output lands
234
+ * (`./README.md`). The engine this returns from is `healthCommand` above,
235
+ * unchanged.
236
+ *
237
+ * @param {{config: string|null, paths: string[]}} options This run's parsed
238
+ * flags; at most one positional, the snapshot directory for trends.
239
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
240
+ * seams a test injects, the same ones `check` takes.
241
+ * @returns {Promise<object>} `healthCommand`'s result, unmodified.
242
+ */
243
+ export async function health(options, { cwd, readGraph, listFiles }) {
244
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
245
+ // The boundary law and the intent, the same loading every command does
246
+ // (`resolvePolicy`) — a `--config` overrides the workspace's own
247
+ // `boundaryConfig`, profile-aware the same way `check` is, and the
248
+ // intent is the tracked root `architecture-intent.json` (or absent).
249
+ const { config } = await resolvePolicy(options, commandContext, cwd);
250
+ const intent = await loadIntentIfTracked(commandContext.root, commandContext.tracked);
251
+ const trendDir =
252
+ options.paths.length === 1
253
+ ? isAbsolute(options.paths[0])
254
+ ? options.paths[0]
255
+ : resolve(cwd, options.paths[0])
256
+ : null;
257
+ return healthCommand(commandContext, { config, intent, trendDir });
258
+ }
@@ -72,7 +72,7 @@
72
72
  */
73
73
  import { createHash } from "node:crypto";
74
74
  import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
75
- import { basename, join, resolve } from "node:path";
75
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
76
76
 
77
77
  import {
78
78
  blindSpotRows,
@@ -85,9 +85,11 @@ import { classifyEvolution } from "../governance/evolution-event.mjs";
85
85
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
86
86
  import { formatHistoryReport } from "../report/history-text.mjs";
87
87
  import { computeDiff, parseBaseline } from "./diff.mjs";
88
- import { buildDependencies, buildProjects } from "./graph.mjs";
88
+ import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
89
89
  import { resolveProvenance } from "./provenance.mjs";
90
90
  import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
91
+ import { resolveCommandContext } from "./context.mjs";
92
+ import { resolvePolicy } from "./policy.mjs";
91
93
 
92
94
  /**
93
95
  * A graph envelope's architecture identity: the part that determines whether
@@ -112,6 +114,12 @@ import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
112
114
  * Provider and provenance changes surface through the transition
113
115
  * classification instead.
114
116
  *
117
+ * This function is the graph-snapshot family's ONE identity — evolution event
118
+ * sides consume its output through `eventSnapshotSide` below, never a second
119
+ * derivation (`../../../../docs/adr/0008-snapshot-identity-per-family.md`
120
+ * records the one-identity-per-family decision, including why the evidence
121
+ * snapshot family deliberately carries no id at all).
122
+ *
115
123
  * @param {{projects: object[], dependencies: object[], policy?: {fingerprint: string}|null}} snapshot
116
124
  * @returns {string} A hex-encoded SHA-256.
117
125
  */
@@ -136,6 +144,34 @@ export function snapshotIdentity({ projects, dependencies, policy }) {
136
144
  return createHash("sha256").update(canonical).digest("hex");
137
145
  }
138
146
 
147
+ /**
148
+ * An evolution event's identity side: the reference to ONE state — a revision
149
+ * when one is known, plus the snapshot identity of the graph that side was
150
+ * judged over. `delta` and `change` build every side through this one
151
+ * function so the identity spelling cannot drift between them, and a storage
152
+ * path can never re-enter the tuple a committed event store dedupes on
153
+ * (`docs/concepts/evolution.md`'s "Nor does a storage path"). `history` and
154
+ * `evolution` spell the same `{revision, snapshot}` shape directly, from a
155
+ * snapshot whose identity is already computed.
156
+ *
157
+ * @param {{revision?: string|null, projects: object[], dependencies: object[],
158
+ * policyFingerprint?: string|null}} side
159
+ * @returns {{revision?: string, snapshot: string}} The identity side.
160
+ */
161
+ export function eventSnapshotSide({ revision, projects, dependencies, policyFingerprint }) {
162
+ return {
163
+ ...(typeof revision === "string" ? { revision } : {}),
164
+ snapshot: snapshotIdentity({
165
+ projects,
166
+ dependencies,
167
+ policy:
168
+ policyFingerprint === undefined || policyFingerprint === null
169
+ ? null
170
+ : { fingerprint: policyFingerprint },
171
+ }),
172
+ };
173
+ }
174
+
139
175
  /**
140
176
  * Reads and validates every snapshot in the history directory.
141
177
  *
@@ -732,3 +768,84 @@ function envelopeToSnapshot(envelope, path, id) {
732
768
  id,
733
769
  };
734
770
  }
771
+
772
+ /**
773
+ * The history directory, resolved once from the single positional argument —
774
+ * shared by the CLI entry and the output guard below.
775
+ *
776
+ * @param {{paths: string[]}} options This run's parsed flags.
777
+ * @param {string} cwd The run's working directory.
778
+ * @returns {string}
779
+ */
780
+ function historyDirFrom(options, cwd) {
781
+ return isAbsolute(options.paths[0]) ? resolve(options.paths[0]) : resolve(cwd, options.paths[0]);
782
+ }
783
+
784
+ /**
785
+ * `history`'s self-footgun guard: writing the history report back into the
786
+ * very directory `history` reads would poison every later run (the report
787
+ * envelope is a `history` envelope, which `parseBaseline` refuses as a
788
+ * non-`graph` snapshot). Declared by the command that owns the law and
789
+ * enforced by the driver's write door; `null` means no refusal.
790
+ *
791
+ * @param {{output: string|null, paths: string[]}} options This run's parsed
792
+ * flags.
793
+ * @param {string} cwd The run's working directory, for relative flag
794
+ * resolution.
795
+ * @returns {string|null} The refusal message, or `null` when the output is
796
+ * safe.
797
+ */
798
+ export function historyOutputRefusal(options, cwd) {
799
+ if (!options.output) return null;
800
+ // `resolve()` on the absolute branch too — not just the raw path — the
801
+ // same normalization `writeOutputReport` applies, so an absolute
802
+ // `--output` carrying a `..` segment that resolves INTO the history
803
+ // directory cannot slip past this guard unnormalized.
804
+ const outputAbs = isAbsolute(options.output)
805
+ ? resolve(options.output)
806
+ : resolve(cwd, options.output);
807
+ const dir = historyDirFrom(options, cwd);
808
+ if (dirname(outputAbs) === dir) {
809
+ return (
810
+ `archkeep: --output '${options.output}' is inside the history directory '${dir}' — ` +
811
+ `writing the report there would be read back as a snapshot on the next run. ` +
812
+ `Write it somewhere else.`
813
+ );
814
+ }
815
+ return null;
816
+ }
817
+
818
+ /**
819
+ * `history` as the CLI drives it: the history directory resolved from the
820
+ * single positional argument, then the shared preamble — command context,
821
+ * and under `--capture` the boundary law whose fingerprint the snapshot
822
+ * records — so `../../cli.mjs`'s driver only wires options, IO seams, and
823
+ * where output lands (`./README.md`). The engine this returns from is
824
+ * `historyCommand` above, unchanged.
825
+ *
826
+ * @param {{capture: boolean, config: string|null, paths: string[]}} options
827
+ * This run's parsed flags.
828
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
829
+ * seams a test injects, the same ones `check` takes.
830
+ * @returns {Promise<object>} `historyCommand`'s result, unmodified.
831
+ */
832
+ export async function history(options, { cwd, readGraph, listFiles }) {
833
+ const dir = historyDirFrom(options, cwd);
834
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
835
+ // The boundary law's fingerprint when the workspace declares one, so a
836
+ // captured snapshot records the policy it was taken under — the same
837
+ // config loading `graph` uses (`resolvePolicy`, profile-aware the same
838
+ // way `check` is), kept in one place so a capture and a standalone
839
+ // `graph` never disagree about the current policy.
840
+ let fingerprint = null;
841
+ if (options.capture) {
842
+ const { config } = await resolvePolicy(options, commandContext, cwd);
843
+ if (config) {
844
+ fingerprint = computePolicyFingerprint(config);
845
+ }
846
+ }
847
+ return historyCommand(dir, commandContext, {
848
+ capture: options.capture,
849
+ policyFingerprint: fingerprint,
850
+ });
851
+ }
@@ -40,8 +40,10 @@ import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
40
40
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
41
41
  import { formatImpactReport } from "../report/impact-text.mjs";
42
42
  import { resolveProvenance } from "./provenance.mjs";
43
+ import { resolveCommandContext } from "./context.mjs";
43
44
  import { composeImpactStatement } from "./impact-statement.mjs";
44
45
  import { computeImpact } from "./impact-reachability.mjs";
46
+ import { resolvePolicy } from "./policy.mjs";
45
47
 
46
48
  /**
47
49
  * The reachability walk this command reports, shared with the canonical
@@ -171,3 +173,24 @@ export function impactCommand(projectName, commandContext, config = null) {
171
173
  },
172
174
  };
173
175
  }
176
+
177
+ /**
178
+ * `impact` as the CLI drives it: the shared preamble — command context, then
179
+ * the boundary law — resolved here so `../../cli.mjs`'s driver only wires
180
+ * options, IO seams, and where output lands (`./README.md`). The engine this
181
+ * returns from is `impactCommand` above, unchanged.
182
+ *
183
+ * @param {string} projectName The project whose dependents are walked.
184
+ * @param {{config: string|null}} options This run's parsed flags.
185
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
186
+ * seams a test injects, the same ones `check` takes.
187
+ * @returns {Promise<object>} `impactCommand`'s result, unmodified.
188
+ */
189
+ export async function impact(projectName, options, { cwd, readGraph, listFiles }) {
190
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
191
+ // Load the boundary config when --config is given or when the workspace
192
+ // declares one, so constraint-impact analysis is computed — profile-aware
193
+ // the same way `check` is (`resolvePolicy`).
194
+ const { config } = await resolvePolicy(options, commandContext, cwd);
195
+ return impactCommand(projectName, commandContext, config);
196
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `inspect` capability facade — the word's verb roster as explicit named
3
+ * re-exports: `graph`, `impact`, `context` (the non-plan read; `--plan`
4
+ * dispatches to `planContext`), `health`.
5
+ *
6
+ * Pure surface module: no judgment, no defaults, no logic — every export is
7
+ * a re-export of a command module. `cli.mjs` routes its verb imports through
8
+ * the facade so the vocabulary's code referent is visible to the import
9
+ * graph, not just to a comment (PD-18,
10
+ * ../../../../docs/architecture/refactor/DECISIONS.md).
11
+ */
12
+ export { graph } from "./graph.mjs";
13
+ export { impact } from "./impact.mjs";
14
+ export { context } from "./context-command.mjs";
15
+ export { planContext } from "./plan-context-command.mjs";
16
+ export { health } from "./health.mjs";
@@ -61,6 +61,7 @@
61
61
  * reporting set. This makes the plan's verdict correct on every provider.
62
62
  */
63
63
  import { statSync } from "node:fs";
64
+ import { isAbsolute, resolve } from "node:path";
64
65
  import { join } from "node:path";
65
66
 
66
67
  import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
@@ -82,6 +83,8 @@ import { buildDependencies, buildProjects, computePolicyFingerprint } from "./gr
82
83
  import { resolveProvenance } from "./provenance.mjs";
83
84
  import { readAdrContext } from "./adr.mjs";
84
85
  import { declaresFitness, fitnessForCheck } from "./fitness.mjs";
86
+ import { resolveCommandContext } from "./context.mjs";
87
+ import { resolvePolicy } from "./policy.mjs";
85
88
  import { computeWaivers } from "./waivers.mjs";
86
89
  import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
87
90
  import { formatPlanContextReport } from "../report/plan-context-text.mjs";
@@ -667,3 +670,35 @@ function goWorkResult(goWork) {
667
670
  function tsconfigPathsResult(tsconfigPaths) {
668
671
  return tsconfigPaths === null ? null : { checked: true, findings: tsconfigPaths.findings };
669
672
  }
673
+
674
+ /**
675
+ * `context --plan` as the CLI drives it: the shared preamble — command
676
+ * context, then the boundary law — resolved here, plus the plan's own
677
+ * arguments (the project, the change's scope paths, the optional history
678
+ * directory), so `../../cli.mjs`'s driver only wires options, IO seams, and
679
+ * where output lands (`./README.md`). The engine this returns from is
680
+ * `planContextCommand` above, unchanged.
681
+ *
682
+ * @param {{config: string|null, historyDir: string|null, paths: string[]}} options
683
+ * This run's parsed flags; `paths[0]` is the project name, the rest are
684
+ * the change's scope.
685
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
686
+ * seams a test injects, the same ones `check` takes.
687
+ * @returns {Promise<object>} `planContextCommand`'s result, unmodified.
688
+ */
689
+ export async function planContext(options, { cwd, readGraph, listFiles }) {
690
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
691
+ const { config } = await resolvePolicy(options, commandContext, cwd);
692
+ const historyDir = options.historyDir
693
+ ? isAbsolute(options.historyDir)
694
+ ? options.historyDir
695
+ : resolve(cwd, options.historyDir)
696
+ : null;
697
+ return planContextCommand(
698
+ options.paths[0],
699
+ options.paths.slice(1),
700
+ commandContext,
701
+ config,
702
+ historyDir,
703
+ );
704
+ }