@ecoma-io/archkeep 0.16.0 → 0.17.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.
package/README.md CHANGED
@@ -225,7 +225,7 @@ Ten minutes end to end, most of it spent deciding what your tags mean:
225
225
  Ten minutes end to end, most of it spent deciding what your tags mean:
226
226
  [**Getting started →**](https://github.com/ecoma-io/archkeep/blob/main/docs/getting-started/installation.md). `graph`, `diff`,
227
227
  `history`, `trajectory`, `evolution`, `drift`, `impact`, `explain`,
228
- `context` and the rest of the 22-command surface are in the
228
+ `context` and the rest of the 23-command surface are in the
229
229
  [CLI reference](https://github.com/ecoma-io/archkeep/blob/main/docs/reference/cli.md).
230
230
 
231
231
  ## Documentation map
package/cli.mjs CHANGED
@@ -107,6 +107,7 @@ import {
107
107
  import { contextCommand } from "./src/commands/context-command.mjs";
108
108
  import { planContextCommand } from "./src/commands/plan-context-command.mjs";
109
109
  import { adrCommand } from "./src/commands/adr.mjs";
110
+ import { decisionsCommand } from "./src/commands/decisions.mjs";
110
111
  import { diffCommand } from "./src/commands/diff.mjs";
111
112
  import { captureDelta, deltaCommand } from "./src/commands/delta.mjs";
112
113
  import { discoverCommand } from "./src/commands/discover.mjs";
@@ -1712,6 +1713,73 @@ async function runAdr(options, { cwd, env }) {
1712
1713
  // Descriptive: 0 for answered, 3 for incomplete coverage.
1713
1714
  return result.status === "ok" ? EXIT.ok : EXIT.error;
1714
1715
  }
1716
+ /**
1717
+ * `decisions`'s run: the deterministic chain behind one recorded decision —
1718
+ * decision → governed rows → projects → current findings, with the
1719
+ * per-decision verification level. Exactly one positional: the ADR id.
1720
+ *
1721
+ * The law is resolved the way `report` resolves it (`resolvePolicy`) because
1722
+ * the chain's Fitness leg reads the workspace's declared gates, and `--config`
1723
+ * wins the same way. Fitness verdicts are derived inside the command from the
1724
+ * declared list; a declared gate that fails to evaluate THROWS (exit 3), it
1725
+ * never silently walks clean. `0` when every hop of the chain resolved, `3`
1726
+ * when any did not — never `1`.
1727
+ *
1728
+ * @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
1729
+ * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
1730
+ * @returns {Promise<number>}
1731
+ */
1732
+ async function runDecisions(options, { cwd, env }) {
1733
+ if (options.paths.length !== 1) {
1734
+ env.err(
1735
+ `archkeep: decisions takes exactly one positional argument (an ADR id); ` +
1736
+ `got ${options.paths.length}`,
1737
+ );
1738
+ return EXIT.usage;
1739
+ }
1740
+
1741
+ let result;
1742
+ try {
1743
+ const commandContext = resolveCommandContext(
1744
+ { cwd },
1745
+ { readGraph: env.readGraph, listFiles: env.listFiles },
1746
+ );
1747
+
1748
+ // ONE law for the chain, resolved exactly like `report` — the Fitness leg
1749
+ // reads this law's declared gates, so a `--config` override must reach it.
1750
+ const { config } = await resolvePolicy(options, commandContext, cwd);
1751
+
1752
+ const intent = commandContext.tracked.includes(INTENT_FILE)
1753
+ ? await loadIntent(commandContext.root, { tracked: commandContext.tracked })
1754
+ : null;
1755
+
1756
+ result = decisionsCommand(options.paths[0], commandContext, config, { intent });
1757
+ } catch (error) {
1758
+ const usageError = error instanceof UsageError;
1759
+ env.err(String(error?.message ?? error));
1760
+ return usageError ? EXIT.usage : EXIT.error;
1761
+ }
1762
+
1763
+ const report = options.format === "json" ? result.report.json : result.report.text;
1764
+
1765
+ if (options.output) {
1766
+ // Atomic, symlink-safe write — `writeOutputReport`'s own docstring owns
1767
+ // the mechanism and the threat it closes.
1768
+ const reportText = report.endsWith("\n") ? report : `${report}\n`;
1769
+ if (!writeOutputReport(options.output, reportText, env, cwd, options.config)) return EXIT.error;
1770
+ // The confirmation names the no-verdict case, so a reader who only
1771
+ // glances at stderr cannot mistake a written chain for a resolved one.
1772
+ env.err(
1773
+ `archkeep: decision chain for ${options.paths[0]} ` +
1774
+ `${result.status === "ok" ? "resolved" : "did NOT fully resolve"} → ${options.output}`,
1775
+ );
1776
+ } else {
1777
+ env.out(report);
1778
+ }
1779
+
1780
+ // Descriptive: 0 when every hop resolved, 3 when any could not. Never 1.
1781
+ return result.status === "ok" ? EXIT.ok : EXIT.error;
1782
+ }
1715
1783
 
1716
1784
  /**
1717
1785
  * `rules`'s `run`: dispatches to the appropriate subcommand (list/info/verify/add),
@@ -2985,6 +3053,42 @@ const EXPLAIN_FLAG_HELP = Object.freeze([
2985
3053
  }),
2986
3054
  ]);
2987
3055
 
3056
+ /**
3057
+ * `decisions`'s flags: text or JSON envelope, optional file output, and the
3058
+ * same `--config` override `report` takes — the chain's Fitness leg reads the
3059
+ * declared gates of one boundary law, and a caller must be able to say which.
3060
+ *
3061
+ * @type {readonly FlagHelp[]}
3062
+ */
3063
+ const DECISIONS_FLAG_HELP = Object.freeze([
3064
+ Object.freeze({
3065
+ flag: "--format",
3066
+ key: "format",
3067
+ arg: "text|json",
3068
+ describe: Object.freeze([
3069
+ "Terminal report (default) or the versioned JSON envelope",
3070
+ "docs/reference/json-output.md documents",
3071
+ ]),
3072
+ }),
3073
+ Object.freeze({
3074
+ flag: "--output",
3075
+ key: "output",
3076
+ arg: "<file>",
3077
+ describe: Object.freeze(["Write the report to a file instead of stdout"]),
3078
+ }),
3079
+ Object.freeze({
3080
+ flag: "--config",
3081
+ key: "config",
3082
+ arg: "<file>",
3083
+ describe: ({ boundaryConfig, inline }) =>
3084
+ Object.freeze([
3085
+ "Read the boundary law from here instead of",
3086
+ inline
3087
+ ? "the inline boundaryConfig in archkeep.json"
3088
+ : `<workspace root>/${boundaryConfig}`,
3089
+ ]),
3090
+ }),
3091
+ ]);
2988
3092
  /**
2989
3093
  * `context`'s flags: text or JSON envelope, optional file output.
2990
3094
  * The project name is a positional argument. `--config` overrides the boundary
@@ -3317,6 +3421,17 @@ const COMMANDS = Object.freeze({
3317
3421
  formats: DESCRIBABLE_FORMATS,
3318
3422
  run: runProvenance,
3319
3423
  }),
3424
+ decisions: Object.freeze({
3425
+ name: "decisions",
3426
+ args: "<id>",
3427
+ summary:
3428
+ "Walk the full chain behind one recorded decision — decision to bound rows, projects, findings, and its verification level",
3429
+ flagHelp: DECISIONS_FLAG_HELP,
3430
+ flags: Object.freeze(Object.fromEntries(DECISIONS_FLAG_HELP.map((f) => [f.flag, f.key]))),
3431
+ defaults: Object.freeze({ format: "text", output: null, config: null }),
3432
+ formats: DESCRIBABLE_FORMATS,
3433
+ run: runDecisions,
3434
+ }),
3320
3435
  adr: Object.freeze({
3321
3436
  name: "adr",
3322
3437
  args: "[<id>]",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Architecture enforcement for polyglot repositories — dependency graphs and module boundaries for Go, Rust, Python, TypeScript, JavaScript, Vue, Java and Kotlin",
5
5
  "keywords": [
6
6
  "architecture",
@@ -70,6 +70,11 @@
70
70
  * same class of limit Kotlin's backtick segments pin.
71
71
  * - An **unterminated directive** (no `;` before end of line) is not a
72
72
  * complete directive and is not read.
73
+ * - A **statement whose initializer opens but never closes** (`using var x =
74
+ * new F {`, the `;` never arriving) is neither read nor flagged — the `{`
75
+ * behind the resource's `=` is the statement's own (#469), the same
76
+ * silence a `using (r) {` block has always held. The malformation scan
77
+ * judges directives only.
73
78
  */
74
79
  import { csharpNamespaceIndex } from "./dotnet/namespaces.mjs";
75
80
  import { maskCSharpComments } from "./dotnet/mask.mjs";
@@ -130,6 +135,13 @@ const CS_EXTERN_ALIAS = new RegExp(
130
135
  /** Exactly one identifier followed by `=`: the alias form. */
131
136
  const ALIAS_FORM = new RegExp(String.raw`^(${SEG})[ \t]*=[ \t]*(.+)$`, "su");
132
137
 
138
+ /**
139
+ * One bare identifier, optionally verbatim (`@class`): an alias's own name.
140
+ * The malformation scan reads it backwards — the text before an initializer
141
+ * `=` is an alias's name only when it is NOT this shape.
142
+ */
143
+ const ALIAS_NAME = new RegExp(String.raw`^@?${SEG}$`, "u");
144
+
133
145
  /** A dotted name, optionally behind the `global::` qualifier: the plain form. */
134
146
  const PLAIN_FORM = new RegExp(String.raw`^(?:global::)?(${DOTTED_NAME})$`, "u");
135
147
 
@@ -262,10 +274,17 @@ export function parseCSharpDirectiveSites(csharpText) {
262
274
  * from one the file truncates — a failed write, a merge marker left
263
275
  * mid-directive — which used to parse as zero directive sites with no
264
276
  * failure, byte-for-byte identical to a file that imports nothing (#419).
265
- * A `(` right after the
266
- * keyword is the using-STATEMENT family `using (var s = f()) { }`,
267
- * `using (x);`, and the `using var x = f();` declaration whose body starts
268
- * `var` never a directive; a statement's `;` may arrive inside its own
277
+ * Statement shapes hold no opinion at all, by two markers the text shows
278
+ * before any terminator arrives: a `(` right after the keyword is the
279
+ * parenthesized family `using (var s = f()) { … }`, `using (x);` and an
280
+ * `=` between the keyword and the walk's first `{` is the declaration family
281
+ * (#469) whenever MORE than one bare identifier precedes it — `var writer`,
282
+ * `Dictionary<string, int> map`, every declaration spelling — because a
283
+ * brace behind a resource's own `=` is that statement's initializer, while
284
+ * the first `{` behind a directive can only be the file's truncation. One
285
+ * bare identifier before the `=` is an ALIAS's own name (`using Pair = …`),
286
+ * the brace then belongs to whatever follows, and the truncated alias stays
287
+ * loud. A statement's `;` may arrive inside its own
269
288
  * block, so the scan has no opinion there. One silence the rule keeps: a
270
289
  * missing `;` that a LATER declaration supplies its own (`extern alias X`
271
290
  * then `namespace Shop.App;`) is not seen — the walk reads the next
@@ -324,11 +343,21 @@ export function csharpDirectiveMalformations(csharpText) {
324
343
  if (source[at] === "(") continue;
325
344
  const next = usingTerminatorAfter(at);
326
345
  if (next === undefined || next[0] === "{") {
327
- flag(
328
- m.index + m[0].indexOf("using"),
329
- "using",
330
- "a `using` directive never reaches its `;` — the file is truncated or malformed, so its imports cannot be read",
331
- );
346
+ // The declaration family's initializer `=` (#469) — argued beside the
347
+ // openers above: an `=` whose prefix is more than one bare identifier
348
+ // is a resource's own, and the brace with it. One bare identifier is
349
+ // an alias's own name, the brace belongs to whatever follows, and the
350
+ // truncated alias stays loud.
351
+ const eq = next !== undefined ? source.indexOf("=", at) : -1;
352
+ const initializer =
353
+ eq !== -1 && eq < next.index && !ALIAS_NAME.test(source.slice(at, eq).trim());
354
+ if (next === undefined || !initializer) {
355
+ flag(
356
+ m.index + m[0].indexOf("using"),
357
+ "using",
358
+ "a `using` directive never reaches its `;` — the file is truncated or malformed, so its imports cannot be read",
359
+ );
360
+ }
332
361
  }
333
362
  }
334
363
  const externTerminatorAfter = terminatorAfter();
@@ -44,6 +44,13 @@
44
44
  * quote closed on a later line, a `)` supplied from a function body below —
45
45
  * produce a record today and stay the documented parse limits above.
46
46
  *
47
+ * One misread answers neither record nor failure, named as a limit rather
48
+ * than fixed (#468's neighbourhood): a bare `import` whose line ends with
49
+ * no path and whose `(` arrives from the statement BELOW it — `import`
50
+ * alone, then `const (` opening the next — is read as that block, and the
51
+ * doubly-broken file stays silent. Rejecting it needs a same-line rule
52
+ * that would flag the legal `import` newline `"path"` continuation.
53
+ *
47
54
  * ## An unreadable go.mod refuses, it does not skip (#405)
48
55
  *
49
56
  * `parseGoModulePath` parses content; it never sees a failed read. Reading a
@@ -310,7 +317,14 @@ export function goImportMalformations(goText) {
310
317
  // close just found rather than rescanning the text before it.
311
318
  let lastClose = 0;
312
319
  let unclosedSince = -1;
313
- for (const m of source.matchAll(/(?:^|;)[ \t]*import/gm)) {
320
+ // `import` must be the keyword, never a prefix of an identifier (#468): a
321
+ // const member named `importPath` sits at line head exactly where the
322
+ // keyword would, and reading it as the keyword flagged a compiling file
323
+ // "an import states no path". Go lexes by maximal munch — letters, digits,
324
+ // and `_` continuing the word make it one identifier — so the lookahead
325
+ // excludes exactly those, and every legal follower (`(`, a quote, an
326
+ // alias, whitespace) still opens.
327
+ for (const m of source.matchAll(/(?:^|;)[ \t]*import(?![\p{L}\p{Nd}_])/gmu)) {
314
328
  let at = m.index + m[0].length;
315
329
  while (at < source.length && /\s/u.test(source[at])) at += 1;
316
330
  if (at >= source.length) {
@@ -55,6 +55,20 @@
55
55
  * `bindings` beside `knownFitness`, at exit 0. Naming a limit is not a
56
56
  * verdict; leaving it unnamed would be the silent direction
57
57
  * (`../../../../AGENTS.md`).
58
+ * ## What it can say about fitness
59
+ *
60
+ * Wave 2's fitness derivation (`../governance/decision-fitness.mjs`) folds a
61
+ * decision's bound constraints and their verdicts into one per-decision
62
+ * level. It is NOT wired into this command's own read: `adr` stays the
63
+ * registry-only surface it was. The caller may hand verdicts in through
64
+ * `io.fitnessVerdicts` (the same `{name, verdict}` shape `fitness` produces)
65
+ * and every record then renders its level — `verified` only when a bound
66
+ * constraint passes. Without verdicts the derivation still runs, and its
67
+ * honest answer is echoed: a decision with authority but nothing verifiable
68
+ * is `unverifiable` — never healthy — while a status without authority is
69
+ * `not_applicable`. An empty verdict set is not silence; it is the registry
70
+ * alone asserting nothing. Levels never change the exit code: `adr` remains
71
+ * 0/2/3, a description of what is recorded, not a gate.
58
72
  */
59
73
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
60
74
  import {
@@ -65,6 +79,8 @@ import {
65
79
  } from "../report/adr-text.mjs";
66
80
  import { ADR_DIR, stripAdrPrefix } from "../governance/adr-registry.mjs";
67
81
  import { adrsBinding, boundFitnessIds, loadAdrRegistry } from "../governance/adr-registry.mjs";
82
+ import { computeDecisionFitness } from "../governance/decision-fitness.mjs";
83
+ import { stripRuleFitnessPrefix } from "../governance/adr-registry.mjs";
68
84
 
69
85
  /**
70
86
  * The other half of the id name space the positional argument answers
@@ -124,8 +140,11 @@ export function readAdrContext(root, io = {}) {
124
140
  * @param {{id?: string}} options
125
141
  * @param {{loadAdrRegistryOverride?: typeof loadAdrRegistry, tracked?: string[],
126
142
  * lstatSync?: (path: string) => {isSymbolicLink: () => boolean},
127
- * realpathSync?: (path: string) => string}} [io] Forwarded to
128
- * `readAdrContext` unchanged.
143
+ * realpathSync?: (path: string) => string, fitnessVerdicts?:
144
+ * Array<{name: string, verdict: string}>}} [io] `tracked`, `lstatSync` and
145
+ * `realpathSync` are forwarded to `readAdrContext` unchanged; `fitnessVerdicts`
146
+ * feeds the per-decision fitness derivation ("What it can say about fitness"
147
+ * in the module header owns what an absent array means).
129
148
  * @returns {{status: "ok"|"no-verdict", result: object, coverage: object,
130
149
  * report: {text: string, json: string}}}
131
150
  * @throws {Error} on an unreadable registry (exit-3 class).
@@ -135,6 +154,21 @@ export function adrCommand(root, options, io = {}) {
135
154
 
136
155
  const { records, byId, knownFitness } = ctx;
137
156
 
157
+ // Per-decision fitness: the wave-2 derivation, fed a lookup built from
158
+ // whatever verdicts the caller can supply (`io.fitnessVerdicts`, the same
159
+ // `{name, verdict}` shape the `fitness` command emits). A binding's prefix
160
+ // (`rule:`/`fitness:`) is stripped before the lookup — a verdict names a
161
+ // declared fitness id, and `fitness:hotspot` and `hotspot` are the same id.
162
+ // An empty verdict set is a legitimate input: every authority decision then
163
+ // derives `unverifiable`, which is the registry alone asserting nothing —
164
+ // the module header's "What it can say about fitness" owns the wording.
165
+ const verdictByName = new Map((io.fitnessVerdicts ?? []).map((v) => [v.name, v]));
166
+ const fitnessLookup = (bindingId) => verdictByName.get(stripRuleFitnessPrefix(bindingId));
167
+ const fitnessById = new Map(
168
+ computeDecisionFitness(records, null, fitnessLookup).map((entry) => [entry.id, entry]),
169
+ );
170
+ const fitness = [...fitnessById.values()];
171
+
138
172
  // An id the caller asked about that the registry does not know is a named
139
173
  // unknown, not a clean result — the invariant. Two cases, told apart by the
140
174
  // id's shape: a `rule:x`/`fitness:x` ref (`FITNESS_REF_PATTERN`, above) is a
@@ -194,15 +228,22 @@ export function adrCommand(root, options, io = {}) {
194
228
  supersedes: records.flatMap((record) =>
195
229
  record.supersedes.map((ref) => ({ adr: record.id, supersedes: ref })),
196
230
  ),
231
+ // The derived reverse link — the records whose `supersedes` names this
232
+ // one — so a machine reader of the envelope sees the same lineage the
233
+ // text face shows.
234
+ supersededBy: records.flatMap((record) =>
235
+ record.supersededBy.map((id) => ({ adr: record.id, supersededBy: id })),
236
+ ),
237
+ fitness,
197
238
  unresolved,
198
239
  knownFitness: [...knownFitness].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
199
240
  };
200
241
 
201
242
  const text =
202
243
  requestedId === undefined
203
- ? formatAdrDump({ records, knownFitness })
244
+ ? formatAdrDump({ records, knownFitness, fitnessById })
204
245
  : byId.has(resolvedAdrId)
205
- ? formatAdrRecord(byId.get(resolvedAdrId), knownFitness)
246
+ ? formatAdrRecord(byId.get(resolvedAdrId), knownFitness, fitnessById)
206
247
  : isFitnessRef
207
248
  ? formatAdrReverse({ fitnessId: requestedId, adrIds: adrsBinding(records, requestedId) })
208
249
  : formatAdrMissing({ adrId: requestedId });
@@ -0,0 +1,291 @@
1
+ /**
2
+ * The `decisions` command: the deterministic chain behind one recorded
3
+ * architecture decision — decision → the governed rows that stand on it
4
+ * (intent + constraint + fitness) → the projects they govern → the current
5
+ * evidence/findings, and the decision's verification level.
6
+ *
7
+ * It composes the wave-2 modules without owning any of them:
8
+ *
9
+ * - `../governance/adr-registry.mjs` — the read of `docs/adr/` and the
10
+ * record index (`readAdrContext` below is this module's thin wrapper);
11
+ * - `../governance/decision-graph.mjs` — `forwardDecision`, the walk that
12
+ * attaches governed rows, projects and evidence, and reports every hop it
13
+ * cannot resolve in `walk.unresolved`;
14
+ * - `../governance/decision-fitness.mjs` — `computeDecisionFitness`, the
15
+ * per-decision verification level;
16
+ * - `./provenance-command.mjs` — `intentRows`/`configRows`, the same row
17
+ * walk `provenance` uses, so this command never holds a second copy of
18
+ * which rows exist.
19
+ *
20
+ * It is descriptive, like `adr`/`report`: it never exits 1. The chain is a
21
+ * description of what is recorded and whether it is currently satisfied, not
22
+ * a finding. What it DOES refuse, loudly (exit 3, never clean):
23
+ *
24
+ * - an unreadable registry — a read that throws propagates to the caller;
25
+ * - a reference that does not resolve — either the positional `<id>` naming
26
+ * no ADR record, or any hop of the walk (`walk.unresolved`), including a
27
+ * binding that names no governed row. A chain that could not walk every hop
28
+ * is rendered as an unresolved block, never as a clean chain (the
29
+ * invariant, `../../../../AGENTS.md`).
30
+ *
31
+ * ## What the chain's Fitness leg verifies
32
+ *
33
+ * A decision's `bindings` name rule/fitness ids. This command walks the
34
+ * workspace's declared `fitness` list (like `report` does) and evaluates
35
+ * those functions against the same snapshot the `fitness` command builds, so
36
+ * a binding that names a declared gate derives its real verdict. A binding
37
+ * naming no declared gate derives `unverifiable` — the registry alone asserts
38
+ * nothing, never a clean pass. The whole derivation is deterministic: the
39
+ * same tree, the same law, the same chain.
40
+ */
41
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
42
+ import { formatDecisionChain } from "../report/decisions-text.mjs";
43
+ import { readAdrContext } from "./adr.mjs";
44
+ import { forwardDecision } from "../governance/decision-graph.mjs";
45
+ import { computeDecisionFitness } from "../governance/decision-fitness.mjs";
46
+ import { stripAdrPrefix, stripRuleFitnessPrefix } from "../governance/adr-registry.mjs";
47
+ import { intentRows, configRows, rowLabel } from "./provenance-command.mjs";
48
+ import { evaluateFitness, fitnessSnapshot } from "../governance/fitness-registry.mjs";
49
+ import { hasTag, isComboDepConstraint } from "../rules/tags.mjs";
50
+ import { resolveMembers } from "../architecture-intent/selectors.mjs";
51
+ import { evaluate } from "../rules/index.mjs";
52
+
53
+ /**
54
+ * The projects whose tags satisfy a constraint row's source selector — the
55
+ * same match `findConstraintsFor` (`../rules/tags.mjs`) makes, so the row's
56
+ * `governs` never disagrees with the row a rule actually applies to.
57
+ *
58
+ * @param {object} row A `depConstraints` row.
59
+ * @param {object} graph The project graph.
60
+ * @returns {string[]}
61
+ */
62
+ function constraintGoverns(row, graph) {
63
+ return Object.entries(graph.nodes)
64
+ .filter(([, project]) =>
65
+ isComboDepConstraint(row)
66
+ ? row.allSourceTags.every((tag) => hasTag(project, tag))
67
+ : hasTag(project, row.sourceTag),
68
+ )
69
+ .map(([name]) => name)
70
+ .sort();
71
+ }
72
+
73
+ /**
74
+ * Resolves one intent side (`from`/`to`/`name`/`source`/`target`) to the
75
+ * projects it names: a declared boundary wins, else the side is an inline
76
+ * selector. Mirrors `sidePatterns` (`../architecture-intent/judge.mjs`).
77
+ *
78
+ * @param {object} intent The normalized intent model.
79
+ * @param {object} graph The project graph.
80
+ * @param {string} side A boundary name or inline selector.
81
+ * @returns {string[]}
82
+ */
83
+ function sideProjects(intent, graph, side) {
84
+ const declared = (intent.boundaries ?? []).find((b) => b.name === side);
85
+ const patterns = declared ? declared.match : [side];
86
+ return resolveMembers(patterns, graph.nodes);
87
+ }
88
+
89
+ /**
90
+ * The projects one intent row governs, by row family — the "who does this
91
+ * row bind" answer. A row with no resolvable side governs nothing, which is
92
+ * itself a fact the walk reports (a governed project list is never assumed).
93
+ *
94
+ * @param {object} intent The normalized intent model.
95
+ * @param {object} graph The project graph.
96
+ * @param {object} row An intent row.
97
+ * @returns {string[]}
98
+ */
99
+ function intentRowGoverns(intent, graph, row) {
100
+ if (typeof row.name === "string") return [row.name];
101
+ if (typeof row.from === "string") {
102
+ // `forbiddenTags` and the dependency rows claim the FROM side's edges;
103
+ // `allowed`/`forbidden` claim the boundary between both sides.
104
+ const from = sideProjects(intent, graph, row.from);
105
+ if (typeof row.to === "string") {
106
+ const to = sideProjects(intent, graph, row.to);
107
+ return Array.from(new Set([...from, ...to])).sort();
108
+ }
109
+ return from;
110
+ }
111
+ return [];
112
+ }
113
+
114
+ /**
115
+ * The verdicts of the workspace's declared fitness functions, folded into a
116
+ * `{name, verdict}` list — the same `{name, verdict}` shape `fitness` emits,
117
+ * so `computeDecisionFitness` can verify a decision's bound gates against
118
+ * this run's real judgements. Deterministic: same tree, same law, same list.
119
+ *
120
+ * @param {object} commandContext From `resolveCommandContext`.
121
+ * @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}, ...]`.
125
+ */
126
+ function fitnessVerdictsFor(commandContext, config, intent) {
127
+ if (!Array.isArray(config?.fitness) || config.fitness.length === 0) return [];
128
+ const snapshot = fitnessSnapshot(commandContext, {
129
+ intent,
130
+ suppressions: config.suppressions ?? [],
131
+ });
132
+ return evaluateFitness(config.fitness, snapshot).map((decision) => ({
133
+ name: decision.name,
134
+ verdict: decision.verdict,
135
+ }));
136
+ }
137
+
138
+ /**
139
+ * The verdict for one `decisions` run: the payload for both renderers, the
140
+ * status, and the coverage that decides exit 0 against 3.
141
+ *
142
+ * @param {string} decisionId The ADR id to walk (`NNN-slug` or `adr:NNN-slug`).
143
+ * @param {object} commandContext From `resolveCommandContext`.
144
+ * @param {object} config The resolved boundary law.
145
+ * @param {{intent?: object|null, fitnessVerdicts?: object[]}} [io]
146
+ * `intent` is the normalized intent model (or null); `fitnessVerdicts`
147
+ * 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.
152
+ */
153
+ export function decisionsCommand(decisionId, commandContext, config, io = {}) {
154
+ const intent = io.intent ?? null;
155
+
156
+ // The registry read — throws on an unreadable `docs/adr/`, which the caller
157
+ // maps to exit 3. Never a clean result.
158
+ const { records, byId, knownFitness } = readAdrContext(commandContext.root, {
159
+ tracked: commandContext.tracked,
160
+ });
161
+
162
+ // The governed rows the walk attaches: intent rows and config constraint
163
+ // rows, each carrying the governance block and the projects it governs.
164
+ // `governs` is derived deterministically from the graph, so the walk never
165
+ // has to re-resolve a selector.
166
+ const rowsFromIntent = intent === null ? [] : intentRows(intent);
167
+ /** @type {import("../governance/decision-graph.mjs").GovernedRow[]} */
168
+ const rows = [
169
+ ...rowsFromIntent.map(
170
+ ({ kind, row }) =>
171
+ /** @type {import("../governance/decision-graph.mjs").GovernedRow} */ ({
172
+ id: rowLabel(kind, row),
173
+ kind: "intent",
174
+ decisionRef: row.decisionRef,
175
+ governs: intentRowGoverns(intent, commandContext.graph, row),
176
+ }),
177
+ ),
178
+ ...configRows(config).map(
179
+ ({ kind, row }) =>
180
+ /** @type {import("../governance/decision-graph.mjs").GovernedRow} */ ({
181
+ id: rowLabel(kind, row),
182
+ kind: "constraint",
183
+ decisionRef: row.decisionRef,
184
+ governs: constraintGoverns(row, commandContext.graph),
185
+ }),
186
+ ),
187
+ // The declared fitness gates themselves, keyed by their name — the same
188
+ // id space `declaredFitnessNames`/`stripRuleFitnessPrefix` use, so a
189
+ // decision binding `fitness:cycle-free` resolves to the gate that exists.
190
+ ...(Array.isArray(config?.fitness) ? config.fitness : []).map(
191
+ (gate) =>
192
+ /** @type {import("../governance/decision-graph.mjs").GovernedRow} */ ({
193
+ id: gate.name,
194
+ kind: "fitness",
195
+ governs: resolveMembers(gate.match ?? ["*"], commandContext.graph.nodes),
196
+ }),
197
+ ),
198
+ ];
199
+
200
+ // The evidence leg: findings for the projects the rows govern, built from
201
+ // the same evaluation `check` runs. A path-scoped or graph-less context
202
+ // would leave this lookup undefined and `attachRowLeg` reports it as an
203
+ // unresolved walk — never a quiet "no findings" claim.
204
+ const violations = evaluate(commandContext.analysis.imports, commandContext.graph, config);
205
+ const findingsByProject = new Map();
206
+ for (const violation of violations) {
207
+ const list = findingsByProject.get(violation.sourceProject) ?? [];
208
+ list.push({
209
+ id: `${violation.sourceFile}:${violation.line}:${violation.column}`,
210
+ project: violation.sourceProject,
211
+ ruleId: violation.messageId,
212
+ });
213
+ findingsByProject.set(violation.sourceProject, list);
214
+ }
215
+
216
+ const walk = forwardDecision(decisionId, {
217
+ records,
218
+ byId,
219
+ knownFitness,
220
+ rows,
221
+ findingsByProject: (project) => findingsByProject.get(project) ?? [],
222
+ });
223
+
224
+ // The per-decision fitness derivation, folded with THIS run's verdicts —
225
+ // the `{name, verdict}` list from the declared fitness gates above (or the
226
+ // caller's override). `computeDecisionFitness`'s second argument carries
227
+ // verdicts but is unused; the lookup is the single door, so it is null.
228
+ const verdicts = io.fitnessVerdicts ?? fitnessVerdictsFor(commandContext, config, intent);
229
+ const verdictByName = new Map(verdicts.map((v) => [v.name, v]));
230
+ const fitnessLookup = (bindingId) => verdictByName.get(stripRuleFitnessPrefix(bindingId));
231
+ const fitnessById = new Map(
232
+ computeDecisionFitness(records, null, fitnessLookup).map((entry) => [entry.id, entry]),
233
+ );
234
+
235
+ const record = byId.get(stripAdrPrefix(decisionId)) ?? null;
236
+ const result = {
237
+ decisionId,
238
+ record,
239
+ walk,
240
+ fitness: record === null ? undefined : fitnessById.get(record.id),
241
+ knownFitness: [...knownFitness].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
242
+ };
243
+
244
+ const unresolved = walk.unresolved;
245
+ const status = walk.ok ? "ok" : "no-verdict";
246
+ const exitCode = status === "ok" ? 0 : 3;
247
+
248
+ const coverage = {
249
+ complete: walk.ok,
250
+ projects: Object.keys(commandContext.graph?.nodes ?? {}).length,
251
+ analyzedFiles: commandContext.analysis?.analyzed ?? 0,
252
+ imports: (commandContext.analysis?.imports ?? []).length,
253
+ notAnalyzed: unresolved.map(({ ref, kind: ukind, reason }) => ({
254
+ file: ukind === "decision" ? `${ref}.md` : ref,
255
+ reason,
256
+ })),
257
+ blindSpots: [],
258
+ notes: [],
259
+ };
260
+
261
+ const envelope = jsonEnvelope({
262
+ command: "decisions",
263
+ context: {
264
+ root: commandContext.root,
265
+ provider: commandContext.provider ?? "native",
266
+ marker: "docs/adr",
267
+ provenance: null,
268
+ },
269
+ status,
270
+ exitCode,
271
+ coverage,
272
+ result,
273
+ });
274
+
275
+ const text = formatDecisionChain({
276
+ decisionId,
277
+ record,
278
+ walk,
279
+ fitness: result.fitness,
280
+ });
281
+
282
+ return {
283
+ status,
284
+ result,
285
+ coverage,
286
+ report: {
287
+ text: `${text}\n`,
288
+ json: renderJson(envelope),
289
+ },
290
+ };
291
+ }