@ecoma-io/archkeep 0.16.1 → 0.18.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 +258 -20
- package/package.json +2 -2
- package/src/architecture-intent/judge.mjs +19 -6
- package/src/commands/adr.mjs +45 -4
- package/src/commands/change-intent.mjs +55 -8
- package/src/commands/change.mjs +332 -11
- package/src/commands/debt.mjs +26 -5
- package/src/commands/decisions.mjs +291 -0
- package/src/commands/delta-classify.mjs +257 -0
- package/src/commands/delta.mjs +269 -8
- package/src/commands/evolution.mjs +758 -5
- package/src/commands/explain.mjs +207 -1
- package/src/commands/history.mjs +81 -5
- package/src/commands/plan-context-command.mjs +163 -2
- package/src/commands/provenance-command.mjs +86 -17
- package/src/commands/provenance.mjs +60 -0
- package/src/commands/report.mjs +48 -1
- package/src/commands/trajectory.mjs +89 -3
- package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
- package/src/governance/adr-registry.mjs +252 -15
- package/src/governance/debt-ledger.mjs +261 -19
- package/src/governance/decision-fitness.mjs +213 -0
- package/src/governance/decision-graph.mjs +483 -0
- package/src/governance/decision-lineage.mjs +250 -0
- package/src/governance/evolution-event.mjs +470 -0
- package/src/governance/evolution-store.mjs +362 -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/change-text.mjs +21 -3
- package/src/report/debt-text.mjs +42 -6
- package/src/report/decisions-text.mjs +164 -0
- package/src/report/delta-text.mjs +36 -1
- package/src/report/evolution-text.mjs +231 -2
- package/src/report/explain-text.mjs +122 -1
- package/src/report/history-text.mjs +9 -3
- package/src/report/plan-context-text.mjs +94 -0
- package/src/report/provenance-text.mjs +67 -1
- package/src/report/report-text.mjs +53 -18
- package/src/report/snapshot-text.mjs +35 -1
- package/src/report/trajectory-text.mjs +30 -1
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision governance graph: a pure, descriptive, bidirectional walk over
|
|
3
|
+
* decisions (the ADR registry's records), the intent/constraint/fitness rows
|
|
4
|
+
* they govern, the projects those rows govern, and the projects' current
|
|
5
|
+
* findings.
|
|
6
|
+
*
|
|
7
|
+
* It answers the two directions of one question — "what does this decision
|
|
8
|
+
* make enforceable, and what does that enforcement currently see?" and "what
|
|
9
|
+
* decision governs this row/project/finding, and why was it made?" — as one
|
|
10
|
+
* pure data shape: `{ok, nodes, edges, unresolved}`.
|
|
11
|
+
*
|
|
12
|
+
* ## Descriptive by contract (Wave 2 scope)
|
|
13
|
+
*
|
|
14
|
+
* This module NEVER gates. It decides nothing about whether a finding IS one
|
|
15
|
+
* (the rule that produced it owns that, `../report/evidence.mjs`'), never
|
|
16
|
+
* changes `check`'s exit code, and never turns green or red on its own. It
|
|
17
|
+
* reports the graph the registry and the caller's row/finding facts describe
|
|
18
|
+
* — or, where a reference cannot resolve, names the gap. The `adr`/`report`
|
|
19
|
+
* surface of the wave renders the walk; nothing here is a verdict.
|
|
20
|
+
*
|
|
21
|
+
* ## The invariant
|
|
22
|
+
*
|
|
23
|
+
* The repository's governing rule (`../../../../AGENTS.md`): an empty result
|
|
24
|
+
* must mean "no violation", and nothing else. For a walk that means an
|
|
25
|
+
* `unresolved` list is the only honest answer to a reference that cannot
|
|
26
|
+
* resolve:
|
|
27
|
+
*
|
|
28
|
+
* - an unknown decision id, a `binding` that names no governed row, a row
|
|
29
|
+
* citing a decision that does not exist, a missing findings lookup, a row
|
|
30
|
+
* or finding with no governing decision — every one lands in `unresolved`
|
|
31
|
+
* with its `ref`, a `kind`, and a reason, and `ok` is false. A walk never
|
|
32
|
+
* returns an empty-looking `nodes: []` over a reference it could not
|
|
33
|
+
* resolve.
|
|
34
|
+
* - a reference that DID resolve and genuinely has nothing attached — a
|
|
35
|
+
* decision that binds no rule and is cited by no row — is a true fact and
|
|
36
|
+
* stays `ok: true`: the walk looked, and what it found is that nothing is
|
|
37
|
+
* bound.
|
|
38
|
+
*
|
|
39
|
+
* ## Determinism
|
|
40
|
+
*
|
|
41
|
+
* No wall-clock time, no randomness, no environment. Iteration follows the
|
|
42
|
+
* caller's data orders — `records` in registry (byte-sorted filename) order,
|
|
43
|
+
* `rows` in the order given, each record's `bindings`/`supersedes` in the
|
|
44
|
+
* order the record declares — and nodes/edges/unresolved are emitted in
|
|
45
|
+
* first-discovery order, so two runs over the same context produce
|
|
46
|
+
* byte-identical output.
|
|
47
|
+
*
|
|
48
|
+
* ## Injection, never IO
|
|
49
|
+
*
|
|
50
|
+
* The module reads no files. The context — the registry index
|
|
51
|
+
* (`./adr-registry.mjs`'s `readAdrContext` returns `records`/`byId`), the
|
|
52
|
+
* workspace's declared rule/fitness ids (`declaredFitnessNames(config)`), the
|
|
53
|
+
* governed rows, and the findings lookups — is assembled by the caller (Wave
|
|
54
|
+
* 2's report/adr surface), so the walk is a pure function of facts, testable
|
|
55
|
+
* on in-memory fixtures.
|
|
56
|
+
*
|
|
57
|
+
* ## The two name spaces, resolved the way the registry resolves them
|
|
58
|
+
*
|
|
59
|
+
* Decision references are the registry's: an ADR id (`0002-case`, or the
|
|
60
|
+
* documented `adr:` spelling `stripAdrPrefix` handles). Rule/fitness ids are
|
|
61
|
+
* the declared-name space `declaredFitnessNames` owns, with the documented
|
|
62
|
+
* `rule:`/`fitness:` prefixes `stripRuleFitnessPrefix` handles. Both helpers
|
|
63
|
+
* (and `resolveDecisionRef`, the classification every other surface shares)
|
|
64
|
+
* come from `./adr-registry.mjs` — this walk consumes the registry's meaning,
|
|
65
|
+
* never a second opinion about what a reference names. Normalization for row
|
|
66
|
+
* matching applies the same documented aliases: a record binding `hotspot`
|
|
67
|
+
* and a row id `fitness:hotspot` name the same thing, exactly as a
|
|
68
|
+
* `decisionRef` of either spelling resolves identically everywhere else.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
import { resolveDecisionRef, stripAdrPrefix, stripRuleFitnessPrefix } from "./adr-registry.mjs";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One governed row the walk knows: an intent row, a boundary constraint row,
|
|
75
|
+
* or a fitness rule, each carrying the governance block the wave's shared
|
|
76
|
+
* row schema (`./row-schema.mjs`) validates. The caller assembles this list
|
|
77
|
+
* from the surfaces it owns — `architecture-intent.json` rows, the boundary
|
|
78
|
+
* law's `depConstraints`, the declared `fitness` list — so the walk never
|
|
79
|
+
* reads a row itself.
|
|
80
|
+
*
|
|
81
|
+
* @typedef {object} GovernedRow
|
|
82
|
+
* @property {string} id The row's own reference — an intent-row label
|
|
83
|
+
* (`projects.required[0]`) or a rule/fitness id (`rule:keep-a`,
|
|
84
|
+
* `fitness:hotspot`, or the bare `hotspot` spelling).
|
|
85
|
+
* @property {"intent"|"constraint"|"fitness"} kind What kind of row it is.
|
|
86
|
+
* @property {string} [decisionRef] The decision that makes the row
|
|
87
|
+
* enforceable — an ADR id (bare or `adr:`-prefixed), or a rule/fitness id
|
|
88
|
+
* naming a decision that binds it.
|
|
89
|
+
* @property {string[]} [governs] The project/part ids this row governs.
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The facts a walk is a pure function of. Registry-shaped values come
|
|
94
|
+
* straight from `./adr-registry.mjs`'s `readAdrContext` and
|
|
95
|
+
* `declaredFitnessNames`; the rows and findings come from the caller.
|
|
96
|
+
*
|
|
97
|
+
* @typedef {object} GraphWalkContext
|
|
98
|
+
* @property {object[]} records The validated records, in registry order.
|
|
99
|
+
* @property {Map<string, object>} byId The registry index.
|
|
100
|
+
* @property {Set<string>} knownFitness Rule/fitness ids the workspace
|
|
101
|
+
* declares (`declaredFitnessNames(config)`).
|
|
102
|
+
* @property {GovernedRow[]} rows The governed rows this workspace carries.
|
|
103
|
+
* @property {(projectId: string) => object[]} [findingsByProject] The current
|
|
104
|
+
* findings/evidence for a project. Required for a forward walk's
|
|
105
|
+
* "current findings" leg; when absent, the walk cannot claim a project has
|
|
106
|
+
* no findings and says so in `unresolved`.
|
|
107
|
+
* @property {Map<string, object>} [findingsById] Finding id -> finding record,
|
|
108
|
+
* for the reverse walk's finding route. A finding record must carry at
|
|
109
|
+
* least `id`, `project`, and `ruleId`; any further fields pass through
|
|
110
|
+
* untouched in the finding node's `data`.
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @typedef {"decision"|"intent"|"constraint"|"fitness"|"project"|"finding"} GraphNodeKind
|
|
115
|
+
* @typedef {object} GraphNode
|
|
116
|
+
* @property {string} id
|
|
117
|
+
* @property {GraphNodeKind} kind
|
|
118
|
+
* @property {string} label
|
|
119
|
+
* @property {object} [data]
|
|
120
|
+
* @typedef {"decisionRef"|"binding"|"governs"|"finding"|"supersedes"} GraphEdgeKind
|
|
121
|
+
* @typedef {object} GraphEdge
|
|
122
|
+
* @property {string} from
|
|
123
|
+
* @property {string} to
|
|
124
|
+
* @property {GraphEdgeKind} kind
|
|
125
|
+
* @typedef {"decision"|"intent"|"constraint"|"fitness"|"project"|"finding"|"rule"|"binding"} UnresolvedKind
|
|
126
|
+
* @typedef {object} UnresolvedRef
|
|
127
|
+
* @property {string} ref The reference that did not resolve.
|
|
128
|
+
* @property {UnresolvedKind} kind What the walk was trying to resolve.
|
|
129
|
+
* @property {string} reason Why it cannot.
|
|
130
|
+
* @typedef {object} GraphWalk
|
|
131
|
+
* @property {boolean} ok False iff `unresolved` is non-empty — a walk that
|
|
132
|
+
* could not resolve every reference it met never looks like a walk that
|
|
133
|
+
* found nothing.
|
|
134
|
+
* @property {GraphNode[]} nodes
|
|
135
|
+
* @property {GraphEdge[]} edges
|
|
136
|
+
* @property {UnresolvedRef[]} unresolved
|
|
137
|
+
*/
|
|
138
|
+
|
|
139
|
+
/** A fresh, empty walk — `ok: true` until the first unresolved reference. */
|
|
140
|
+
function newWalk() {
|
|
141
|
+
const walk = { ok: true, nodes: [], edges: [], unresolved: [] };
|
|
142
|
+
const nodeIds = new Set();
|
|
143
|
+
const edgeKeys = new Set();
|
|
144
|
+
const unresolvedKeys = new Set();
|
|
145
|
+
return {
|
|
146
|
+
walk,
|
|
147
|
+
node(id, kind, label, data) {
|
|
148
|
+
if (nodeIds.has(id)) return;
|
|
149
|
+
nodeIds.add(id);
|
|
150
|
+
walk.nodes.push(data === undefined ? { id, kind, label } : { id, kind, label, data });
|
|
151
|
+
},
|
|
152
|
+
edge(from, to, kind) {
|
|
153
|
+
const key = `${from}\u0000${kind}\u0000${to}`;
|
|
154
|
+
if (edgeKeys.has(key)) return;
|
|
155
|
+
edgeKeys.add(key);
|
|
156
|
+
walk.edges.push({ from, to, kind });
|
|
157
|
+
},
|
|
158
|
+
unresolved(ref, kind, reason) {
|
|
159
|
+
const key = `${kind}\u0000${ref}`;
|
|
160
|
+
if (unresolvedKeys.has(key)) return;
|
|
161
|
+
unresolvedKeys.add(key);
|
|
162
|
+
walk.ok = false;
|
|
163
|
+
walk.unresolved.push({ ref, kind, reason });
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The decision node a record contributes, with the record's own facts. */
|
|
169
|
+
function decisionNode(record) {
|
|
170
|
+
const data = { status: record.status };
|
|
171
|
+
for (const key of [
|
|
172
|
+
"created",
|
|
173
|
+
"updated",
|
|
174
|
+
"context",
|
|
175
|
+
"decision",
|
|
176
|
+
"rationale",
|
|
177
|
+
"alternatives",
|
|
178
|
+
"consequences",
|
|
179
|
+
"assumptions",
|
|
180
|
+
]) {
|
|
181
|
+
if (record[key] !== undefined) data[key] = record[key];
|
|
182
|
+
}
|
|
183
|
+
if (record.supersedes.length > 0) data.supersedes = record.supersedes;
|
|
184
|
+
if ((record.supersededBy ?? []).length > 0) data.supersededBy = record.supersededBy;
|
|
185
|
+
return { id: record.id, kind: "decision", label: record.id, data };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** `id`, with the documented `adr:` prefix stripped — the registry key. */
|
|
189
|
+
function decisionIdOf(ref) {
|
|
190
|
+
return stripAdrPrefix(ref);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The records whose `bindings` name `target`, normalized by the same
|
|
195
|
+
* `rule:`/`fitness:` aliases the registry's own resolution applies.
|
|
196
|
+
*/
|
|
197
|
+
function bindingRecords(records, target) {
|
|
198
|
+
return records.filter((record) =>
|
|
199
|
+
record.bindings.some((binding) => stripRuleFitnessPrefix(binding) === target),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The derived reverse-lineage index every walk builds for itself. */
|
|
204
|
+
function supersededByIndex(records) {
|
|
205
|
+
const index = new Map();
|
|
206
|
+
for (const record of records) {
|
|
207
|
+
for (const ref of record.supersedes) {
|
|
208
|
+
const list = index.get(ref) ?? [];
|
|
209
|
+
list.push(record.id);
|
|
210
|
+
index.set(ref, list);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return index;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Adds a record's node and the full supersession chain in both directions —
|
|
218
|
+
* `supersedes` forward, derived `supersededBy` backward — cycle-safe (a
|
|
219
|
+
* `visited` set; a raw, hand-built context can present a cycle the registry
|
|
220
|
+
* would have refused, and the walk must still terminate).
|
|
221
|
+
*/
|
|
222
|
+
function attachLineage(g, record, ctx) {
|
|
223
|
+
const index = supersededByIndex(ctx.records);
|
|
224
|
+
const visited = new Set();
|
|
225
|
+
const queue = [record.id];
|
|
226
|
+
while (queue.length > 0) {
|
|
227
|
+
const id = queue.shift();
|
|
228
|
+
if (visited.has(id)) continue;
|
|
229
|
+
visited.add(id);
|
|
230
|
+
const current = ctx.byId.get(id);
|
|
231
|
+
if (current === undefined) {
|
|
232
|
+
g.unresolved(
|
|
233
|
+
id,
|
|
234
|
+
"decision",
|
|
235
|
+
`"${id}" is named in a supersession chain but no matching ADR record is in the registry`,
|
|
236
|
+
);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
g.node(current.id, "decision", current.id, decisionNode(current).data);
|
|
240
|
+
for (const next of current.supersedes) {
|
|
241
|
+
g.edge(current.id, next, "supersedes");
|
|
242
|
+
if (!visited.has(next)) queue.push(next);
|
|
243
|
+
}
|
|
244
|
+
for (const next of index.get(current.id) ?? []) {
|
|
245
|
+
g.edge(next, current.id, "supersedes");
|
|
246
|
+
if (!visited.has(next)) queue.push(next);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Every decision that governs `row`, in discovery order: the decision its
|
|
253
|
+
* `decisionRef` names (an ADR record, or — for a `rule:`/`fitness:`-shaped
|
|
254
|
+
* citation — the decisions whose `bindings` carry that id), plus every
|
|
255
|
+
* decision whose `bindings` name the row's own id. A citation that resolves
|
|
256
|
+
* to nothing lands in `unresolved`, never in a silent skip.
|
|
257
|
+
*
|
|
258
|
+
* @returns {number} How many governing decisions were attached.
|
|
259
|
+
*/
|
|
260
|
+
function governingDecisionsFor(g, row, ctx) {
|
|
261
|
+
let count = 0;
|
|
262
|
+
const decisionRef = row.decisionRef;
|
|
263
|
+
if (typeof decisionRef === "string" && decisionRef.trim() !== "") {
|
|
264
|
+
const resolution = resolveDecisionRef(ctx.byId, ctx.knownFitness, decisionRef);
|
|
265
|
+
if (resolution === "adr") {
|
|
266
|
+
const record = ctx.byId.get(decisionIdOf(decisionRef));
|
|
267
|
+
attachLineage(g, record, ctx);
|
|
268
|
+
g.edge(row.id, record.id, "decisionRef");
|
|
269
|
+
count += 1;
|
|
270
|
+
} else if (resolution === "fitness") {
|
|
271
|
+
const target = stripRuleFitnessPrefix(decisionRef);
|
|
272
|
+
for (const record of bindingRecords(ctx.records, target)) {
|
|
273
|
+
attachLineage(g, record, ctx);
|
|
274
|
+
g.edge(record.id, row.id, "binding");
|
|
275
|
+
count += 1;
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
g.unresolved(
|
|
279
|
+
decisionRef,
|
|
280
|
+
"decision",
|
|
281
|
+
`${row.id} cites "${decisionRef}", which does not resolve — no matching ADR, rule, or fitness record`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const target = stripRuleFitnessPrefix(row.id);
|
|
286
|
+
for (const record of bindingRecords(ctx.records, target)) {
|
|
287
|
+
attachLineage(g, record, ctx);
|
|
288
|
+
g.edge(record.id, row.id, "binding");
|
|
289
|
+
count += 1;
|
|
290
|
+
}
|
|
291
|
+
return count;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Adds a governed row's node plus its governed projects and their findings. */
|
|
295
|
+
function attachRowLeg(g, row, ctx) {
|
|
296
|
+
g.node(row.id, row.kind, row.id);
|
|
297
|
+
for (const projectId of row.governs ?? []) {
|
|
298
|
+
g.node(projectId, "project", projectId);
|
|
299
|
+
g.edge(row.id, projectId, "governs");
|
|
300
|
+
if (ctx.findingsByProject === undefined) {
|
|
301
|
+
g.unresolved(
|
|
302
|
+
projectId,
|
|
303
|
+
"project",
|
|
304
|
+
`the context provides no findingsByProject lookup — the walk cannot claim "${projectId}" has no findings`,
|
|
305
|
+
);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
for (const finding of ctx.findingsByProject(projectId)) {
|
|
309
|
+
g.node(finding.id, "finding", finding.id, finding);
|
|
310
|
+
g.edge(projectId, finding.id, "finding");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Forward walk: a decision -> the governed rows that attach to it (rows whose
|
|
317
|
+
* `decisionRef` names it — bare or `adr:`-prefixed — plus the rows whose ids
|
|
318
|
+
* its `bindings` name) -> the projects/parts those rows govern -> the
|
|
319
|
+
* projects' current findings/evidence, all read-only.
|
|
320
|
+
*
|
|
321
|
+
* Every hop that cannot resolve is reported: an unknown decision id, a
|
|
322
|
+
* binding that names no governed row, a governed project the context cannot
|
|
323
|
+
* produce findings for. A decision that resolves and attaches nothing is the
|
|
324
|
+
* true fact "recorded but not enforceable", and stays `ok: true`.
|
|
325
|
+
*
|
|
326
|
+
* @param {string} decisionId An ADR id (`0002-case`, or `adr:0002-case`).
|
|
327
|
+
* @param {GraphWalkContext} ctx
|
|
328
|
+
* @returns {GraphWalk}
|
|
329
|
+
*/
|
|
330
|
+
export function forwardDecision(decisionId, ctx) {
|
|
331
|
+
const g = newWalk();
|
|
332
|
+
const record = ctx.byId.get(decisionIdOf(decisionId));
|
|
333
|
+
if (record === undefined) {
|
|
334
|
+
g.unresolved(
|
|
335
|
+
decisionId,
|
|
336
|
+
"decision",
|
|
337
|
+
`"${decisionId}" does not resolve — no matching ADR record in the registry`,
|
|
338
|
+
);
|
|
339
|
+
return g.walk;
|
|
340
|
+
}
|
|
341
|
+
g.node(record.id, "decision", record.id, decisionNode(record).data);
|
|
342
|
+
|
|
343
|
+
const boundIds = new Set(record.bindings.map((binding) => stripRuleFitnessPrefix(binding)));
|
|
344
|
+
for (const row of ctx.rows) {
|
|
345
|
+
const viaDecisionRef =
|
|
346
|
+
typeof row.decisionRef === "string" &&
|
|
347
|
+
resolveDecisionRef(ctx.byId, ctx.knownFitness, row.decisionRef) === "adr" &&
|
|
348
|
+
decisionIdOf(row.decisionRef) === record.id;
|
|
349
|
+
const viaBinding = boundIds.has(stripRuleFitnessPrefix(row.id));
|
|
350
|
+
if (!viaDecisionRef && !viaBinding) continue;
|
|
351
|
+
attachRowLeg(g, row, ctx);
|
|
352
|
+
if (viaDecisionRef) g.edge(row.id, record.id, "decisionRef");
|
|
353
|
+
if (viaBinding) g.edge(record.id, row.id, "binding");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
for (const binding of record.bindings) {
|
|
357
|
+
const target = stripRuleFitnessPrefix(binding);
|
|
358
|
+
if (!ctx.rows.some((row) => stripRuleFitnessPrefix(row.id) === target)) {
|
|
359
|
+
g.unresolved(
|
|
360
|
+
binding,
|
|
361
|
+
"binding",
|
|
362
|
+
`"${binding}" is bound by ${record.id} but no governed row in the context carries that id`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return g.walk;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Reverse walk: a constraint/finding/rule id — a `rule:` id, a `fitness:` id,
|
|
371
|
+
* an intent-row label, or a finding id — -> the decision(s) that govern it
|
|
372
|
+
* (via the rows' `decisionRef` citations and the decisions' `bindings`) ->
|
|
373
|
+
* each governing decision's rationale/context/lineage/status, all read-only.
|
|
374
|
+
*
|
|
375
|
+
* A reference that matches nothing, a row whose `decisionRef` does not
|
|
376
|
+
* resolve, and a row or finding no decision binds or cites are all reported
|
|
377
|
+
* in `unresolved` — a reverse walk never answers "no decision governs this"
|
|
378
|
+
* about a reference it could not establish.
|
|
379
|
+
*
|
|
380
|
+
* @param {string} rowRef The id to walk back from.
|
|
381
|
+
* @param {GraphWalkContext} ctx
|
|
382
|
+
* @returns {GraphWalk}
|
|
383
|
+
*/
|
|
384
|
+
export function reverseRow(rowRef, ctx) {
|
|
385
|
+
const g = newWalk();
|
|
386
|
+
|
|
387
|
+
const row = ctx.rows.find((candidate) => candidate.id === rowRef);
|
|
388
|
+
if (row !== undefined) {
|
|
389
|
+
g.node(row.id, row.kind, row.id);
|
|
390
|
+
const governing = governingDecisionsFor(g, row, ctx);
|
|
391
|
+
if (governing === 0) {
|
|
392
|
+
g.unresolved(
|
|
393
|
+
rowRef,
|
|
394
|
+
"row",
|
|
395
|
+
`"${rowRef}" is governed by no decision — no decisionRef cites the row and no decision binds its id`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
return g.walk;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const finding = ctx.findingsById?.get(rowRef);
|
|
402
|
+
if (finding !== undefined) {
|
|
403
|
+
g.node(finding.id, "finding", finding.id, finding);
|
|
404
|
+
let governing = 0;
|
|
405
|
+
for (const candidate of ctx.rows) {
|
|
406
|
+
if ((candidate.governs ?? []).includes(finding.project)) {
|
|
407
|
+
governing += governingDecisionsFor(g, candidate, ctx);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const ruleId = stripRuleFitnessPrefix(finding.ruleId ?? "");
|
|
411
|
+
for (const record of bindingRecords(ctx.records, ruleId)) {
|
|
412
|
+
attachLineage(g, record, ctx);
|
|
413
|
+
g.edge(record.id, finding.id, "binding");
|
|
414
|
+
governing += 1;
|
|
415
|
+
}
|
|
416
|
+
if (governing === 0) {
|
|
417
|
+
g.unresolved(
|
|
418
|
+
rowRef,
|
|
419
|
+
"finding",
|
|
420
|
+
`"${rowRef}" is governed by no decision — no row governing its project cites one and no decision binds rule "${ruleId}"`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
return g.walk;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (rowRef.startsWith("rule:") || rowRef.startsWith("fitness:")) {
|
|
427
|
+
const kind = rowRef.startsWith("rule:") ? "rule" : "fitness";
|
|
428
|
+
const target = stripRuleFitnessPrefix(rowRef);
|
|
429
|
+
let governing = 0;
|
|
430
|
+
for (const candidate of ctx.rows) {
|
|
431
|
+
if (stripRuleFitnessPrefix(candidate.id) === target) {
|
|
432
|
+
g.node(candidate.id, candidate.kind, candidate.id);
|
|
433
|
+
governing += governingDecisionsFor(g, candidate, ctx);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
for (const record of bindingRecords(ctx.records, target)) {
|
|
437
|
+
attachLineage(g, record, ctx);
|
|
438
|
+
g.edge(record.id, rowRef, "binding");
|
|
439
|
+
governing += 1;
|
|
440
|
+
}
|
|
441
|
+
if (governing === 0) {
|
|
442
|
+
g.unresolved(
|
|
443
|
+
rowRef,
|
|
444
|
+
kind,
|
|
445
|
+
`"${rowRef}" does not resolve — no governed row carries that id and no decision binds it`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
return g.walk;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
g.unresolved(
|
|
452
|
+
rowRef,
|
|
453
|
+
"row",
|
|
454
|
+
`"${rowRef}" does not resolve — no governed row, finding, rule, or fitness id carries it`,
|
|
455
|
+
);
|
|
456
|
+
return g.walk;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Lineage walk: the full supersession chain of a decision, `supersedes`
|
|
461
|
+
* forward and derived `supersededBy` backward, cycle-safe and deterministic
|
|
462
|
+
* (registry order, first-discovery emission). An unknown start id — and a
|
|
463
|
+
* chain that names a record the registry does not hold — is reported in
|
|
464
|
+
* `unresolved`, never as an empty chain.
|
|
465
|
+
*
|
|
466
|
+
* @param {string} decisionId An ADR id (`0002-case`, or `adr:0002-case`).
|
|
467
|
+
* @param {GraphWalkContext} ctx
|
|
468
|
+
* @returns {GraphWalk}
|
|
469
|
+
*/
|
|
470
|
+
export function lineage(decisionId, ctx) {
|
|
471
|
+
const g = newWalk();
|
|
472
|
+
const record = ctx.byId.get(decisionIdOf(decisionId));
|
|
473
|
+
if (record === undefined) {
|
|
474
|
+
g.unresolved(
|
|
475
|
+
decisionId,
|
|
476
|
+
"decision",
|
|
477
|
+
`"${decisionId}" does not resolve — no matching ADR record in the registry`,
|
|
478
|
+
);
|
|
479
|
+
return g.walk;
|
|
480
|
+
}
|
|
481
|
+
attachLineage(g, record, ctx);
|
|
482
|
+
return g.walk;
|
|
483
|
+
}
|