@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,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical evolution event — one record every evolution command emits,
|
|
3
|
+
* with one stable identity, one classification vocabulary and one idempotency
|
|
4
|
+
* rule. `docs/concepts/evolution.md` is the human-facing model; this module is
|
|
5
|
+
* its implementation, and the two must not disagree.
|
|
6
|
+
*
|
|
7
|
+
* The whole module is pure: no clock, no filesystem, no injected state. That
|
|
8
|
+
* is the load-bearing half of the design. An event's `id`/`dedupeKey` are
|
|
9
|
+
* derived from `{base, head, declarationDigest}` and nothing else — never from
|
|
10
|
+
* `recordedAt`, `notes`, or `provenance` — so re-running the same transition
|
|
11
|
+
* produces the same event and the store can prove idempotency instead of
|
|
12
|
+
* guessing it. `declarationDigest` covers only the DECLARATIVE parts of a
|
|
13
|
+
* change-intent (`{version, base, projects, edges, constraints}`); free-prose
|
|
14
|
+
* `summary` is excluded because prose is not semantics: two runs whose
|
|
15
|
+
* summary was re-worded must produce the same digest, or the digest would be
|
|
16
|
+
* a function of narration rather than of the declared change.
|
|
17
|
+
*
|
|
18
|
+
* `classifyEvolution(input)` is the one home of the classification predicates
|
|
19
|
+
* (design §2): CHANGE, DRIFT, VIOLATION, REPAIR, DECISION_CHANGE. Each class
|
|
20
|
+
* is a fact about the input, multiple classes are allowed, and the output is
|
|
21
|
+
* sorted. The invariant this module exists to hold is the repository's own:
|
|
22
|
+
* **an empty result is a claim, not a shrug.** An unclassifiable item — an
|
|
23
|
+
* unknown delta entry, one-sided metadata, a verdict that could not be
|
|
24
|
+
* determined — is never folded into a clean result: it raises a `notes[]`
|
|
25
|
+
* disclosure, and where the event carries a verdict-relevant unknown the
|
|
26
|
+
* disposition is `no-verdict`, never a fabricated `accepted`/`rejected`.
|
|
27
|
+
* `[]` classifications appear only for a fully comparable, unchanged pair,
|
|
28
|
+
* and the event says so in notes.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
32
|
+
|
|
33
|
+
import { canonicalizeJson } from "../canonical.mjs";
|
|
34
|
+
|
|
35
|
+
/** The version every event record carries. A different value is a store error. */
|
|
36
|
+
export const EVOLUTION_EVENT_SCHEMA_VERSION = 1;
|
|
37
|
+
|
|
38
|
+
/** The classification vocabulary, one fact per class, sorted lexicographically. */
|
|
39
|
+
export const EVENT_CLASSIFICATIONS = Object.freeze([
|
|
40
|
+
"CHANGE",
|
|
41
|
+
"DRIFT",
|
|
42
|
+
"VIOLATION",
|
|
43
|
+
"REPAIR",
|
|
44
|
+
"DECISION_CHANGE",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** The disposition vocabulary every event may carry. */
|
|
48
|
+
export const EVENT_DISPOSITIONS = Object.freeze(["accepted", "rejected", "no-verdict"]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The canonical tuple an event's identity is built on — the ONE definition,
|
|
52
|
+
* shared by `eventId` (which hashes it) and `eventDedupeKey` (which is it), so
|
|
53
|
+
* the store cannot dedupe on one spelling of the tuple while the id is another.
|
|
54
|
+
*
|
|
55
|
+
* `declarationDigest` participates only when the event carries a declaration:
|
|
56
|
+
* a transition-kind event (history/evolution) has none, and its absence is
|
|
57
|
+
* part of the tuple — an event with a declaration is a different event from
|
|
58
|
+
* the same base/head without one, and must not collide with it.
|
|
59
|
+
*
|
|
60
|
+
* @param {{base: object, head: object, declaration?: {digest?: string}}} event
|
|
61
|
+
* @returns {string} The canonical serialization of the tuple.
|
|
62
|
+
*/
|
|
63
|
+
export function eventDedupeKey(event) {
|
|
64
|
+
return canonicalizeJson({
|
|
65
|
+
base: event.base,
|
|
66
|
+
head: event.head,
|
|
67
|
+
declarationDigest: event.declaration?.digest,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The stable event identity: `sha256` of the canonical tuple. Never depends on
|
|
73
|
+
* `recordedAt`, `notes`, `provenance`, or any wall clock — see the module
|
|
74
|
+
* header for why that exclusion is the idempotency guarantee.
|
|
75
|
+
*
|
|
76
|
+
* @param {{base: object, head: object, declaration?: {digest?: string}}} event
|
|
77
|
+
* @returns {string} 64-hex-char SHA-256.
|
|
78
|
+
*/
|
|
79
|
+
export function eventId(event) {
|
|
80
|
+
return createHash("sha256").update(eventDedupeKey(event)).digest("hex");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The digest of a normalized change-intent's DECLARATIVE parts only:
|
|
85
|
+
* `{version, base, projects, edges, constraints}`. The prose `summary` is
|
|
86
|
+
* excluded by construction — this object never references it, so a re-worded
|
|
87
|
+
* summary cannot change the digest (idempotency: prose is not semantics).
|
|
88
|
+
*
|
|
89
|
+
* The input is the same normalized shape `commands/change-intent.mjs`'s
|
|
90
|
+
* `parseChangeIntent` returns; the digest is built from the whole declarative
|
|
91
|
+
* sections, so any declarative difference — a project row, an edge row, a
|
|
92
|
+
* constraint key, the base commit — changes the digest.
|
|
93
|
+
*
|
|
94
|
+
* @param {{version: string, base: object, projects: object, edges: object,
|
|
95
|
+
* constraints: object, summary?: string}} intent A normalized change-intent.
|
|
96
|
+
* `summary` is accepted (it rides on the normalized intent) and deliberately
|
|
97
|
+
* ignored — prose is not semantics.
|
|
98
|
+
* @returns {string} The canonical serialization of the declarative parts.
|
|
99
|
+
*/
|
|
100
|
+
export function declarationDigest(intent) {
|
|
101
|
+
return canonicalizeJson({
|
|
102
|
+
version: intent.version,
|
|
103
|
+
base: intent.base,
|
|
104
|
+
projects: intent.projects,
|
|
105
|
+
edges: intent.edges,
|
|
106
|
+
constraints: intent.constraints,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @typedef {object} EvolutionEvidence
|
|
112
|
+
* @property {{projects?: {added: string[], removed: string[], changed: string[]},
|
|
113
|
+
* edges?: {added: string[], removed: string[]},
|
|
114
|
+
* policyChanged?: boolean|null, policyOneSided?: boolean,
|
|
115
|
+
* provenanceChanged?: boolean|null}} [observed]
|
|
116
|
+
* The structural diff between base and head: project names and edge identity
|
|
117
|
+
* strings (source,target,type) that were added, removed, or changed. Empty
|
|
118
|
+
* by default. `policyChanged` — whether the policy fingerprint changed
|
|
119
|
+
* between base and head; `null` is "could not be compared": exactly one side
|
|
120
|
+
* records the policy (`policyOneSided: true`) or neither does
|
|
121
|
+
* (both-absent). `true` is a disclosure, never a refusal. `policyOneSided`
|
|
122
|
+
* is the one-sided mirror from `commands/history.mjs` — an input fact, never
|
|
123
|
+
* derived from `policyChanged === null`, because both-sides-absent also
|
|
124
|
+
* yields `null` while remaining comparable. `provenanceChanged` — whether
|
|
125
|
+
* the commit record advanced between base and head; it is `true` only when
|
|
126
|
+
* both sides record provenance and the commits differ.
|
|
127
|
+
* @property {boolean} [codeDrift]
|
|
128
|
+
* Whether provenance advanced with no architectural or policy change. The
|
|
129
|
+
* caller computes it under `commands/history.mjs`'s discipline — only when
|
|
130
|
+
* the architecture did not move, the policy was ACTUALLY compared, and the
|
|
131
|
+
* policy did not change; a caller that passes `true` alongside a
|
|
132
|
+
* `policyChanged` of `null` gets a loud note and no DRIFT, never a guess.
|
|
133
|
+
* @property {{introduced?: {id: string, waived: boolean}[],
|
|
134
|
+
* resolved?: string[], unknown?: {id: string, reason: string}[]}} [violations]
|
|
135
|
+
* The delta classification of violations: introduced (with each entry's
|
|
136
|
+
* waiver state), resolved, and unknown. Unknown entries are the
|
|
137
|
+
* verdict-relevant unclassifiable items — they force `no-verdict`.
|
|
138
|
+
* @property {{id: string, verdict: "pass"|"fail"|"unknown"}[]} [declaredConstraints]
|
|
139
|
+
* Declared constraint verdicts. `fail` is a VIOLATION; any other value than
|
|
140
|
+
* `pass`/`fail` is an undeterminable verdict — note + `no-verdict`.
|
|
141
|
+
* @property {{id: string,
|
|
142
|
+
* verdict: "matched"|"undeclared"|"unfulfilled"|"unproven"}[]} [declaredIntentRows]
|
|
143
|
+
* Declared change-intent rows and the head's verdict on each (the
|
|
144
|
+
* `matched|undeclared|unfulfilled|unproven` vocabulary). `undeclared` and
|
|
145
|
+
* `unfulfilled` are DRIFT and reject; `unproven` is note + `no-verdict`.
|
|
146
|
+
* @property {string[]} [driftFindingsResolved]
|
|
147
|
+
* Drift findings that no longer exist at head — a REPAIR signal.
|
|
148
|
+
* @property {string[]} [debtResolved]
|
|
149
|
+
* Active debt entries closed between base and head — a REPAIR signal.
|
|
150
|
+
* @property {{records: {id: string, status: string, supersedes: string[]}[]}|null} [adrBase]
|
|
151
|
+
* The ADR registry at base, as `{records: …}` (the shape
|
|
152
|
+
* `adr-registry.mjs`'s `loadAdrRegistry` returns), or `null` when the side
|
|
153
|
+
* does not record a decision registry. `undefined` = the input carried no
|
|
154
|
+
* decision evidence at all (not one-sided).
|
|
155
|
+
* @property {{records: {id: string, status: string, supersedes: string[]}[]}|null} [adrHead]
|
|
156
|
+
* The ADR registry at head, or `null` when absent. Exactly one side `null`
|
|
157
|
+
* is the one-sided case: DECISION_CHANGE is NOT asserted and a note is
|
|
158
|
+
* added.
|
|
159
|
+
*/
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The classification result.
|
|
163
|
+
*
|
|
164
|
+
* @typedef {object} EvolutionClassification
|
|
165
|
+
* @property {string[]} classifications Sorted; a subset of
|
|
166
|
+
* `EVENT_CLASSIFICATIONS`. `[]` only for a fully comparable, unchanged pair
|
|
167
|
+
* (with a note saying so) — never for an input that could not be classified.
|
|
168
|
+
* @property {"accepted"|"rejected"|"no-verdict"} disposition
|
|
169
|
+
* `no-verdict` on any verdict-relevant unknown; `rejected` on introduced
|
|
170
|
+
* non-waived violations, a declared constraint `fail`, or undeclared /
|
|
171
|
+
* unfulfilled declared-intent rows; `accepted` otherwise.
|
|
172
|
+
* @property {string[]} notes Disclosure notes — one-sided metadata, unknown
|
|
173
|
+
* entries, the "fully comparable, unchanged" statement, the policy-change
|
|
174
|
+
* disclosure.
|
|
175
|
+
* @property {{projects: string[], boundaries: string[], constraints: string[],
|
|
176
|
+
* decisions: string[]}} affected Identity strings only, each sorted and
|
|
177
|
+
* de-duplicated.
|
|
178
|
+
*/
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Classifies one evolution event from its evidence signals — deterministic
|
|
182
|
+
* and pure (no clock, no fs). Each predicate is a fact about the input,
|
|
183
|
+
* never an inference (design §2):
|
|
184
|
+
*
|
|
185
|
+
* | Class | Predicate (all must hold) |
|
|
186
|
+
* |---|---|
|
|
187
|
+
* | CHANGE | structural diff between base/head non-empty |
|
|
188
|
+
* | DRIFT | `codeDrift` signal, OR declared intent rows `undeclared`/`unfulfilled`
|
|
189
|
+
* by the head; and NOT merely a policy-only transition; and no
|
|
190
|
+
* DECISION_CHANGE asserted (a supersession is DECISION_CHANGE, never DRIFT) |
|
|
191
|
+
* | VIOLATION | introduced violations non-empty with ≥1 not-waived, OR a declared
|
|
192
|
+
* constraint verdict `fail` |
|
|
193
|
+
* | REPAIR | resolved violations non-empty, OR drift findings resolved, OR active
|
|
194
|
+
* debt entries closed |
|
|
195
|
+
* | DECISION_CHANGE | same ADR id with a different status between base/head, or a
|
|
196
|
+
* new `supersedes` relation. REQUIRES both sides' registries — either side
|
|
197
|
+
* absent (`null`) ⇒ NOT asserted, note added |
|
|
198
|
+
*
|
|
199
|
+
* The `affected` identities are derived from the same signals, never from a
|
|
200
|
+
* second opinion: changed project names, changed edge identity strings, the
|
|
201
|
+
* constraint/intent rows whose verdict was not `pass`/`matched`, and the ADR
|
|
202
|
+
* ids whose lineage moved.
|
|
203
|
+
*
|
|
204
|
+
* @param {EvolutionEvidence} [input]
|
|
205
|
+
* @returns {EvolutionClassification}
|
|
206
|
+
*/
|
|
207
|
+
export function classifyEvolution(input = {}) {
|
|
208
|
+
const observed = input.observed ?? {};
|
|
209
|
+
const projects = observed.projects ?? { added: [], removed: [], changed: [] };
|
|
210
|
+
const edges = observed.edges ?? { added: [], removed: [] };
|
|
211
|
+
const addedProjects = projects.added ?? [];
|
|
212
|
+
const removedProjects = projects.removed ?? [];
|
|
213
|
+
const changedProjects = projects.changed ?? [];
|
|
214
|
+
const addedEdges = edges.added ?? [];
|
|
215
|
+
const removedEdges = edges.removed ?? [];
|
|
216
|
+
const structureChanged =
|
|
217
|
+
addedProjects.length +
|
|
218
|
+
removedProjects.length +
|
|
219
|
+
changedProjects.length +
|
|
220
|
+
addedEdges.length +
|
|
221
|
+
removedEdges.length >
|
|
222
|
+
0;
|
|
223
|
+
|
|
224
|
+
const codeDrift = input.codeDrift === true;
|
|
225
|
+
const policyChanged = observed.policyChanged ?? false;
|
|
226
|
+
// One-sided is an input fact (`snapshot-meta`'s `policyOneSided`), never
|
|
227
|
+
// derived from `policyChanged === null`: both-absent also yields `null` but
|
|
228
|
+
// is comparable, and deriving one-sided from it would disclose a refusal
|
|
229
|
+
// that is not real.
|
|
230
|
+
const policyOneSided = observed.policyOneSided === true;
|
|
231
|
+
// Provenance actually advanced (both sides record a commit and they
|
|
232
|
+
// differ). Pair an unverifiable policy with advancing provenance and the
|
|
233
|
+
// transition carried real code motion that cannot be classified — never a
|
|
234
|
+
// "fully comparable, unchanged pair".
|
|
235
|
+
const provenanceAdvanced = observed.provenanceChanged === true;
|
|
236
|
+
|
|
237
|
+
const violations = input.violations ?? {};
|
|
238
|
+
const introduced = violations.introduced ?? [];
|
|
239
|
+
const resolved = violations.resolved ?? [];
|
|
240
|
+
const unknown = violations.unknown ?? [];
|
|
241
|
+
const declaredConstraints = input.declaredConstraints ?? [];
|
|
242
|
+
const declaredIntentRows = input.declaredIntentRows ?? [];
|
|
243
|
+
const driftFindingsResolved = input.driftFindingsResolved ?? [];
|
|
244
|
+
const debtResolved = input.debtResolved ?? [];
|
|
245
|
+
|
|
246
|
+
const adrBase = input.adrBase;
|
|
247
|
+
const adrHead = input.adrHead;
|
|
248
|
+
// One-sided mirror of history's policyOneSided: exactly ONE side carries a
|
|
249
|
+
// registry. Both sides absent ("no decision evidence supplied", like
|
|
250
|
+
// snapshots that record no fingerprint on either side) is comparable —
|
|
251
|
+
// nothing was asserted, and there is nothing to disclose. Either side
|
|
252
|
+
// `null` (the side does not record one) while the other carries the
|
|
253
|
+
// registry is "could not be compared".
|
|
254
|
+
const decisionOneSided = (adrBase == null) !== (adrHead == null);
|
|
255
|
+
|
|
256
|
+
const notes = [];
|
|
257
|
+
const classifications = new Set();
|
|
258
|
+
const constraintIds = [];
|
|
259
|
+
const decisionIds = [];
|
|
260
|
+
|
|
261
|
+
if (structureChanged) {
|
|
262
|
+
classifications.add("CHANGE");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Disclosures first, so every note is in the record even when a predicate
|
|
266
|
+
// below has nothing to add — a transition's notes must not depend on its
|
|
267
|
+
// classes. The wording mirrors `commands/history.mjs`'s `classifyTransition`
|
|
268
|
+
// for the same signals, so the two surfaces read the same facts the same way.
|
|
269
|
+
if (policyChanged === true) {
|
|
270
|
+
notes.push(
|
|
271
|
+
"policy (the declared architectural intent) changed between these snapshots — " +
|
|
272
|
+
"the boundary law differs even though the graph may not",
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (policyOneSided) {
|
|
276
|
+
notes.push(
|
|
277
|
+
"policy (the declared architectural intent) could not be compared — one side of " +
|
|
278
|
+
"the transition records the boundary law and the other does not",
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (observed.policyChanged === null && provenanceAdvanced) {
|
|
282
|
+
notes.push(
|
|
283
|
+
"policy (the declared architectural intent) could not be compared — neither side " +
|
|
284
|
+
"records the boundary law while the provenance advanced, so the pair cannot be " +
|
|
285
|
+
"classified (code motion may be hidden)",
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
if (decisionOneSided) {
|
|
289
|
+
notes.push(
|
|
290
|
+
"the decision registry (docs/adr) could not be compared — one side of the " +
|
|
291
|
+
"transition records it and the other does not",
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Verdict-relevant unknowns: an entry the input cannot classify is NEVER
|
|
296
|
+
// folded into a clean result. Each one is disclosed, and any of them makes
|
|
297
|
+
// the disposition `no-verdict` (never a fabricated accepted/rejected).
|
|
298
|
+
let verdictRelevantUnknown = false;
|
|
299
|
+
for (const entry of unknown) {
|
|
300
|
+
verdictRelevantUnknown = true;
|
|
301
|
+
notes.push(
|
|
302
|
+
`delta entry '${entry.id}' could not be classified` +
|
|
303
|
+
(typeof entry.reason === "string" && entry.reason !== "" ? ` — ${entry.reason}` : ""),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
for (const row of declaredConstraints) {
|
|
307
|
+
constraintIds.push(row.id);
|
|
308
|
+
if (row.verdict !== "pass" && row.verdict !== "fail") {
|
|
309
|
+
verdictRelevantUnknown = true;
|
|
310
|
+
notes.push(
|
|
311
|
+
`declared constraint '${row.id}' verdict '${row.verdict}' could not be determined`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
for (const row of declaredIntentRows) {
|
|
316
|
+
if (
|
|
317
|
+
row.verdict !== "matched" &&
|
|
318
|
+
row.verdict !== "undeclared" &&
|
|
319
|
+
row.verdict !== "unfulfilled" &&
|
|
320
|
+
row.verdict !== "unproven"
|
|
321
|
+
) {
|
|
322
|
+
verdictRelevantUnknown = true;
|
|
323
|
+
notes.push(`declared intent row '${row.id}' carries an unknown verdict '${row.verdict}'`);
|
|
324
|
+
} else if (row.verdict === "unproven") {
|
|
325
|
+
verdictRelevantUnknown = true;
|
|
326
|
+
notes.push(
|
|
327
|
+
`declared intent row '${row.id}' is unproven — its verdict could not be determined`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (row.verdict !== "matched") {
|
|
331
|
+
constraintIds.push(row.id);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// VIOLATION — introduced non-waived entries are the violations that count; a
|
|
336
|
+
// waived introduction is a disclosed, non-gating fact, never a violation —
|
|
337
|
+
// and an introduction whose every entry is waived is disclosed too, so the
|
|
338
|
+
// empty classification below never reads as "nothing moved".
|
|
339
|
+
const introducedNotWaived = introduced.filter((entry) => entry.waived !== true);
|
|
340
|
+
const failedConstraints = declaredConstraints.filter((row) => row.verdict === "fail");
|
|
341
|
+
if (introducedNotWaived.length > 0 || failedConstraints.length > 0) {
|
|
342
|
+
classifications.add("VIOLATION");
|
|
343
|
+
}
|
|
344
|
+
if (introduced.length > 0 && introducedNotWaived.length === 0) {
|
|
345
|
+
notes.push("introduced violations are all waived — not classified as VIOLATION");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// REPAIR — resolved violations, resolved drift findings, or closed debt.
|
|
349
|
+
if (resolved.length > 0 || driftFindingsResolved.length > 0 || debtResolved.length > 0) {
|
|
350
|
+
classifications.add("REPAIR");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// DECISION_CHANGE — the ADR lineage moved between base and head. Both sides'
|
|
354
|
+
// registries are REQUIRED: either side absent is the one-sided case, and
|
|
355
|
+
// "could not be compared" is disclosed, never asserted as a change.
|
|
356
|
+
if (!decisionOneSided) {
|
|
357
|
+
const baseRecords = adrBase?.records ?? [];
|
|
358
|
+
const headRecords = adrHead?.records ?? [];
|
|
359
|
+
const baseStatus = new Map(baseRecords.map((record) => [record.id, record.status]));
|
|
360
|
+
const baseSupersedes = new Map(
|
|
361
|
+
baseRecords.map((record) => [record.id, new Set(record.supersedes ?? [])]),
|
|
362
|
+
);
|
|
363
|
+
for (const record of headRecords) {
|
|
364
|
+
if (baseStatus.has(record.id) && baseStatus.get(record.id) !== record.status) {
|
|
365
|
+
decisionIds.push(record.id);
|
|
366
|
+
}
|
|
367
|
+
const relationsAtBase = baseSupersedes.get(record.id) ?? new Set();
|
|
368
|
+
for (const target of record.supersedes ?? []) {
|
|
369
|
+
if (!relationsAtBase.has(target)) {
|
|
370
|
+
decisionIds.push(record.id, target);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (decisionIds.length > 0) {
|
|
375
|
+
classifications.add("DECISION_CHANGE");
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// DRIFT — the one predicate with a guard and an exclusion. The guard: not
|
|
380
|
+
// merely a policy-only transition (a policy change with no other signal is
|
|
381
|
+
// disclosed, never DRIFT). The exclusion: a supersession between two
|
|
382
|
+
// registry states is DECISION_CHANGE, never DRIFT (design §9), so the code
|
|
383
|
+
// drift signal is not ALSO classified DRIFT when the lineage moved.
|
|
384
|
+
if (!classifications.has("DECISION_CHANGE")) {
|
|
385
|
+
const otherSignals =
|
|
386
|
+
structureChanged ||
|
|
387
|
+
codeDrift ||
|
|
388
|
+
introduced.length > 0 ||
|
|
389
|
+
resolved.length > 0 ||
|
|
390
|
+
unknown.length > 0 ||
|
|
391
|
+
driftFindingsResolved.length > 0 ||
|
|
392
|
+
debtResolved.length > 0 ||
|
|
393
|
+
declaredConstraints.some((row) => row.verdict !== "pass") ||
|
|
394
|
+
declaredIntentRows.some((row) => row.verdict !== "matched");
|
|
395
|
+
const merelyPolicyOnly = policyChanged === true && !otherSignals;
|
|
396
|
+
const intentViolated = declaredIntentRows.filter(
|
|
397
|
+
(row) => row.verdict === "undeclared" || row.verdict === "unfulfilled",
|
|
398
|
+
);
|
|
399
|
+
let drifted = intentViolated.length > 0;
|
|
400
|
+
if (codeDrift) {
|
|
401
|
+
if (policyOneSided) {
|
|
402
|
+
notes.push("code drift cannot be asserted — the policy change could not be compared");
|
|
403
|
+
} else if (!merelyPolicyOnly) {
|
|
404
|
+
drifted = true;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (drifted) {
|
|
408
|
+
classifications.add("DRIFT");
|
|
409
|
+
for (const row of intentViolated) {
|
|
410
|
+
constraintIds.push(row.id);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// The disposition: `no-verdict` on any verdict-relevant unknown (never a
|
|
416
|
+
// fabricated accepted/rejected), `rejected` on introduced non-waived
|
|
417
|
+
// violations, a declared constraint `fail`, or undeclared / unfulfilled
|
|
418
|
+
// declared-intent rows, `accepted` otherwise — including the fully
|
|
419
|
+
// comparable, unchanged pair.
|
|
420
|
+
/** @type {"accepted"|"rejected"|"no-verdict"} */
|
|
421
|
+
let disposition = "accepted";
|
|
422
|
+
if (verdictRelevantUnknown) {
|
|
423
|
+
disposition = "no-verdict";
|
|
424
|
+
} else if (
|
|
425
|
+
introducedNotWaived.length > 0 ||
|
|
426
|
+
failedConstraints.length > 0 ||
|
|
427
|
+
declaredIntentRows.some((row) => row.verdict === "undeclared" || row.verdict === "unfulfilled")
|
|
428
|
+
) {
|
|
429
|
+
disposition = "rejected";
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const classificationsList = [...classifications].sort();
|
|
433
|
+
|
|
434
|
+
// **An empty result is a claim.** `[]` classifications appear ONLY for a
|
|
435
|
+
// fully comparable, unchanged pair, and the event says so; an input that
|
|
436
|
+
// could not be fully classified never reads as a clean "nothing happened".
|
|
437
|
+
// Each other empty-list case carries its own disclosure (the one-sided
|
|
438
|
+
// notes, the waiver note, the unknown-entry notes), so no branch of this
|
|
439
|
+
// block produces a silent `[]`.
|
|
440
|
+
if (classificationsList.length === 0) {
|
|
441
|
+
const signaledButUnclassified =
|
|
442
|
+
introduced.length > 0 ||
|
|
443
|
+
resolved.length > 0 ||
|
|
444
|
+
driftFindingsResolved.length > 0 ||
|
|
445
|
+
debtResolved.length > 0;
|
|
446
|
+
if (
|
|
447
|
+
!verdictRelevantUnknown &&
|
|
448
|
+
!signaledButUnclassified &&
|
|
449
|
+
!policyOneSided &&
|
|
450
|
+
!decisionOneSided &&
|
|
451
|
+
!(observed.policyChanged === null && provenanceAdvanced)
|
|
452
|
+
) {
|
|
453
|
+
notes.push("a fully comparable, unchanged pair — no classification applies");
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const uniqueSorted = (values) => [...new Set(values)].sort();
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
classifications: classificationsList,
|
|
461
|
+
disposition,
|
|
462
|
+
notes,
|
|
463
|
+
affected: {
|
|
464
|
+
projects: uniqueSorted([...addedProjects, ...removedProjects, ...changedProjects]),
|
|
465
|
+
boundaries: uniqueSorted([...addedEdges, ...removedEdges]),
|
|
466
|
+
constraints: uniqueSorted(constraintIds),
|
|
467
|
+
decisions: uniqueSorted(decisionIds),
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
}
|