@ecoma-io/archkeep 0.25.0 → 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 (120) 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 +247 -34
  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.mjs +263 -49
  20. package/src/commands/diff.mjs +45 -0
  21. package/src/commands/discover.mjs +37 -0
  22. package/src/commands/drift.mjs +45 -0
  23. package/src/commands/evolution.mjs +22 -2
  24. package/src/commands/explain-capability.mjs +11 -0
  25. package/src/commands/explain.mjs +24 -0
  26. package/src/commands/fitness.mjs +119 -10
  27. package/src/commands/govern-capability.mjs +16 -0
  28. package/src/commands/graph.mjs +39 -0
  29. package/src/commands/health.mjs +34 -0
  30. package/src/commands/history.mjs +85 -2
  31. package/src/commands/impact.mjs +23 -0
  32. package/src/commands/inspect-capability.mjs +16 -0
  33. package/src/commands/plan-context-command.mjs +35 -0
  34. package/src/commands/policy.mjs +42 -4
  35. package/src/commands/provenance-command.mjs +15 -24
  36. package/src/commands/report.mjs +38 -0
  37. package/src/commands/rules-capability.mjs +16 -0
  38. package/src/commands/rules.mjs +3 -1
  39. package/src/commands/scenario.mjs +23 -0
  40. package/src/commands/trajectory.mjs +35 -0
  41. package/src/commands/waivers.mjs +30 -0
  42. package/src/corpus/goldens/.gitkeep +0 -0
  43. package/src/corpus/goldens/adr.json +62 -0
  44. package/src/corpus/goldens/adr.text +6 -0
  45. package/src/corpus/goldens/change.json +98 -0
  46. package/src/corpus/goldens/change.text +6 -0
  47. package/src/corpus/goldens/check.json +90 -0
  48. package/src/corpus/goldens/check.sarif +479 -0
  49. package/src/corpus/goldens/check.text +9 -0
  50. package/src/corpus/goldens/context.json +47 -0
  51. package/src/corpus/goldens/context.text +6 -0
  52. package/src/corpus/goldens/debt.json +59 -0
  53. package/src/corpus/goldens/debt.text +7 -0
  54. package/src/corpus/goldens/decisions.json +74 -0
  55. package/src/corpus/goldens/decisions.text +7 -0
  56. package/src/corpus/goldens/delta.json +94 -0
  57. package/src/corpus/goldens/delta.sarif +472 -0
  58. package/src/corpus/goldens/delta.text +5 -0
  59. package/src/corpus/goldens/diff.json +58 -0
  60. package/src/corpus/goldens/diff.text +4 -0
  61. package/src/corpus/goldens/discover.json +77 -0
  62. package/src/corpus/goldens/discover.text +10 -0
  63. package/src/corpus/goldens/drift.json +43 -0
  64. package/src/corpus/goldens/drift.text +3 -0
  65. package/src/corpus/goldens/evolution.json +328 -0
  66. package/src/corpus/goldens/evolution.text +32 -0
  67. package/src/corpus/goldens/explain.json +60 -0
  68. package/src/corpus/goldens/explain.text +7 -0
  69. package/src/corpus/goldens/fitness.json +45 -0
  70. package/src/corpus/goldens/fitness.text +3 -0
  71. package/src/corpus/goldens/graph.json +78 -0
  72. package/src/corpus/goldens/graph.text +10 -0
  73. package/src/corpus/goldens/health.json +72 -0
  74. package/src/corpus/goldens/health.text +10 -0
  75. package/src/corpus/goldens/help.text +104 -0
  76. package/src/corpus/goldens/history.json +43 -0
  77. package/src/corpus/goldens/history.text +4 -0
  78. package/src/corpus/goldens/impact.json +327 -0
  79. package/src/corpus/goldens/impact.text +10 -0
  80. package/src/corpus/goldens/provenance.json +453 -0
  81. package/src/corpus/goldens/provenance.text +14 -0
  82. package/src/corpus/goldens/reconcile.json +168 -0
  83. package/src/corpus/goldens/reconcile.text +3 -0
  84. package/src/corpus/goldens/report.json +168 -0
  85. package/src/corpus/goldens/report.text +42 -0
  86. package/src/corpus/goldens/rules verify.json +0 -0
  87. package/src/corpus/goldens/rules verify.text +0 -0
  88. package/src/corpus/goldens/scenario.json +441 -0
  89. package/src/corpus/goldens/scenario.text +28 -0
  90. package/src/corpus/goldens/trajectory.json +83 -0
  91. package/src/corpus/goldens/trajectory.text +10 -0
  92. package/src/corpus/goldens/usage-error.text +105 -0
  93. package/src/corpus/goldens/waivers.json +40 -0
  94. package/src/corpus/goldens/waivers.text +1 -0
  95. package/src/corpus/goldens-lsp/initialize.result.json +15 -0
  96. package/src/corpus/goldens-lsp/publish-cleared-empty.json +4 -0
  97. package/src/corpus/goldens-lsp/publish-fixed-empty.json +5 -0
  98. package/src/corpus/goldens-lsp/publish-unparseable.json +54 -0
  99. package/src/corpus/goldens-lsp/publish-violation.json +22 -0
  100. package/src/corpus/goldens-lsp/register-watched-files.json +72 -0
  101. package/src/go-work.mjs +8 -23
  102. package/src/governance/evolution-event.mjs +15 -0
  103. package/src/governance/evolution-store.mjs +59 -31
  104. package/src/governance/provenance-record.mjs +0 -152
  105. package/src/governance/verdict.mjs +26 -3
  106. package/src/intent/intent-manifest.json +14 -14
  107. package/src/lsp/server.mjs +5 -8
  108. package/src/lsp/workspace-index.mjs +55 -220
  109. package/src/options.mjs +10 -0
  110. package/src/providers/native/discover.mjs +13 -12
  111. package/src/providers/native/model.mjs +8 -3
  112. package/src/providers/nx-static.mjs +231 -0
  113. package/src/report/json.mjs +3 -2
  114. package/src/report/sarif.mjs +8 -3
  115. package/src/rules/edge-constraints.mjs +9 -0
  116. package/src/rules/index.mjs +26 -10
  117. package/src/rules/messages.mjs +64 -14
  118. package/src/rules/specifiers.mjs +21 -20
  119. package/src/tsconfig-paths.mjs +8 -14
  120. package/src/verdict.mjs +127 -16
@@ -42,12 +42,15 @@ import { readFileSync } from "node:fs";
42
42
 
43
43
  import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
44
44
  import { computeRuleImpact } from "../rules/edge-constraints.mjs";
45
+ import { isAbsolute, resolve } from "node:path";
45
46
  import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
46
47
  import { SCHEMA_VERSION } from "../report/json.mjs";
47
48
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
48
49
  import { formatDiffReport } from "../report/diff-text.mjs";
49
50
  import { compareSnapshotMetadata, dirtyBaselineNote, dirtyHeadNote } from "./snapshot-meta.mjs";
50
51
  import { resolveProvenance } from "./provenance.mjs";
52
+ import { resolveCommandContext } from "./context.mjs";
53
+ import { resolvePolicy } from "./policy.mjs";
51
54
 
52
55
  /**
53
56
  * Reads and validates a baseline snapshot from `path`.
@@ -236,6 +239,20 @@ function buildHeadSnapshot(commandContext) {
236
239
  * consumer wants to see: it is a real architectural event, not an
237
240
  * implementation detail.
238
241
  *
242
+ * This key is in-memory arithmetic and nothing else: `Map`/`Set` keys inside
243
+ * one run, never persisted and never emitted — only counts derived from
244
+ * those sets reach an envelope. The STORED spelling of edge identity is
245
+ * `edgeEvolutionIdentity` (`../governance/evolution-event.mjs`), the escaped
246
+ * `source>target:type` string evolution events carry, and the two spellings
247
+ * are deliberately not unified — each medium keeps exactly one identity
248
+ * constructor (`../../../../docs/adr/0008-snapshot-identity-per-family.md`;
249
+ * INV-6 in `../../../../docs/architecture/refactor/INVARIANTS.md`). An edge
250
+ * crosses from a structural diff into an event as its raw
251
+ * `{source, target, type}` triple, mapped through that function at the
252
+ * command boundary; a `\0`-joined key written into an event record would
253
+ * give one edge two spellings inside the store, and anything comparing
254
+ * identity strings across events would read them as different boundaries.
255
+ *
239
256
  * @param {{source: string, target: string, type: string}} edge
240
257
  * @returns {string}
241
258
  */
@@ -558,3 +575,31 @@ export function diffCommand(
558
575
  },
559
576
  };
560
577
  }
578
+
579
+ /**
580
+ * `diff` as the CLI drives it: the baseline path resolved from the single
581
+ * positional argument, then the shared preamble — command context, boundary
582
+ * law — so `../../cli.mjs`'s driver only wires options, IO seams, and where
583
+ * output lands (`./README.md`). The engine this returns from is
584
+ * `diffCommand` above, unchanged.
585
+ *
586
+ * @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
587
+ * This run's parsed flags; `paths[0]` is the baseline file.
588
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
589
+ * seams a test injects, the same ones `check` takes.
590
+ * @returns {Promise<object>} `diffCommand`'s result, unmodified.
591
+ */
592
+ export async function diff(options, { cwd, readGraph, listFiles }) {
593
+ const baselinePath = isAbsolute(options.paths[0])
594
+ ? options.paths[0]
595
+ : resolve(cwd, options.paths[0]);
596
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
597
+ // Load the boundary config when --config is given or when the workspace
598
+ // declares one, so rule-impact analysis is computed. Without a config,
599
+ // the diff reports only structural changes — same as before. A
600
+ // profile-selected workspace resolves the same way `check` does
601
+ // (`resolvePolicy`), so a policy edit under an unchanged profile NAME is
602
+ // still visible as a fingerprint change here.
603
+ const { config } = await resolvePolicy(options, commandContext, cwd);
604
+ return diffCommand(baselinePath, commandContext, { config });
605
+ }
@@ -122,6 +122,43 @@ export function proposalToIntent(proposal) {
122
122
  };
123
123
  }
124
124
 
125
+ /**
126
+ * Serialize a proposal into the exact bytes `--write-intent` plants at the
127
+ * target path — `proposalToIntent` rendered as stable, two-space JSON with a
128
+ * trailing newline, so the file a human reviews as a diff is the file
129
+ * `check` would read. Owned here, beside the conversion it renders, so the
130
+ * CLI's write door can never re-derive a second spelling of it.
131
+ *
132
+ * @param {object} proposal The proposal from `discoverCommand`'s result.
133
+ * @returns {string} The serialized intent document.
134
+ */
135
+ export function intentJsonFromProposal(proposal) {
136
+ return JSON.stringify(proposalToIntent(proposal), null, 2) + "\n";
137
+ }
138
+
139
+ /**
140
+ * The `--write-intent` self-footgun refusal, decided beside the proposal it
141
+ * protects. A proposal is a suggestion; a file already at the target is a law
142
+ * (or a candidate someone holds), and silently overwriting it with a proposal
143
+ * is the adoption this command must never perform by itself — the same
144
+ * posture `--output`'s fixed-name table holds at the write door, specialized
145
+ * to the one target `discover` itself names. The CLI keeps the mechanics
146
+ * (the `wx` write, the stderr wording around it); the DECISION to refuse
147
+ * travels with the verb, the way `historyOutputRefusal` does for history.
148
+ *
149
+ * @param {string} target The `--write-intent` path as the user wrote it.
150
+ * @param {{exists: (path: string) => boolean}} io Injectable existence read —
151
+ * the same seam style `loadNativeModel`'s `{readFile}` keeps.
152
+ * @returns {string|null} The refusal message, or `null` when the write may
153
+ * proceed.
154
+ */
155
+ export function intentWriteRefusal(target, { exists }) {
156
+ return exists(target)
157
+ ? `archkeep: ${target} already exists, and a proposal must never ` +
158
+ `silently replace what is there. Move or delete the file first, then run this again.`
159
+ : null;
160
+ }
161
+
125
162
  /**
126
163
  * Runs the `discover` command: observes the workspace, optionally proposes the
127
164
  * candidate architecture over it, and returns the report.
@@ -77,6 +77,8 @@
77
77
  * intent produce byte-identical text and JSON.
78
78
  */
79
79
  import { blindSpotRows } from "../analysis/source-util.mjs";
80
+ import { resolveCommandContext } from "./context.mjs";
81
+ import { resolvePolicy } from "./policy.mjs";
80
82
  import { buildDependencies, buildProjects } from "./graph.mjs";
81
83
  import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
82
84
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
@@ -471,3 +473,46 @@ export async function driftCommand(commandContext, io = {}) {
471
473
  },
472
474
  };
473
475
  }
476
+
477
+ /**
478
+ * `drift` as the CLI drives it: the shared preamble — command context, then
479
+ * the boundary law — resolved here so `../../cli.mjs`'s driver only wires
480
+ * options, IO seams, and where output lands (`./README.md`). The engine this
481
+ * returns from is `driftCommand` above, unchanged.
482
+ *
483
+ * The loaded policy — profile-aware the same way `check` is
484
+ * (`resolvePolicy`), `null` when the workspace declares none. Drift reads
485
+ * the intent's rows, and the fitness half of a row's `decisionRef` resolves
486
+ * against the ids THIS policy declares (F04), so the same policy that made
487
+ * the boundary law answerable to the model must answer here. `drift` has no
488
+ * `--config` (`DRIFT_FLAG_HELP`), so `config` is always the workspace's own
489
+ * default — resolvePolicy reads `options.config` as the override, hence
490
+ * `null` here, which selects the workspace's configured boundary law (or a
491
+ * profile, when one is registered).
492
+ *
493
+ * The failure is DEFERRED rather than thrown here. `drift`'s only reader of
494
+ * this policy is the non-verdict decisionRef axis, and only for rows that
495
+ * carry one, so a workspace with an intent and no boundary config was
496
+ * exiting 3 over a law drift would never have opened — a fifth refusal
497
+ * neither `docs/usage/drift.md` nor `reconcile`, which makes the same four,
498
+ * ever had. `driftCommand` rethrows it, unchanged, at the one site that
499
+ * reads the policy, so every workspace whose intent cites anything keeps the
500
+ * exact exit-3 it had.
501
+ *
502
+ * @param {{format: string, output: string|null, paths: string[]}} options
503
+ * This run's parsed flags.
504
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
505
+ * seams a test injects, the same ones `check` takes.
506
+ * @returns {Promise<object>} `driftCommand`'s result, unmodified.
507
+ */
508
+ export async function drift(options, { cwd, readGraph, listFiles }) {
509
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
510
+ let config = null;
511
+ let configError = null;
512
+ try {
513
+ ({ config } = await resolvePolicy({ ...options, config: null }, commandContext, cwd));
514
+ } catch (error) {
515
+ configError = /** @type {Error} */ (error);
516
+ }
517
+ return driftCommand(commandContext, { config, configError });
518
+ }
@@ -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
@@ -766,3 +768,84 @@ function envelopeToSnapshot(envelope, path, id) {
766
768
  id,
767
769
  };
768
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
+ }