@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.
@@ -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
+ }