@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
|
@@ -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
|
+
}
|
|
@@ -37,8 +37,17 @@
|
|
|
37
37
|
* or a polluted prototype cannot smuggle keys into a validated origin. This
|
|
38
38
|
* module builds nothing from untrusted keys; it validates and, at write time,
|
|
39
39
|
* builds a fresh object with only the three permitted keys.
|
|
40
|
+
* ## The decision-lifecycle record
|
|
41
|
+
*
|
|
42
|
+
* The same discipline extends from a row to a DECISION (an ADR id — the
|
|
43
|
+
* stable handle later waves reference). `recordDecisionLifecycle` records one
|
|
44
|
+
* lifecycle event — a status transition, a supersession, or a bindings
|
|
45
|
+
* change — attributed by the same `origin` shape and the same clock door:
|
|
46
|
+
* `by`/`tool` are required, and `on` comes from `recordOrigin` and nowhere
|
|
47
|
+
* else. A record that records nothing (a no-op transition) is refused loudly.
|
|
40
48
|
*/
|
|
41
49
|
|
|
50
|
+
import { ADR_STATUSES } from "./adr-registry.mjs";
|
|
42
51
|
import { clockViolations } from "./clock.mjs";
|
|
43
52
|
|
|
44
53
|
/** The only keys a validated `origin` may carry. */
|
|
@@ -175,3 +184,144 @@ export function recordOrigin({ by, tool, clock }) {
|
|
|
175
184
|
// record, so two calls with the same clock are byte-identical.
|
|
176
185
|
return { by, tool, on: clock.now() };
|
|
177
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* The decision-lifecycle events one record can hold, each a single fact about
|
|
189
|
+
* ONE decision (an ADR id — `docs/adr/NNN-slug.md` — the stable handle the
|
|
190
|
+
* registry's `byId` map keys on). A decision's creation and every status
|
|
191
|
+
* change, supersession, and bindings change is recorded as one of these.
|
|
192
|
+
*/
|
|
193
|
+
export const DECISION_LIFECYCLE_KINDS = Object.freeze([
|
|
194
|
+
"status-transition",
|
|
195
|
+
"supersession",
|
|
196
|
+
"bindings-change",
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @typedef {object} DecisionLifecycleRecord
|
|
201
|
+
* @property {"status-transition"|"supersession"|"bindings-change"} kind
|
|
202
|
+
* One recorded lifecycle event on one decision.
|
|
203
|
+
* @property {string} decisionId The ADR id the event happened to — the stable
|
|
204
|
+
* handle the ADR registry keys on (`docs/adr/NNN-slug.md`).
|
|
205
|
+
* @property {string|null} [from] status-transition: the status the decision
|
|
206
|
+
* left, or null when the event is the decision's creation (its proposed
|
|
207
|
+
* entry).
|
|
208
|
+
* @property {string} [to] status-transition: the status the decision entered.
|
|
209
|
+
* @property {string[]} [superseded] supersession: the ADR id(s) this decision
|
|
210
|
+
* replaced — `decisionId` is the RECORDING record, the successor.
|
|
211
|
+
* @property {string[]} [added] bindings-change: constraint ids made
|
|
212
|
+
* enforceable.
|
|
213
|
+
* @property {string[]} [removed] bindings-change: constraint ids unbound.
|
|
214
|
+
* @property {OriginRecord} origin WHO recorded the event and with what tool —
|
|
215
|
+
* `on` produced by `recordOrigin`, the only door.
|
|
216
|
+
*/
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Records one decision-lifecycle event, attributed by the same `origin`
|
|
220
|
+
* discipline as a row: `by` and `tool` are required, and `on` comes from the
|
|
221
|
+
* injected clock through `recordOrigin` — the only producer of an `on`, and
|
|
222
|
+
* the refusal to run without one is its own, inherited here.
|
|
223
|
+
*
|
|
224
|
+
* The record carries ONLY the kind's own keys and the origin, built fresh, so
|
|
225
|
+
* nothing from untrusted input rides along. A no-op event — a status
|
|
226
|
+
* transition that changes nothing, a supersession naming no target, a
|
|
227
|
+
* bindings change that adds and removes nothing — is refused loudly: a record
|
|
228
|
+
* that records nothing would read as a transition that happened, the silent
|
|
229
|
+
* direction this module exists to exclude.
|
|
230
|
+
*
|
|
231
|
+
* Statuses are validated against `ADR_STATUSES` (`./adr-registry.mjs`), the
|
|
232
|
+
* single status vocabulary — a record can never attest a status the registry
|
|
233
|
+
* could not hold.
|
|
234
|
+
*
|
|
235
|
+
* @param {{kind: "status-transition"|"supersession"|"bindings-change",
|
|
236
|
+
* decisionId: string,
|
|
237
|
+
* from?: string|null, to?: string,
|
|
238
|
+
* superseded?: string[],
|
|
239
|
+
* added?: string[], removed?: string[],
|
|
240
|
+
* origin: {by: string, tool: string},
|
|
241
|
+
* clock: import("./clock.mjs").Clock}} event
|
|
242
|
+
* @returns {DecisionLifecycleRecord}
|
|
243
|
+
* @throws {Error} on an unknown kind, a missing decisionId, a status outside
|
|
244
|
+
* the registry's `ADR_STATUSES`, a no-op event, or an invalid origin/clock.
|
|
245
|
+
*/
|
|
246
|
+
export function recordDecisionLifecycle({
|
|
247
|
+
kind,
|
|
248
|
+
decisionId,
|
|
249
|
+
from = null,
|
|
250
|
+
to,
|
|
251
|
+
superseded,
|
|
252
|
+
added,
|
|
253
|
+
removed,
|
|
254
|
+
origin,
|
|
255
|
+
clock,
|
|
256
|
+
}) {
|
|
257
|
+
const violations = [];
|
|
258
|
+
if (!DECISION_LIFECYCLE_KINDS.includes(kind)) {
|
|
259
|
+
violations.push(
|
|
260
|
+
`kind: must be one of ${DECISION_LIFECYCLE_KINDS.join(", ")}, got ${describe(kind)}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (typeof decisionId !== "string" || decisionId.trim() === "") {
|
|
264
|
+
violations.push(
|
|
265
|
+
`decisionId: must be a non-empty string naming the ADR, got ${describe(decisionId)}`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (kind === "status-transition") {
|
|
269
|
+
if (from !== null && !ADR_STATUSES.includes(from)) {
|
|
270
|
+
violations.push(
|
|
271
|
+
`from: must be null or one of ${ADR_STATUSES.join(", ")}, got ${describe(from)}`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (!ADR_STATUSES.includes(to)) {
|
|
275
|
+
violations.push(`to: must be one of ${ADR_STATUSES.join(", ")}, got ${describe(to)}`);
|
|
276
|
+
}
|
|
277
|
+
if (from !== null && from === to) {
|
|
278
|
+
violations.push(
|
|
279
|
+
`to: equals from (${JSON.stringify(from)}) — a status transition that changes nothing is not a recordable event`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
} else if (kind === "supersession") {
|
|
283
|
+
if (
|
|
284
|
+
!Array.isArray(superseded) ||
|
|
285
|
+
superseded.length === 0 ||
|
|
286
|
+
superseded.some((ref) => typeof ref !== "string" || ref.trim() === "")
|
|
287
|
+
) {
|
|
288
|
+
violations.push("superseded: must be a non-empty array of ADR ids this decision replaced");
|
|
289
|
+
}
|
|
290
|
+
} else if (kind === "bindings-change") {
|
|
291
|
+
for (const [name, value] of [
|
|
292
|
+
["added", added],
|
|
293
|
+
["removed", removed],
|
|
294
|
+
]) {
|
|
295
|
+
if (value !== undefined && !Array.isArray(value)) {
|
|
296
|
+
violations.push(`${name}: must be an array of constraint ids, got ${describe(value)}`);
|
|
297
|
+
} else if (
|
|
298
|
+
Array.isArray(value) &&
|
|
299
|
+
value.some((id) => typeof id !== "string" || id.trim() === "")
|
|
300
|
+
) {
|
|
301
|
+
violations.push(`${name}: every entry must be a non-empty constraint id`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const addedList = Array.isArray(added) ? added : [];
|
|
305
|
+
const removedList = Array.isArray(removed) ? removed : [];
|
|
306
|
+
if (addedList.length === 0 && removedList.length === 0) {
|
|
307
|
+
violations.push(
|
|
308
|
+
"added/removed: a bindings change that adds nothing and removes nothing is not a recordable event",
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (violations.length > 0) {
|
|
313
|
+
throw new Error(`decisionLifecycle: ${violations.join("; ")}`);
|
|
314
|
+
}
|
|
315
|
+
const eventFields =
|
|
316
|
+
kind === "status-transition"
|
|
317
|
+
? { from, to }
|
|
318
|
+
: kind === "supersession"
|
|
319
|
+
? { superseded }
|
|
320
|
+
: { added: added ?? [], removed: removed ?? [] };
|
|
321
|
+
return {
|
|
322
|
+
kind,
|
|
323
|
+
decisionId,
|
|
324
|
+
...eventFields,
|
|
325
|
+
origin: recordOrigin({ by: origin?.by, tool: origin?.tool, clock }),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
@@ -246,6 +246,10 @@ const PROJECT_TYPES = ["app", "lib", "e2e"];
|
|
|
246
246
|
* An explicit `exclude` list REPLACES this default (the `tsc` convention for
|
|
247
247
|
* the same field): a workspace that names its own list takes over the whole
|
|
248
248
|
* decision, `exclude: []` included — that spelling is the documented opt-out.
|
|
249
|
+
* `excludeBeyondDefaults` extends the effective set — the defaults when
|
|
250
|
+
* `exclude` is absent, an explicit list when it is present — without restating
|
|
251
|
+
* it, so a workspace with `testdata/` or `golden/` directories does not copy
|
|
252
|
+
* these three patterns by hand (issue #389).
|
|
249
253
|
*
|
|
250
254
|
* @see DEFAULT_MANIFEST_NAMES
|
|
251
255
|
*/
|
|
@@ -375,7 +379,7 @@ function declaredProjectViolations(row, index) {
|
|
|
375
379
|
return violations;
|
|
376
380
|
}
|
|
377
381
|
|
|
378
|
-
const INFER_KEYS = ["manifests", "include", "exclude"];
|
|
382
|
+
const INFER_KEYS = ["manifests", "include", "exclude", "excludeBeyondDefaults"];
|
|
379
383
|
|
|
380
384
|
/** `projects.infer`'s problems, or `[]` when the key is absent — absent means "use the defaults", not "malformed". */
|
|
381
385
|
function inferViolations(value) {
|
|
@@ -385,6 +389,11 @@ function inferViolations(value) {
|
|
|
385
389
|
...stringListViolations(value.manifests, "projects.infer.manifests"),
|
|
386
390
|
...stringListViolations(value.include, "projects.infer.include", globComplexityError),
|
|
387
391
|
...stringListViolations(value.exclude, "projects.infer.exclude", globComplexityError),
|
|
392
|
+
...stringListViolations(
|
|
393
|
+
value.excludeBeyondDefaults,
|
|
394
|
+
"projects.infer.excludeBeyondDefaults",
|
|
395
|
+
globComplexityError,
|
|
396
|
+
),
|
|
388
397
|
];
|
|
389
398
|
// `[]` and "omit the key" both validate against `stringListViolations` above
|
|
390
399
|
// — a list is still a list at length zero — but they must not mean the same
|
|
@@ -758,9 +767,14 @@ export function normalizeNativeModel(raw) {
|
|
|
758
767
|
: {
|
|
759
768
|
manifests: rawInfer.manifests ?? DEFAULT_MANIFEST_NAMES,
|
|
760
769
|
include: rawInfer.include ?? ["**"],
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
|
|
770
|
+
// `exclude` replaces the defaults (`DEFAULT_INFER_EXCLUDE`'s doc
|
|
771
|
+
// comment owns the why); `excludeBeyondDefaults` then extends the
|
|
772
|
+
// effective set — the defaults when `exclude` is absent, the
|
|
773
|
+
// explicit list when it is present — without restating it.
|
|
774
|
+
exclude: [
|
|
775
|
+
.../** @type {string[]|undefined} */ (rawInfer.exclude ?? DEFAULT_INFER_EXCLUDE),
|
|
776
|
+
.../** @type {string[]|undefined} */ (rawInfer.excludeBeyondDefaults ?? []),
|
|
777
|
+
],
|
|
764
778
|
},
|
|
765
779
|
},
|
|
766
780
|
projectRules: /** @type {unknown[]} */ (raw.projectRules ?? []).map((row) => {
|