@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
@@ -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
+ }
@@ -105,11 +105,15 @@ import {
105
105
  serializeEvidenceSnapshot,
106
106
  } from "./delta-snapshot.mjs";
107
107
  import { computeDiff } from "./diff.mjs";
108
+ import { isAbsolute, resolve } from "node:path";
108
109
  import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
109
110
  import { eventSnapshotSide } from "./history.mjs";
110
111
  import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
111
112
  import { resolveProvenance } from "./provenance.mjs";
113
+ import { resolveCommandContext } from "./context.mjs";
112
114
  import { compareSnapshotMetadata, dirtyBaselineNote, dirtyHeadNote } from "./snapshot-meta.mjs";
115
+ import { describe } from "../values.mjs";
116
+ import { resolvePolicy } from "./policy.mjs";
113
117
 
114
118
  const require = createRequire(import.meta.url);
115
119
  /** @type {{name: string, version: string}} */
@@ -382,11 +386,15 @@ export function sourceProjectAttributor(headGraph, baselineProjects) {
382
386
  * a custom-rule-only introduced finding never reaches the VIOLATION
383
387
  * predicate, so a classifications scan would read "accepted" on an exit-1
384
388
  * run — the silent direction;
385
- * - everything else — a clean comparable capture (`ok`, `[]` classifications),
386
- * an `ok` capture with a fact class (REPAIR, CHANGE, DRIFT,
387
- * DECISION_CHANGE — each accepted by the vocabulary `classification` earns),
388
- * or an `ok` capture holding a WAIVED violation (a waiver is a tracked
389
- * acceptance — which is exactly what kept the gate `ok`) ⇒ `accepted`.
389
+ * - `ok` status — a clean comparable capture (`[]` classifications), an `ok`
390
+ * capture with a fact class (REPAIR, CHANGE, DRIFT, DECISION_CHANGE — each
391
+ * accepted by the vocabulary `classification` earns), or an `ok` capture
392
+ * holding a WAIVED violation (a waiver is a tracked acceptance — which is
393
+ * exactly what kept the gate `ok`) ⇒ `accepted`;
394
+ * - any OTHER status — a typo, a case- or whitespace-mangled spelling, an
395
+ * absent field — THROWS naming the input, the input latch `verdictFor`
396
+ * holds. There is no fourth status to fold, and folding a stranger to a
397
+ * verdict would be the silent direction.
390
398
  *
391
399
  * The two refusals that can never reach this mapping — an unjudgeable head
392
400
  * and a provider mismatch — THROW before any event exists, so a delta that
@@ -395,12 +403,162 @@ export function sourceProjectAttributor(headGraph, baselineProjects) {
395
403
  *
396
404
  * @param {{status: "ok"|"findings"|"no-verdict"}} input
397
405
  * @returns {"accepted"|"rejected"|"no-verdict"}
406
+ * @throws {Error} On any `status` outside the three-verb contract, naming the
407
+ * input.
398
408
  */
399
409
  export function deltaDisposition({ status }) {
400
410
  // used by its own test
401
411
  if (status === "no-verdict") return "no-verdict";
402
412
  if (status === "findings") return "rejected";
403
- return "accepted";
413
+ if (status === "ok") return "accepted";
414
+ throw new Error(
415
+ `deltaDisposition: unknown delta status ${describe(status)} — expected "ok", "findings", ` +
416
+ `or "no-verdict". A status outside the delta verb's own contract has no disposition; ` +
417
+ `guessing one would be the silent direction. This is a bug in archkeep, not a fact about ` +
418
+ `the workspace.`,
419
+ );
420
+ }
421
+
422
+ /**
423
+ * The input latch for `deltaFold`: names the first counted bucket that is not
424
+ * an array, or `null` when every one is. A returned problem becomes the
425
+ * fold's refusal — never a throw, because the refusal is a fact about the run
426
+ * the command reports in-band, the same lane an `unknown` classification
427
+ * takes.
428
+ *
429
+ * Every bucket the COMMAND counts is validated, not only the ones the fold
430
+ * reads first: the custom-rule notes loop, the §1 mapping, `result.summary`
431
+ * and both report faces count the same buckets downstream of the fold, so a
432
+ * bucket that would crash or silently empty one of them is refused while
433
+ * there is still a verdict to withhold.
434
+ *
435
+ * @param {object} classification From `classifyDelta`.
436
+ * @param {{judged: object[], skipped: object[], removed: string[],
437
+ * findings: {introduced: object[], resolved: object[], unchanged: object[],
438
+ * unknown: object[]}}|null} custom
439
+ * @returns {string|null}
440
+ */
441
+ function deltaFoldInputProblem(classification, custom) {
442
+ const counted = [
443
+ ["classification.violations.introduced", classification?.violations?.introduced],
444
+ ["classification.violations.resolved", classification?.violations?.resolved],
445
+ ["classification.violations.unchanged", classification?.violations?.unchanged],
446
+ ["classification.violations.unknown", classification?.violations?.unknown],
447
+ ["classification.unresolvable.introduced", classification?.unresolvable?.introduced],
448
+ ["classification.unresolvable.resolved", classification?.unresolvable?.resolved],
449
+ ["classification.unresolvable.unchanged", classification?.unresolvable?.unchanged],
450
+ ["classification.unresolvable.unknown", classification?.unresolvable?.unknown],
451
+ ...(custom === null
452
+ ? []
453
+ : [
454
+ ["custom.judged", custom?.judged],
455
+ ["custom.skipped", custom?.skipped],
456
+ ["custom.removed", custom?.removed],
457
+ ["custom.findings.introduced", custom?.findings?.introduced],
458
+ ["custom.findings.resolved", custom?.findings?.resolved],
459
+ ["custom.findings.unchanged", custom?.findings?.unchanged],
460
+ ["custom.findings.unknown", custom?.findings?.unknown],
461
+ ]),
462
+ ];
463
+ for (const [name, value] of counted) {
464
+ if (!Array.isArray(value)) {
465
+ return `the delta fold counts "${name}", which is ${describe(value)} — a bucket that is not an array counts as empty in every lane, and an empty delta reads "no change". This is a bug in archkeep, not a fact about the workspace.`;
466
+ }
467
+ }
468
+ return null;
469
+ }
470
+
471
+ /**
472
+ * The `delta` exit fold, lifted out of `deltaCommand` as a pure function so
473
+ * the mapping from classification buckets to the verdict is a fact a test can
474
+ * pin (`./change.mjs`'s `reconcileDisposition` is the arrangement's
475
+ * precedent). The lane order is the one `deltaCommand`'s header states:
476
+ * introduced-and-not-waived (violations and custom findings alike) →
477
+ * `findings`/1; else any unknown item → `no-verdict`/3; else `ok`/0.
478
+ *
479
+ * The input latch runs first: a bucket the fold cannot read is refused as a
480
+ * no-verdict whose single reason names it (`refused`), never folded past as a
481
+ * silently-empty count. The literals stay hand-rolled per site by decision —
482
+ * the carrier folds are the pinned baseline INV-2 names, not a table to be
483
+ * converged (`docs/architecture/refactor/AUTHORITY-MAP.md`).
484
+ *
485
+ * Pure and exported for the fold's own tests; `deltaCommand` is its only
486
+ * production caller.
487
+ *
488
+ * @param {object} classification From `classifyDelta`.
489
+ * @param {{judged: object[], skipped: object[], removed: string[],
490
+ * findings: {introduced: object[], resolved: object[], unchanged: object[],
491
+ * unknown: object[]}}|null} custom
492
+ * @returns {{status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3,
493
+ * decision: object, introducedWaived: number, refused?: undefined}|
494
+ * {status: "no-verdict", exitCode: 3, decision: object, introducedWaived?: undefined,
495
+ * refused: string}}
496
+ * `introducedWaived` rides every judged lane because `result.summary`
497
+ * reports it whichever lane fired; a refusal carries `refused` and no
498
+ * counts, and is the one return whose `decision.reason` names a malformed
499
+ * input rather than a fact about the trees.
500
+ */
501
+ export function deltaFold(classification, custom) {
502
+ const problem = deltaFoldInputProblem(classification, custom);
503
+ if (problem !== null) {
504
+ return {
505
+ status: "no-verdict",
506
+ exitCode: 3,
507
+ decision: buildDecision({
508
+ status: "no-verdict",
509
+ coverageComplete: true,
510
+ findings: 0,
511
+ reason: problem,
512
+ }),
513
+ refused: problem,
514
+ };
515
+ }
516
+ const { violations, unresolvable } = classification;
517
+ const introducedWaived = violations.introduced.filter((entry) => entry.waived === true).length;
518
+ const introducedNotWaived = violations.introduced.length - introducedWaived;
519
+ // Custom findings have no waiver lane (`./delta-classify.mjs`'s
520
+ // `classifyCustomFindings` argues the by-construction absence), so every
521
+ // introduced one gates.
522
+ const customIntroduced = custom === null ? 0 : custom.findings.introduced.length;
523
+ const customUnknown = custom === null ? 0 : custom.findings.unknown.length;
524
+ const unknownCount = violations.unknown.length + unresolvable.unknown.length + customUnknown;
525
+
526
+ if (introducedNotWaived + customIntroduced > 0) {
527
+ return {
528
+ status: "findings",
529
+ exitCode: 1,
530
+ decision: buildDecision({
531
+ status: "findings",
532
+ coverageComplete: true,
533
+ findings: introducedNotWaived + customIntroduced,
534
+ }),
535
+ introducedWaived,
536
+ };
537
+ }
538
+ if (unknownCount > 0) {
539
+ return {
540
+ status: "no-verdict",
541
+ exitCode: 3,
542
+ decision: buildDecision({
543
+ status: "no-verdict",
544
+ coverageComplete: true,
545
+ findings: 0,
546
+ reason:
547
+ `${unknownCount} delta item${unknownCount === 1 ? "" : "s"} could not be classified — ` +
548
+ (customUnknown > 0
549
+ ? `${customUnknown} of them custom-rule item${customUnknown === 1 ? "" : "s"} — `
550
+ : "") +
551
+ `an item whose identity cannot be stated is never guessed into a bucket`,
552
+ }),
553
+ introducedWaived,
554
+ };
555
+ }
556
+ return {
557
+ status: "ok",
558
+ exitCode: 0,
559
+ decision: buildDecision({ status: "ok", coverageComplete: true, findings: 0 }),
560
+ introducedWaived,
561
+ };
404
562
  }
405
563
 
406
564
  /** First eight hex characters of a fingerprint, for prose that names one. */
@@ -449,8 +607,8 @@ const short = (fingerprint) =>
449
607
  * `loadIntent`; absent intent ⇒ no ids, an in-band note says so), and the
450
608
  * custom-rule host's two injectable seams, passed through to
451
609
  * `customRulesForDelta`.
452
- * @returns {Promise<{status: "ok"|"findings"|"no-verdict", delta?: object,
453
- * coverage: object,
610
+ * @returns {Promise<{status: "ok"|"findings"|"no-verdict",
611
+ * exitCode: 0|1|3, delta?: object, coverage: object,
454
612
  * eventWrite?: {id: string, duplicate: boolean}|null,
455
613
  * report: {text: string, json: string, sarif?: string}}>} `delta` carries
456
614
  * the additive `classifications`/`affected` fields (design §1); `eventWrite`
@@ -691,6 +849,48 @@ export async function deltaCommand(
691
849
  findings: { introduced: [], resolved: [], unchanged: [], unknown: [] },
692
850
  };
693
851
  }
852
+ // The exit fold, taken the moment both of its inputs are final: everything
853
+ // below — the custom-rule notes loop, the §1 mapping, `result.summary`, both
854
+ // report faces — counts the same buckets, and a malformed one must be
855
+ // refused HERE, in-band (no-verdict, exit 3, the bucket named), never
856
+ // counted past as a silently-empty bucket. The refusal withholds `delta`
857
+ // and the event exactly the coverage refusal does (#608): a comparison
858
+ // whose counts could not be read has no result to report.
859
+ const fold = deltaFold(classification, custom);
860
+ if (fold.refused !== undefined) {
861
+ const refusalCoverage = {
862
+ complete: true,
863
+ projects: Object.keys(graph.nodes).length,
864
+ analyzedFiles: analysis.analyzed,
865
+ imports: analysis.imports.length,
866
+ notAnalyzed: [],
867
+ blindSpots: blindSpotRows(analysis.failures),
868
+ notes: [fold.refused],
869
+ };
870
+ return {
871
+ status: fold.status,
872
+ exitCode: fold.exitCode,
873
+ coverage: refusalCoverage,
874
+ report: {
875
+ text: `delta: no verdict — ${fold.refused}\n`,
876
+ json: renderJson(
877
+ jsonEnvelope({
878
+ command: "delta",
879
+ context: { root, provider, marker, provenance: headProvenance },
880
+ status: fold.status,
881
+ exitCode: fold.exitCode,
882
+ coverage: refusalCoverage,
883
+ // The refusal-withheld payload, stated the way `coverageRefusal`
884
+ // states it: `jsonEnvelope` requires the key, the refusal has no
885
+ // result to report.
886
+ result: undefined,
887
+ decision: fold.decision,
888
+ }),
889
+ ),
890
+ },
891
+ };
892
+ }
893
+
694
894
  if (custom !== null) {
695
895
  for (const skipped of custom.skipped) {
696
896
  notes.push(`custom rule "${skipped.name}" was not classified — ${skipped.reason}`);
@@ -737,48 +937,10 @@ export async function deltaCommand(
737
937
  codeDrift,
738
938
  });
739
939
 
940
+ // The verdict, from the one fold — `deltaFold` above owns the lane order
941
+ // and the input latch; this destructure is the command's only hand in it.
740
942
  const { violations, unresolvable } = classification;
741
- const introducedWaived = violations.introduced.filter((entry) => entry.waived === true).length;
742
- const introducedNotWaived = violations.introduced.length - introducedWaived;
743
- // Custom findings have no waiver lane (`./delta-classify.mjs`'s
744
- // `classifyCustomFindings` argues the by-construction absence), so every
745
- // introduced one gates.
746
- const customIntroduced = custom === null ? 0 : custom.findings.introduced.length;
747
- const customUnknown = custom === null ? 0 : custom.findings.unknown.length;
748
- const unknownCount = violations.unknown.length + unresolvable.unknown.length + customUnknown;
749
-
750
- /** @type {"ok"|"findings"|"no-verdict"} */
751
- let status;
752
- /** @type {0|1|3} */
753
- let exitCode;
754
- let decision;
755
- if (introducedNotWaived + customIntroduced > 0) {
756
- status = "findings";
757
- exitCode = 1;
758
- decision = buildDecision({
759
- status,
760
- coverageComplete: true,
761
- findings: introducedNotWaived + customIntroduced,
762
- });
763
- } else if (unknownCount > 0) {
764
- status = "no-verdict";
765
- exitCode = 3;
766
- decision = buildDecision({
767
- status,
768
- coverageComplete: true,
769
- findings: 0,
770
- reason:
771
- `${unknownCount} delta item${unknownCount === 1 ? "" : "s"} could not be classified — ` +
772
- (customUnknown > 0
773
- ? `${customUnknown} of them custom-rule item${customUnknown === 1 ? "" : "s"} — `
774
- : "") +
775
- `an item whose identity cannot be stated is never guessed into a bucket`,
776
- });
777
- } else {
778
- status = "ok";
779
- exitCode = 0;
780
- decision = buildDecision({ status, coverageComplete: true, findings: 0 });
781
- }
943
+ const { status, exitCode, decision, introducedWaived } = fold;
782
944
 
783
945
  const coverage = {
784
946
  complete: true,
@@ -948,6 +1110,7 @@ export async function deltaCommand(
948
1110
 
949
1111
  return {
950
1112
  status,
1113
+ exitCode,
951
1114
  eventWrite,
952
1115
  delta: result,
953
1116
  coverage,
@@ -961,3 +1124,54 @@ export async function deltaCommand(
961
1124
  },
962
1125
  };
963
1126
  }
1127
+
1128
+ /**
1129
+ * `delta --capture` as the CLI drives it: the shared preamble — command
1130
+ * context, then the boundary law — resolved here, so `../../cli.mjs`'s
1131
+ * driver only wires where the evidence text lands. The engine this returns
1132
+ * from is `captureDelta` above, unchanged; the driver owns the write door.
1133
+ *
1134
+ * @param {{config: string|null}} options This run's parsed flags.
1135
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
1136
+ * seams a test injects, the same ones `check` takes.
1137
+ * @returns {Promise<object>} `captureDelta`'s result (`snapshot`, `text`),
1138
+ * unmodified.
1139
+ */
1140
+ export async function captureBaseline(options, { cwd, readGraph, listFiles }) {
1141
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
1142
+ // The snapshot's policy fingerprint needs the boundary config — the
1143
+ // workspace's own `boundaryConfig` (profile-aware the same way `check` is,
1144
+ // `resolvePolicy`), so the stored fingerprint agrees with a standalone
1145
+ // `graph` fingerprint over the same workspace, kept in one place so a
1146
+ // capture and a `graph` never disagree about the current policy.
1147
+ const { config } = await resolvePolicy(options, commandContext, cwd);
1148
+ return captureDelta(commandContext, { config });
1149
+ }
1150
+
1151
+ /**
1152
+ * `delta` (compare) as the CLI drives it: the baseline path resolved from
1153
+ * the single positional argument, then the shared preamble — command
1154
+ * context, boundary law — so `../../cli.mjs`'s driver only wires options,
1155
+ * IO seams, and where output lands. The engine this returns from is
1156
+ * `deltaCommand` above, unchanged.
1157
+ *
1158
+ * @param {{config: string|null, eventOut: string|null, paths: string[]}} options
1159
+ * This run's parsed flags; `paths[0]` is the baseline file.
1160
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
1161
+ * seams a test injects, the same ones `check` takes.
1162
+ * @returns {Promise<object>} `deltaCommand`'s result, unmodified.
1163
+ */
1164
+ export async function delta(options, { cwd, readGraph, listFiles }) {
1165
+ const baselinePath = isAbsolute(options.paths[0])
1166
+ ? resolve(options.paths[0])
1167
+ : resolve(cwd, options.paths[0]);
1168
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
1169
+ // Same loading logic as `check` (`resolvePolicy`) — a `--config`
1170
+ // overrides the workspace's own `boundaryConfig`, profile-aware the same
1171
+ // way `check` is.
1172
+ const { config } = await resolvePolicy(options, commandContext, cwd);
1173
+ return deltaCommand(baselinePath, commandContext, {
1174
+ config,
1175
+ eventOut: options.eventOut,
1176
+ });
1177
+ }