@ecoma-io/archkeep 0.16.1 → 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 +1 -1
- package/cli.mjs +115 -0
- package/package.json +1 -1
- package/src/commands/adr.mjs +45 -4
- package/src/commands/decisions.mjs +291 -0
- package/src/commands/explain.mjs +136 -0
- package/src/commands/provenance-command.mjs +86 -17
- package/src/commands/provenance.mjs +60 -0
- package/src/commands/report.mjs +48 -1
- package/src/governance/adr-registry.mjs +252 -15
- package/src/governance/decision-fitness.mjs +213 -0
- package/src/governance/decision-graph.mjs +483 -0
- package/src/governance/provenance-record.mjs +150 -0
- package/src/providers/native/model.mjs +18 -4
- package/src/report/adr-text.mjs +109 -4
- package/src/report/decisions-text.mjs +164 -0
- package/src/report/explain-text.mjs +77 -1
- package/src/report/provenance-text.mjs +67 -1
- package/src/report/report-text.mjs +53 -18
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
|
|
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.
|
|
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",
|
package/src/commands/adr.mjs
CHANGED
|
@@ -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
|
|
128
|
-
* `
|
|
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
|
+
}
|