@ecoma-io/archkeep 0.17.0 → 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/cli.mjs +143 -20
- package/package.json +2 -2
- package/src/architecture-intent/judge.mjs +19 -6
- 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/delta-classify.mjs +257 -0
- package/src/commands/delta.mjs +269 -8
- package/src/commands/evolution.mjs +758 -5
- package/src/commands/explain.mjs +71 -1
- package/src/commands/history.mjs +81 -5
- package/src/commands/plan-context-command.mjs +163 -2
- package/src/commands/trajectory.mjs +89 -3
- package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
- package/src/governance/debt-ledger.mjs +261 -19
- 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/report/change-text.mjs +21 -3
- package/src/report/debt-text.mjs +42 -6
- package/src/report/delta-text.mjs +36 -1
- package/src/report/evolution-text.mjs +231 -2
- package/src/report/explain-text.mjs +45 -0
- package/src/report/history-text.mjs +9 -3
- package/src/report/plan-context-text.mjs +94 -0
- package/src/report/snapshot-text.mjs +35 -1
- package/src/report/trajectory-text.mjs +30 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision-lineage helpers of Decision Governance integration (Wave 3 §9):
|
|
3
|
+
* two pure functions of registry input that answer which recorded decisions
|
|
4
|
+
* govern a run's affected constraint/fitness ids, and whether the decision
|
|
5
|
+
* lineage moved between two registry states.
|
|
6
|
+
*
|
|
7
|
+
* ## What each helper answers
|
|
8
|
+
*
|
|
9
|
+
* - `computeAffectedDecisions(registry, fitnessRows, constraintRows)` — the
|
|
10
|
+
* "affected-decisions resolution": for each affected id, the ADR records
|
|
11
|
+
* binding it (the registry's own `adrsBinding`/`boundFitnessIds`) and the
|
|
12
|
+
* row's `decisionRef` lineage (the registry's own `resolveDecisionRef`).
|
|
13
|
+
* An unresolved ref is surfaced twice — as the per-row entry's
|
|
14
|
+
* `resolution: "unknown"` and as the `unresolvedNote` naming every
|
|
15
|
+
* unresolved ref (`unresolvedDecisionRefRows`, the bulk form `check` /
|
|
16
|
+
* `context` / `drift` share) — never dropped (`../../../../AGENTS.md`: an
|
|
17
|
+
* empty result must mean "no violation", and nothing else; a citation that
|
|
18
|
+
* cannot be verified reads as unverified, never as clean).
|
|
19
|
+
* - `detectDecisionChange(baseRegistry, headRegistry)` — the DECISION_CHANGE
|
|
20
|
+
* predicate (design §2): the ADR lineage moved between base and head —
|
|
21
|
+
* the same ADR id with a different status, or a new `supersedes` relation.
|
|
22
|
+
* Both registry states are REQUIRED: exactly one side absent is the
|
|
23
|
+
* one-sided case (the mirror of `policyOneSided`), where the change is NOT
|
|
24
|
+
* asserted and a note says the lineage is not comparable — asserting the
|
|
25
|
+
* lineage did not move from one registry would fabricate a fact about the
|
|
26
|
+
* side the input does not have.
|
|
27
|
+
*
|
|
28
|
+
* ## Reuse, never a second opinion
|
|
29
|
+
*
|
|
30
|
+
* Both helpers are pure functions of registry input — testable with no
|
|
31
|
+
* filesystem — and every judgment they make is the registry's or the event
|
|
32
|
+
* classifier's, never a private reading:
|
|
33
|
+
*
|
|
34
|
+
* - the binding reverse lookup is `adrsBinding` itself, the exact-match
|
|
35
|
+
* surface the `adr` command's reverse lookup uses (`rule:x` / `fitness:x`
|
|
36
|
+
* spellings match the record's own binding spelling);
|
|
37
|
+
* - the fitness half of `resolveDecisionRef` answers against the registry's
|
|
38
|
+
* own binding ids (`boundFitnessIds`), prefix-normalised exactly the way
|
|
39
|
+
* `resolveDecisionRef` normalises the ref side (`stripRuleFitnessPrefix`)
|
|
40
|
+
* — the same-name-space comparison `decision-graph.mjs`'s row matching
|
|
41
|
+
* applies. This helper answers "what the recorded decisions make
|
|
42
|
+
* enforceable"; the F04 discipline — judging a citation against the ids
|
|
43
|
+
* the executed policy actually declares — stays with the config-facing
|
|
44
|
+
* surfaces (`check` / `context` / `drift`) that own it;
|
|
45
|
+
* - the DECISION_CHANGE predicate is `classifyEvolution`'s own — one
|
|
46
|
+
* classification, one definition, consumed here rather than re-derived.
|
|
47
|
+
*
|
|
48
|
+
* Deterministic: input order is preserved (registry byte-sorted filename
|
|
49
|
+
* order for `adrs`, caller order for rows and refs), and both outputs are
|
|
50
|
+
* stable across runs.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import {
|
|
54
|
+
adrsBinding,
|
|
55
|
+
boundFitnessIds,
|
|
56
|
+
hasAuthority,
|
|
57
|
+
resolveDecisionRef,
|
|
58
|
+
stripAdrPrefix,
|
|
59
|
+
stripRuleFitnessPrefix,
|
|
60
|
+
unresolvedDecisionRefRows,
|
|
61
|
+
} from "./adr-registry.mjs";
|
|
62
|
+
import { classifyEvolution } from "./evolution-event.mjs";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One affected constraint/fitness row: the identity the helper resolves
|
|
66
|
+
* decisions for, and the optional `decisionRef` that claims a governing
|
|
67
|
+
* decision.
|
|
68
|
+
*
|
|
69
|
+
* @typedef {object} AffectedRow
|
|
70
|
+
* @property {string} id The affected constraint/fitness id — a constraint
|
|
71
|
+
* row label or a rule/fitness id in any spelling the registry records.
|
|
72
|
+
* @property {string} [decisionRef] The decision that makes the row
|
|
73
|
+
* enforceable, in any spelling `resolveDecisionRef` accepts.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One affected id's decision lineage: the ADRs binding it and, when the row
|
|
78
|
+
* carries a `decisionRef`, how that citation resolved.
|
|
79
|
+
*
|
|
80
|
+
* @typedef {object} DecisionLineageEntry
|
|
81
|
+
* @property {string} id The affected id, verbatim.
|
|
82
|
+
* @property {string[]} adrs The ADR ids binding the id, in registry order —
|
|
83
|
+
* a reverse lookup into the records' own `bindings`. Empty when no record
|
|
84
|
+
* binds it (an unenforced id is a fact, not an error — the same
|
|
85
|
+
* unenforced-but-ok sentence the `adr` command's reverse lookup prints).
|
|
86
|
+
* @property {string} [decisionRef] The row's citation, when it carried one.
|
|
87
|
+
* @property {"none"|"adr"|"fitness"|"unknown"} resolution
|
|
88
|
+
* `none` = the row carried no `decisionRef`. `adr` = the citation named a
|
|
89
|
+
* recorded decision; `fitness` = it named an id the registry's records
|
|
90
|
+
* bind; `unknown` = it named neither — and is disclosed, never dropped.
|
|
91
|
+
* @property {object} [record] The governing record's status/lineage facts —
|
|
92
|
+
* present when `resolution` is `"adr"`.
|
|
93
|
+
* @property {string} [reason] Why an `unknown` citation did not resolve.
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The affected-decisions answer: the governing decision ids and one lineage
|
|
98
|
+
* entry per affected id, plus the unresolved-citation disclosure.
|
|
99
|
+
*
|
|
100
|
+
* @typedef {object} AffectedDecisions
|
|
101
|
+
* @property {string[]} decisions The distinct governing ADR ids — the ADRs
|
|
102
|
+
* binding the affected ids plus every `decisionRef` that itself named an
|
|
103
|
+
* ADR — sorted, de-duplicated. What an event's `affected.decisions` names
|
|
104
|
+
* for a run whose affected rows these are.
|
|
105
|
+
* @property {DecisionLineageEntry[]} lineage One entry per affected id, in
|
|
106
|
+
* caller order (fitness rows first, then constraint rows).
|
|
107
|
+
* @property {string} [unresolvedNote] Present exactly when some affected
|
|
108
|
+
* row's `decisionRef` resolved `unknown` — names every such ref. Never
|
|
109
|
+
* absent when an unresolved ref exists.
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolves the affected-decisions answer for one run's affected
|
|
114
|
+
* constraint/fitness ids.
|
|
115
|
+
*
|
|
116
|
+
* The registry is the `loadAdrRegistry` result (`{records, byId}`); absent
|
|
117
|
+
* (`null`/`undefined`) it is an empty registry — a workspace that has not
|
|
118
|
+
* adopted ADRs has nothing binding an id and nothing to resolve, and every
|
|
119
|
+
* citation resolves `unknown`, surfaced by the note. Rows that lack an `id`
|
|
120
|
+
* are skipped: they name no identity to resolve decisions for, and skipping
|
|
121
|
+
* them drops no fact (an id-less row claims no affected identity).
|
|
122
|
+
*
|
|
123
|
+
* @param {{records: object[], byId: Map<string, object>}|null|undefined} registry
|
|
124
|
+
* @param {AffectedRow[]} [fitnessRows] The affected fitness rows.
|
|
125
|
+
* @param {AffectedRow[]} [constraintRows] The affected constraint rows.
|
|
126
|
+
* @returns {AffectedDecisions}
|
|
127
|
+
*/
|
|
128
|
+
export function computeAffectedDecisions(registry, fitnessRows, constraintRows) {
|
|
129
|
+
const records = Array.isArray(registry?.records) ? registry.records : [];
|
|
130
|
+
const byId = registry?.byId instanceof Map ? registry.byId : new Map();
|
|
131
|
+
// The fitness half of `resolveDecisionRef` answers against the ids the
|
|
132
|
+
// registry's records bind, prefix-normalised the same way the ref side is
|
|
133
|
+
// (`stripRuleFitnessPrefix`), so `rule:no-direct-dep` and
|
|
134
|
+
// `fitness:no-direct-dep` and bare `no-direct-dep` name the same id.
|
|
135
|
+
const knownFitness = new Set([...boundFitnessIds(records)].map(stripRuleFitnessPrefix));
|
|
136
|
+
|
|
137
|
+
const lineage = [];
|
|
138
|
+
const decisions = new Set();
|
|
139
|
+
for (const row of [...(fitnessRows ?? []), ...(constraintRows ?? [])]) {
|
|
140
|
+
if (row === null || typeof row !== "object" || typeof row.id !== "string" || row.id === "") {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
/** @type {DecisionLineageEntry} */
|
|
144
|
+
const entry = { id: row.id, adrs: adrsBinding(records, row.id), resolution: "none" };
|
|
145
|
+
for (const adr of entry.adrs) decisions.add(adr);
|
|
146
|
+
|
|
147
|
+
const ref = row.decisionRef;
|
|
148
|
+
if (typeof ref !== "string" || ref.trim() === "") {
|
|
149
|
+
entry.resolution = "none";
|
|
150
|
+
lineage.push(entry);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
entry.decisionRef = ref;
|
|
154
|
+
const resolution = resolveDecisionRef(byId, knownFitness, ref);
|
|
155
|
+
if (resolution === "adr") {
|
|
156
|
+
const record = byId.get(stripAdrPrefix(ref));
|
|
157
|
+
entry.resolution = "adr";
|
|
158
|
+
entry.record = {
|
|
159
|
+
id: record.id,
|
|
160
|
+
status: record.status,
|
|
161
|
+
authority: hasAuthority(record.status),
|
|
162
|
+
supersedes: record.supersedes,
|
|
163
|
+
supersededBy: record.supersededBy,
|
|
164
|
+
};
|
|
165
|
+
decisions.add(record.id);
|
|
166
|
+
} else if (resolution === "fitness") {
|
|
167
|
+
entry.resolution = "fitness";
|
|
168
|
+
} else {
|
|
169
|
+
entry.resolution = "unknown";
|
|
170
|
+
// The shared unresolved wording (`decision-graph.mjs` states the same
|
|
171
|
+
// sentence for a row whose citation names nothing).
|
|
172
|
+
entry.reason = `"${ref}" does not resolve — no matching ADR, rule, or fitness record`;
|
|
173
|
+
}
|
|
174
|
+
lineage.push(entry);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const rows = [
|
|
178
|
+
...(fitnessRows ?? []).map((row, index) => ({ kind: `fitness[${index}]`, row })),
|
|
179
|
+
...(constraintRows ?? []).map((row, index) => ({ kind: `constraint[${index}]`, row })),
|
|
180
|
+
];
|
|
181
|
+
const unresolved = unresolvedDecisionRefRows(rows, byId, knownFitness);
|
|
182
|
+
|
|
183
|
+
/** @type {AffectedDecisions} */
|
|
184
|
+
const result = {
|
|
185
|
+
decisions: [...decisions].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
186
|
+
lineage,
|
|
187
|
+
};
|
|
188
|
+
if (unresolved.length > 0) {
|
|
189
|
+
result.unresolvedNote =
|
|
190
|
+
`unresolved decisionRefs: ${unresolved.map(({ decisionRef }) => `"${decisionRef}"`).join(", ")}` +
|
|
191
|
+
` — no matching ADR, rule, or fitness record`;
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The DECISION_CHANGE answer between two registry states.
|
|
198
|
+
*
|
|
199
|
+
* @typedef {object} DecisionChange
|
|
200
|
+
* @property {boolean} superseded Whether the decision lineage moved: the
|
|
201
|
+
* same ADR id with a different status, or a new `supersedes` relation,
|
|
202
|
+
* between base and head — `classifyEvolution`'s own predicate, never a
|
|
203
|
+
* second opinion.
|
|
204
|
+
* @property {boolean} comparable Whether the two registry states were both
|
|
205
|
+
* present and could be compared. `false` for a one-sided comparison (or,
|
|
206
|
+
* at the caller, an unreadable head registry) — the state never read as a
|
|
207
|
+
* "did not move" claim, because that would fabricate a fact about evidence
|
|
208
|
+
* the comparison could not hold. `superseded` is meaningful only when
|
|
209
|
+
* `comparable` is `true`.
|
|
210
|
+
* @property {string[]} notes Disclosure notes. The non-comparable case —
|
|
211
|
+
* exactly one registry state supplied — carries the
|
|
212
|
+
* "decision lineage not comparable" note; both states absent carries
|
|
213
|
+
* nothing to disclose (no decision evidence was supplied, so nothing was
|
|
214
|
+
* asserted, the mirror of `classifyEvolution`'s both-sides-absent case).
|
|
215
|
+
*/
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Detects whether the decision lineage moved between two registry states —
|
|
219
|
+
* the DECISION_CHANGE predicate (design §2), delegating the supersession
|
|
220
|
+
* judgment to `classifyEvolution` so one definition stays the only one.
|
|
221
|
+
*
|
|
222
|
+
* Both registry states are REQUIRED. Exactly one side absent (one-sided) ⇒
|
|
223
|
+
* `comparable: false` (with `superseded: false` and a note) — the state
|
|
224
|
+
* never reads as "did not move", because asserting the lineage did not move
|
|
225
|
+
* from one registry would fabricate a fact about the side the input does not
|
|
226
|
+
* have. Both sides absent is "no decision evidence supplied" — comparable,
|
|
227
|
+
* nothing asserted, nothing to disclose.
|
|
228
|
+
*
|
|
229
|
+
* Each registry is a `loadAdrRegistry` result (`{records, byId}`) or
|
|
230
|
+
* `null`/`undefined` for an absent side.
|
|
231
|
+
*
|
|
232
|
+
* @param {{records: object[], byId: Map<string, object>}|null|undefined} baseRegistry
|
|
233
|
+
* @param {{records: object[], byId: Map<string, object>}|null|undefined} headRegistry
|
|
234
|
+
* @returns {DecisionChange}
|
|
235
|
+
*/
|
|
236
|
+
export function detectDecisionChange(baseRegistry, headRegistry) {
|
|
237
|
+
const base = baseRegistry ?? null;
|
|
238
|
+
const head = headRegistry ?? null;
|
|
239
|
+
const oneSided = (base == null) !== (head == null);
|
|
240
|
+
const classification = classifyEvolution({ adrBase: base, adrHead: head });
|
|
241
|
+
const notes = [];
|
|
242
|
+
if (oneSided) {
|
|
243
|
+
notes.push("decision lineage not comparable — both registry states required");
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
superseded: classification.classifications.includes("DECISION_CHANGE"),
|
|
247
|
+
comparable: !oneSided,
|
|
248
|
+
notes,
|
|
249
|
+
};
|
|
250
|
+
}
|