@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
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `check` capability facade — the word's verb roster as explicit named
3
+ * re-exports: `check`, `fitness`, `scenario`.
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 { check, sortViolations } from "./check.mjs";
12
+ export { fitness } from "./fitness.mjs";
13
+ export { scenario } from "./scenario.mjs";
@@ -209,7 +209,7 @@ function declaredEdgeManifest({ provider, graph }, sourceProject) {
209
209
  * evidenceOut?: string|null}} options
210
210
  * @param {{cwd: string, readGraph?: Function, listFiles?: Function,
211
211
  * listUntracked?: Function}} context
212
- * @returns {Promise<{report: string, violations: number, declaredEdgeFindings: number,
212
+ * @returns {Promise<{exitCode: 0|1|3, report: string, violations: number, declaredEdgeFindings: number,
213
213
  * goWorkDrift: number, tsconfigPathsDead: number, intentFindings: number,
214
214
  * intentUnresolved: number, intentUnresolvedDecisionRefs: number, fitnessFail: number,
215
215
  * fitnessUnknown: number, customRuleFail: number, customRuleUnknown: number,
@@ -521,6 +521,15 @@ export async function check(
521
521
  // from the MARKER (the line a reader edits is the document's), project and
522
522
  // constraint from the edge — so suppression, waiver annotation, sorting and
523
523
  // the SARIF face all treat it as the ordinary violation it is.
524
+ //
525
+ // PD-13 (2026-09-06) pins THIS fold as the documented normalization seam
526
+ // (`../../../../docs/architecture/refactor/DECISIONS.md`): the one place
527
+ // `judgeEdge`'s markdown-pairing verdicts are reshaped into the exact
528
+ // `Violation` record `violationOf` (`../rules/index.mjs`) builds — the
529
+ // families then fold into the one verdict lane as count keys into
530
+ // `verdictFor` (`../verdict.mjs`). The canonical statement is the
531
+ // "Finding — the unowned concept" section of
532
+ // `../../../../docs/architecture/refactor/SEMANTIC-MODEL.md`.
524
533
  /** @type {object[]} */
525
534
  const markdownRaw = [];
526
535
  if (config !== null && markdownTrack !== null && config.depConstraints.length > 0) {
@@ -1289,6 +1298,7 @@ export async function check(
1289
1298
  });
1290
1299
 
1291
1300
  return {
1301
+ exitCode: verdict.exitCode,
1292
1302
  report,
1293
1303
  violations: violations.length,
1294
1304
  waived,
@@ -1311,8 +1321,8 @@ export async function check(
1311
1321
  customRulesDeclared: customRules !== null,
1312
1322
  analyzed,
1313
1323
  unchecked,
1314
- // The site-level count `verdictFor` needs: `cli.mjs` passes this whole
1315
- // return through `verdictFor` for the process's exit code, so a count the
1324
+ // The exit authority is `verdict` above, surfaced on this return: the
1325
+ // process exit code comes straight from this field, so a count the
1316
1326
  // envelope saw but the exit code did not would let the two disagree.
1317
1327
  blindSpots: blindSpotCount,
1318
1328
  };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `compare` capability facade — the word's verb roster as explicit named
3
+ * re-exports: `diff`, `delta` (both modes), `change`, `drift`, `reconcile`,
4
+ * `history`, `trajectory`, `evolution`, plus the output guards the two
5
+ * verbs that write reports declare (`change`, `history`, `trajectory`).
6
+ *
7
+ * Pure surface module: no judgment, no defaults, no logic — every export is
8
+ * a re-export of a command module. `cli.mjs` routes its verb imports through
9
+ * the facade so the vocabulary's code referent is visible to the import
10
+ * graph, not just to a comment (PD-18,
11
+ * ../../../../docs/architecture/refactor/DECISIONS.md).
12
+ */
13
+ export { diff } from "./diff.mjs";
14
+ export { captureBaseline, delta } from "./delta.mjs";
15
+ export { change, changeOutputRefusal } from "./change.mjs";
16
+ export { drift } from "./drift.mjs";
17
+ export { reconcileCommand } from "./reconcile.mjs";
18
+ export { history, historyOutputRefusal } from "./history.mjs";
19
+ export { trajectoryCommand, trajectoryOutputRefusal } from "./trajectory.mjs";
20
+ export { evolutionCommand } from "./evolution.mjs";
@@ -24,6 +24,18 @@
24
24
  * `overallComplete` implies ALL gates pass. Any failed gate MUST prevent
25
25
  * `overallComplete = true`.
26
26
  *
27
+ * ## Not the coverage refusal (register R1)
28
+ *
29
+ * These statuses grade the composed evaluation — how completely the
30
+ * Evidence-Complete gates were satisfied for the statement being made. A
31
+ * different question is whether the run saw enough input to make any claim
32
+ * at all, and that one is answered only by the graph family's shared
33
+ * refusal contract (`./coverage-verdict.mjs`): `coverageVerdict` refuses
34
+ * `no-verdict` (exit 3), and no status here is derived from it or folds
35
+ * into it. Two models of "did we see everything", owned by different
36
+ * waves — register R1 in `docs/architecture/refactor/SEMANTIC-MODEL.md`
37
+ * keeps them separate.
38
+ *
27
39
  * @module
28
40
  */
29
41
 
@@ -37,10 +37,12 @@ import { judgeEdge } from "../rules/edge-constraints.mjs";
37
37
  import { findConstraintsFor } from "../rules/tags.mjs";
38
38
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
39
39
  import { formatContextReport } from "../report/context-text.mjs";
40
+ import { resolveCommandContext } from "./context.mjs";
40
41
  import { coverageVerdict } from "./coverage-verdict.mjs";
41
42
  import { resolveProvenance } from "./provenance.mjs";
42
43
  import { readAdrContext } from "./adr.mjs";
43
44
  import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
45
+ import { resolvePolicy } from "./policy.mjs";
44
46
 
45
47
  /**
46
48
  * Collects the architecture context for a project: its tags, which constraint
@@ -210,3 +212,32 @@ export function contextCommand(projectName, commandContext, config) {
210
212
  },
211
213
  };
212
214
  }
215
+
216
+ /**
217
+ * `context` as the CLI drives it (the non-plan read): the shared preamble —
218
+ * command context, then the boundary law — resolved here so
219
+ * `../../cli.mjs`'s driver only wires options, IO seams, and where output
220
+ * lands (`./README.md`). The engine this returns from is `contextCommand`
221
+ * above, unchanged.
222
+ *
223
+ * The command context is resolved over the WHOLE workspace. Scoping by path
224
+ * is the plan command's decision (which projects the change touches), not
225
+ * the preamble's: the rule verdict and the architecture snapshot must be
226
+ * over the whole tree, and only reporting is narrowed. Passing no paths
227
+ * keeps the non-plan `context` path byte-for-byte identical.
228
+ *
229
+ * @param {{config: string|null, paths: string[]}} options This run's parsed
230
+ * flags; `paths[0]` is the project name.
231
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
232
+ * seams a test injects, the same ones `check` takes.
233
+ * @returns {Promise<object>} `contextCommand`'s result, unmodified.
234
+ */
235
+ export async function context(options, { cwd, readGraph, listFiles }) {
236
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
237
+ // The config's location is a separate fact from the workspace root.
238
+ // Same loading logic as `check` and `explain` (`resolvePolicy`) — a
239
+ // `--config` overrides the workspace's own `boundaryConfig`,
240
+ // profile-aware the same way `check` is.
241
+ const { config } = await resolvePolicy(options, commandContext, cwd);
242
+ return contextCommand(options.paths[0], commandContext, config);
243
+ }
@@ -135,7 +135,7 @@ export function coverageVerdict(commandContext, { acceptedFiles } = {}) {
135
135
  * command was asking (the text face's one free clause), `acceptedFiles`
136
136
  * forwards to `coverageVerdict` (see above), and `decision: true` attaches
137
137
  * the canonical `unknown` decision.
138
- * @returns {{status: "no-verdict", coverage: object,
138
+ * @returns {{status: "no-verdict", exitCode: 3, coverage: object,
139
139
  * report: {text: string, json: string}}}
140
140
  */
141
141
  export function coverageRefusal({ command, commandContext, what, acceptedFiles, decision }) {
@@ -151,6 +151,18 @@ export function coverageRefusal({ command, commandContext, what, acceptedFiles,
151
151
  }
152
152
  /** @type {"no-verdict"} The completeness is false, so this is the status. */
153
153
  const status = "no-verdict";
154
+ // The `complete` throw above pins the STATUS; this guard pins the EXIT the
155
+ // same way, so the `{3}` annotation below is a checked fact rather than an
156
+ // unchecked assertion — a future `coverageVerdict` that let an incomplete
157
+ // run claim a clean exit dies here, loudly, instead of typing the lie.
158
+ if (verdict.exitCode !== EXIT.error) {
159
+ throw new Error(
160
+ `coverageRefusal: ${command}'s incomplete verdict carries exit ${verdict.exitCode} — ` +
161
+ `a refusal never exits clean`,
162
+ );
163
+ }
164
+ /** @type {3} The value flows from the one verdict table; the type is proven above. */
165
+ const refusalExitCode = verdict.exitCode;
154
166
  const reasons = coverageIncompleteReasons({
155
167
  unchecked: verdict.notAnalyzed.length,
156
168
  blindSpots: verdict.blindSpotCount,
@@ -174,7 +186,7 @@ export function coverageRefusal({ command, commandContext, what, acceptedFiles,
174
186
  provenance: resolveProvenance(commandContext.root),
175
187
  },
176
188
  status,
177
- exitCode: verdict.exitCode,
189
+ exitCode: refusalExitCode,
178
190
  coverage,
179
191
  result: undefined,
180
192
  ...(decision === true
@@ -190,6 +202,7 @@ export function coverageRefusal({ command, commandContext, what, acceptedFiles,
190
202
  });
191
203
  return {
192
204
  status,
205
+ exitCode: refusalExitCode,
193
206
  coverage,
194
207
  report: {
195
208
  text:
@@ -51,11 +51,14 @@ import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
51
51
  import { computeDebtLedger } from "../governance/debt-ledger.mjs";
52
52
  import { readEvents } from "../governance/evolution-store.mjs";
53
53
  import { formatDebtReport } from "../report/debt-text.mjs";
54
+ import { isAbsolute, resolve } from "node:path";
54
55
  import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
55
56
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
56
57
  import { resolveProvenance } from "./provenance.mjs";
57
58
  import { buildObserved, refuseIncompleteGraph } from "./drift.mjs";
58
59
  import { readSnapshots } from "./history.mjs";
60
+ import { resolveCommandContext } from "./context.mjs";
61
+ import { resolvePolicy } from "./policy.mjs";
59
62
 
60
63
  /**
61
64
  * Runs the `debt` command: computes the current candid facts, ages them across
@@ -240,3 +243,30 @@ export async function debtCommand(dir, commandContext, options = {}) {
240
243
  },
241
244
  };
242
245
  }
246
+
247
+ /**
248
+ * `debt` as the CLI drives it: the history directory resolved from the
249
+ * single positional argument, then the shared preamble — command context,
250
+ * then the boundary law — so `../../cli.mjs`'s driver only wires options,
251
+ * IO seams, and where output lands (`./README.md`). The engine this returns
252
+ * from is `debtCommand` above, unchanged.
253
+ *
254
+ * @param {{config: string|null, events: string|null, paths: string[]}} options
255
+ * This run's parsed flags; `paths[0]` is the history directory.
256
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
257
+ * seams a test injects, the same ones `check` takes.
258
+ * @returns {Promise<object>} `debtCommand`'s result, unmodified.
259
+ */
260
+ export async function debt(options, { cwd, readGraph, listFiles }) {
261
+ const dir = isAbsolute(options.paths[0]) ? options.paths[0] : resolve(cwd, options.paths[0]);
262
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
263
+ // The boundary law the ledger ages waivers against — resolved the same way
264
+ // `graph` and `diff` resolve it (`resolvePolicy`), so a `debt` run and a
265
+ // `check` run never disagree about the current suppressions, and a
266
+ // profile-selected workspace resolves the same way `check` does.
267
+ const { config } = await resolvePolicy(options, commandContext, cwd);
268
+ return debtCommand(dir, commandContext, {
269
+ config,
270
+ events: options.events,
271
+ });
272
+ }
@@ -46,9 +46,13 @@ import { computeDecisionFitness } from "../governance/decision-fitness.mjs";
46
46
  import { stripAdrPrefix, stripRuleFitnessPrefix } from "../governance/adr-registry.mjs";
47
47
  import { intentRows, configRows, rowLabel } from "./provenance-command.mjs";
48
48
  import { evaluateFitness, fitnessSnapshot } from "../governance/fitness-registry.mjs";
49
+ import { resolveCommandContext } from "./context.mjs";
50
+ import { driftForCheck } from "./drift.mjs";
49
51
  import { hasTag, isComboDepConstraint } from "../rules/tags.mjs";
50
52
  import { resolveMembers } from "../architecture-intent/selectors.mjs";
53
+ import { loadIntentIfTracked } from "../architecture-intent/model.mjs";
51
54
  import { evaluate } from "../rules/index.mjs";
55
+ import { resolvePolicy } from "./policy.mjs";
52
56
 
53
57
  /**
54
58
  * The projects whose tags satisfy a constraint row's source selector — the
@@ -119,12 +123,33 @@ function intentRowGoverns(intent, graph, row) {
119
123
  *
120
124
  * @param {object} commandContext From `resolveCommandContext`.
121
125
  * @param {object} config The resolved boundary law.
122
- * @param {object|null} intent The normalized intent model, or null when the
123
- * workspace declares none.
124
- * @returns {object[]} `[{name, verdict}, ...]`.
126
+ * @returns {Promise<object[]>} `[{name, verdict}, ...]`.
125
127
  */
126
- function fitnessVerdictsFor(commandContext, config, intent) {
128
+ async function fitnessVerdictsFor(commandContext, config) {
127
129
  if (!Array.isArray(config?.fitness) || config.fitness.length === 0) return [];
130
+ // A `drift-free` gate judges the SAME verdict-shaped intent the `fitness`
131
+ // command feeds its registry — `driftForCheck`'s, not the raw normalized
132
+ // model the walk's intent rows are built from: that model carries no
133
+ // `.verdict`, and a gate dispatched on it read as a fail over a clean tree
134
+ // (#737). The construction mirrors `./fitness.mjs`'s `fitnessCommand` —
135
+ // the same `drift.intent === undefined` → `null` resolution — so the two
136
+ // faces derive identical rows from identical facts.
137
+ const drift = await driftForCheck(commandContext);
138
+ const intent =
139
+ drift.intent === undefined
140
+ ? null
141
+ : {
142
+ verdict:
143
+ drift.findings.length > 0
144
+ ? "findings"
145
+ : drift.unresolved.length > 0
146
+ ? "no-verdict"
147
+ : "ok",
148
+ boundaries: drift.boundaries,
149
+ findings: drift.findings,
150
+ unresolved: drift.unresolved,
151
+ notes: drift.notes,
152
+ };
128
153
  const snapshot = fitnessSnapshot(commandContext, {
129
154
  intent,
130
155
  suppressions: config.suppressions ?? [],
@@ -145,12 +170,14 @@ function fitnessVerdictsFor(commandContext, config, intent) {
145
170
  * @param {{intent?: object|null, fitnessVerdicts?: object[]}} [io]
146
171
  * `intent` is the normalized intent model (or null); `fitnessVerdicts`
147
172
  * overrides the run's own evaluation — a test supplies a fixed list.
148
- * @returns {{status: "ok"|"no-verdict", result: object, coverage: object,
149
- * report: {text: string, json: string}}}
150
- * @throws {Error} on an unreadable registry, a malformed law, or a config
151
- * declaring fitness that fails to evaluate exit-3 class.
173
+ * @returns {Promise<{status: "ok"|"no-verdict", result: object, coverage: object,
174
+ * report: {text: string, json: string}}>}
175
+ * @throws {Error} on an unreadable registry, a malformed law, a
176
+ * `driftForCheck` refusal on the fitness leg's intent (an unreadable or
177
+ * invalid `architecture-intent.json`, an unregistered-plugin graph), or a
178
+ * config declaring fitness that fails to evaluate — exit-3 class.
152
179
  */
153
- export function decisionsCommand(decisionId, commandContext, config, io = {}) {
180
+ export async function decisionsCommand(decisionId, commandContext, config, io = {}) {
154
181
  const intent = io.intent ?? null;
155
182
 
156
183
  // The registry read — throws on an unreadable `docs/adr/`, which the caller
@@ -225,7 +252,7 @@ export function decisionsCommand(decisionId, commandContext, config, io = {}) {
225
252
  // the `{name, verdict}` list from the declared fitness gates above (or the
226
253
  // caller's override). `computeDecisionFitness`'s second argument carries
227
254
  // verdicts but is unused; the lookup is the single door, so it is null.
228
- const verdicts = io.fitnessVerdicts ?? fitnessVerdictsFor(commandContext, config, intent);
255
+ const verdicts = io.fitnessVerdicts ?? (await fitnessVerdictsFor(commandContext, config));
229
256
  const verdictByName = new Map(verdicts.map((v) => [v.name, v]));
230
257
  const fitnessLookup = (bindingId) => verdictByName.get(stripRuleFitnessPrefix(bindingId));
231
258
  const fitnessById = new Map(
@@ -289,3 +316,25 @@ export function decisionsCommand(decisionId, commandContext, config, io = {}) {
289
316
  },
290
317
  };
291
318
  }
319
+
320
+ /**
321
+ * `decisions` as the CLI drives it: the shared preamble — command context,
322
+ * the boundary law, the tracked intent — resolved here so `../../cli.mjs`'s
323
+ * driver only wires options, IO seams, and where output lands
324
+ * (`./README.md`). The engine this returns from is `decisionsCommand`
325
+ * above, unchanged.
326
+ *
327
+ * @param {{config: string|null, paths: string[]}} options This run's parsed
328
+ * flags; `paths[0]` is the ADR id.
329
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
330
+ * seams a test injects, the same ones `check` takes.
331
+ * @returns {Promise<object>} `decisionsCommand`'s result, unmodified.
332
+ */
333
+ export async function decisions(options, { cwd, readGraph, listFiles }) {
334
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
335
+ // ONE law for the chain, resolved exactly like `report` — the Fitness leg
336
+ // reads this law's declared gates, so a `--config` override must reach it.
337
+ const { config } = await resolvePolicy(options, commandContext, cwd);
338
+ const intent = await loadIntentIfTracked(commandContext.root, commandContext.tracked);
339
+ return decisionsCommand(options.paths[0], commandContext, config, { intent });
340
+ }
@@ -274,7 +274,9 @@ export function buildEvidenceSnapshot({
274
274
  * order is the only order the format keeps, and `buildEvidenceSnapshot` sorts
275
275
  * every array whose source does not guarantee it. Two captures over one
276
276
  * unchanged tree produce byte-identical files, which is what makes a plain
277
- * `diff` of two baselines meaningful.
277
+ * `diff` of two baselines meaningful. That determinism is also why this
278
+ * family carries no identity field, by decision: the bytes are the identity
279
+ * (`../../../../docs/adr/0008-snapshot-identity-per-family.md`).
278
280
  *
279
281
  * @param {object} snapshot From `buildEvidenceSnapshot`.
280
282
  * @returns {string} The JSON text, newline-terminated.
@@ -330,7 +332,11 @@ export function readEvidenceSnapshot(path, io = {}) {
330
332
  * What is deliberately NOT a refusal: dirty base provenance. A baseline from
331
333
  * an uncommitted tree is weaker evidence, not unreadable evidence — the parsed
332
334
  * snapshot exposes `provenance.dirty` so the renderer can say so loudly, and
333
- * classification itself proceeds.
335
+ * classification itself proceeds. A dirty BIT that is not a boolean is a
336
+ * different thing — malformed, not weaker: the gates read `dirty === true`
337
+ * while the renderers interpolate truthiness, so any other type would read
338
+ * clean to some consumers and dirty to others. The provenance block below
339
+ * refuses it by name.
334
340
  *
335
341
  * @param {string} text The file contents.
336
342
  * @param {string} path The path the text came from, for error messages.
@@ -392,8 +398,23 @@ export function parseEvidenceSnapshot(text, path) {
392
398
 
393
399
  if (parsed.provenance !== null && !isPlainObject(parsed.provenance)) {
394
400
  problems.push("provenance: must be an object ({commit, remote, dirty}) or null");
395
- } else if (isPlainObject(parsed.provenance) && typeof parsed.provenance.commit !== "string") {
396
- problems.push("provenance.commit: must be a string when provenance is present");
401
+ } else if (isPlainObject(parsed.provenance)) {
402
+ if (typeof parsed.provenance.commit !== "string") {
403
+ problems.push("provenance.commit: must be a string when provenance is present");
404
+ }
405
+ if (parsed.provenance.remote !== null && typeof parsed.provenance.remote !== "string") {
406
+ problems.push("provenance.remote: must be a string or null when provenance is present");
407
+ }
408
+ if (typeof parsed.provenance.dirty !== "boolean") {
409
+ // Malformed, not weaker: the gates read `dirty === true` while the
410
+ // renderers interpolate truthiness, so a value of any other type reads
411
+ // clean to some consumers and dirty to others — the one disagreement
412
+ // this record must never carry.
413
+ problems.push(
414
+ "provenance.dirty: must be a boolean when provenance is present — the gates decide " +
415
+ "between committed and uncommitted evidence on this bit",
416
+ );
417
+ }
397
418
  }
398
419
 
399
420
  if (typeof parsed.policyFingerprint !== "string" || parsed.policyFingerprint === "") {