@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.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/cli.mjs +258 -20
  3. package/package.json +2 -2
  4. package/src/architecture-intent/judge.mjs +19 -6
  5. package/src/commands/adr.mjs +45 -4
  6. package/src/commands/change-intent.mjs +55 -8
  7. package/src/commands/change.mjs +332 -11
  8. package/src/commands/debt.mjs +26 -5
  9. package/src/commands/decisions.mjs +291 -0
  10. package/src/commands/delta-classify.mjs +257 -0
  11. package/src/commands/delta.mjs +269 -8
  12. package/src/commands/evolution.mjs +758 -5
  13. package/src/commands/explain.mjs +207 -1
  14. package/src/commands/history.mjs +81 -5
  15. package/src/commands/plan-context-command.mjs +163 -2
  16. package/src/commands/provenance-command.mjs +86 -17
  17. package/src/commands/provenance.mjs +60 -0
  18. package/src/commands/report.mjs +48 -1
  19. package/src/commands/trajectory.mjs +89 -3
  20. package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
  21. package/src/governance/adr-registry.mjs +252 -15
  22. package/src/governance/debt-ledger.mjs +261 -19
  23. package/src/governance/decision-fitness.mjs +213 -0
  24. package/src/governance/decision-graph.mjs +483 -0
  25. package/src/governance/decision-lineage.mjs +250 -0
  26. package/src/governance/evolution-event.mjs +470 -0
  27. package/src/governance/evolution-store.mjs +362 -0
  28. package/src/governance/provenance-record.mjs +150 -0
  29. package/src/providers/native/model.mjs +18 -4
  30. package/src/report/adr-text.mjs +109 -4
  31. package/src/report/change-text.mjs +21 -3
  32. package/src/report/debt-text.mjs +42 -6
  33. package/src/report/decisions-text.mjs +164 -0
  34. package/src/report/delta-text.mjs +36 -1
  35. package/src/report/evolution-text.mjs +231 -2
  36. package/src/report/explain-text.mjs +122 -1
  37. package/src/report/history-text.mjs +9 -3
  38. package/src/report/plan-context-text.mjs +94 -0
  39. package/src/report/provenance-text.mjs +67 -1
  40. package/src/report/report-text.mjs +53 -18
  41. package/src/report/snapshot-text.mjs +35 -1
  42. 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
+ }