@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
@@ -15,10 +15,90 @@ import { ADR_DIR, ADR_STATUSES } from "../governance/adr-registry.mjs";
15
15
  /** A status label for a record, in the text a human reads. */
16
16
  function statusLabel(status) {
17
17
  if (status === "accepted") return "accepted";
18
+ if (status === "active") return "active";
18
19
  if (status === "superseded") return "superseded";
20
+ if (status === "retired") return "retired";
19
21
  return "proposed";
20
22
  }
21
23
 
24
+ /**
25
+ * The `fitness:` line for one record — the per-decision verification level
26
+ * the command derives (`../governance/decision-fitness.mjs` owns the
27
+ * vocabulary). The line is always printed with its reason when the level has
28
+ * one; an `enforced` level carries none and says so. This command alone can
29
+ * never verify anything (its header's "What it cannot assert" owns why), so
30
+ * the level comes in precomputed from the caller — absent it, the line is an
31
+ * explicit "(not measured)" that cannot be mistaken for a verdict.
32
+ *
33
+ * @param {{level: string, verified: boolean, reason?: string}|undefined} fitness
34
+ * @returns {string}
35
+ */
36
+ function fitnessLine(fitness) {
37
+ if (fitness === undefined) return `fitness: ${"(not measured)"}`;
38
+ if (fitness.verified)
39
+ return `fitness: ${fitness.level} — verified true: bound constraints resolve and pass`;
40
+ if (typeof fitness.reason === "string") {
41
+ return `fitness: ${fitness.level} — ${fitness.reason}`;
42
+ }
43
+ return `fitness: ${fitness.level}`;
44
+ }
45
+
46
+ /**
47
+ * The `created:` / `updated:` metadata lines for one record, when the record
48
+ * carries them. Absent frontmatter stays absent — these are the decision's
49
+ * own committed timeline, never a wall-clock guess.
50
+ *
51
+ * @param {object} record
52
+ * @returns {string[]}
53
+ */
54
+ function timelineLines(record) {
55
+ const lines = [];
56
+ if (typeof record.created === "string") lines.push(`created: ${record.created}`);
57
+ if (typeof record.updated === "string") lines.push(`updated: ${record.updated}`);
58
+ return lines;
59
+ }
60
+
61
+ /**
62
+ * The reverse lineage line — the records whose `supersedes` names this one,
63
+ * derived at load by the registry and therefore always consistent with the
64
+ * forward `supersedes:` line on the other record.
65
+ *
66
+ * @param {object} record
67
+ * @returns {string[]}
68
+ */
69
+ function supersededByLines(record) {
70
+ if (!Array.isArray(record.supersededBy) || record.supersededBy.length === 0) return [];
71
+ return [`supersededBy: ${record.supersededBy.join(", ")}`];
72
+ }
73
+
74
+ /**
75
+ * One prose field rendered for the single-record view: the label on the
76
+ * first line, continued lines aligned under the prose. A field that is
77
+ * absent or blank renders nothing — the record's own body decides.
78
+ *
79
+ * @param {string} label The 12-wide `label:` prefix.
80
+ * @param {string} text The field's markdown prose.
81
+ * @returns {string[]}
82
+ */
83
+ function proseLines(label, text) {
84
+ const content = text.split("\n").map((line) => line.trimEnd());
85
+ const out = [`${label}${content[0]}`];
86
+ for (const line of content.slice(1)) {
87
+ out.push(`${" ".repeat(label.length)}${line}`);
88
+ }
89
+ return out;
90
+ }
91
+
92
+ /** The prose fields of one record, in the order §3.2 of the wave 2 contract names them. */
93
+ const PROSE_FIELDS = [
94
+ ["context: ", "context"],
95
+ ["decision: ", "decision"],
96
+ ["rationale: ", "rationale"],
97
+ ["alternatives:", "alternatives"],
98
+ ["consequences:", "consequences"],
99
+ ["assumptions: ", "assumptions"],
100
+ ];
101
+
22
102
  /**
23
103
  * The `bindings:` line for one record — the ONE place either face decides how
24
104
  * a binding is rendered.
@@ -54,21 +134,26 @@ function bindingsLine(record, known) {
54
134
  * Renders the whole registry: one block per record, each naming its status,
55
135
  * its supersession chain, and the rules/fitnesses it binds.
56
136
  *
57
- * @param {{records: object[], knownFitness?: Set<string>}} result
137
+ * @param {{records: object[], knownFitness?: Set<string>, fitnessById?:
138
+ * Map<string, {level: string, verified: boolean, reason?: string}>}} result
58
139
  * @returns {string}
59
140
  */
60
- export function formatAdrDump({ records, knownFitness }) {
141
+ export function formatAdrDump({ records, knownFitness, fitnessById }) {
61
142
  if (records.length === 0) {
62
143
  return `no ADRs in ${ADR_DIR}/ — nothing is recorded, and nothing is enforceable through it`;
63
144
  }
64
145
  const known = knownFitness ?? new Set();
146
+ const fitnessMap = fitnessById ?? new Map();
65
147
  const blocks = records.map((record) => {
66
148
  const header = `${record.id} (${statusLabel(record.status)})`;
67
149
  const lines = [header, "-".repeat(header.length)];
68
150
  if (record.supersedes.length > 0) {
69
151
  lines.push(`supersedes: ${record.supersedes.join(", ")}`);
70
152
  }
153
+ lines.push(...supersededByLines(record));
154
+ lines.push(...timelineLines(record));
71
155
  lines.push(bindingsLine(record, known));
156
+ lines.push(fitnessLine(fitnessMap.get(record.id)));
72
157
  lines.push(`status set: ${ADR_STATUSES.join(", ")}`);
73
158
  return lines.join("\n");
74
159
  });
@@ -110,20 +195,40 @@ export function formatAdrMissing({ adrId }) {
110
195
  * a reader who asked about one record sees strictly less than one who dumped
111
196
  * the registry otherwise, and what they stop seeing is the marker.
112
197
  *
113
- * @param {{id: string, status: string, supersedes: string[], bindings: string[]}} record
198
+ * Beyond the dump's lines it also renders the record's own prose (context,
199
+ * decision, rationale, alternatives, consequences, assumptions) when the body
200
+ * records them — this is the view a reader asking "why was this decided" gets.
201
+ *
202
+ * @param {{id: string, status: string, supersedes: string[], supersededBy?:
203
+ * string[], bindings: string[], created?: string, updated?: string, context?:
204
+ * string, decision?: string, rationale?: string, alternatives?: string,
205
+ * consequences?: string, assumptions?: string}} record
114
206
  * @param {Set<string>} [knownFitness] The ids the registry's records mention.
115
207
  * Optional only so the parameter can be omitted where there is nothing to
116
208
  * compare against; an absent set marks every binding rather than none,
117
209
  * because "nothing corroborates this" is the honest answer when no set was
118
210
  * supplied — never the quiet one.
211
+ * @param {Map<string, {level: string, verified: boolean, reason?: string}>}
212
+ * [fitnessById] The fitness level per record id, computed by the caller
213
+ * (`../commands/adr.mjs`). Absent, a record's fitness line reads as the
214
+ * explicit "(not measured)" — never as a verdict this renderer invented.
119
215
  * @returns {string}
120
216
  */
121
- export function formatAdrRecord(record, knownFitness) {
217
+ export function formatAdrRecord(record, knownFitness, fitnessById) {
122
218
  const header = `${record.id} (${statusLabel(record.status)})`;
123
219
  const lines = [header, "-".repeat(header.length)];
124
220
  if (record.supersedes.length > 0) {
125
221
  lines.push(`supersedes: ${record.supersedes.join(", ")}`);
126
222
  }
223
+ lines.push(...supersededByLines(record));
224
+ lines.push(...timelineLines(record));
127
225
  lines.push(bindingsLine(record, knownFitness ?? new Set()));
226
+ lines.push(fitnessLine(fitnessById?.get(record.id)));
227
+ for (const [label, key] of PROSE_FIELDS) {
228
+ const text = record[key];
229
+ if (typeof text === "string" && text.trim() !== "") {
230
+ lines.push(...proseLines(label, text));
231
+ }
232
+ }
128
233
  return lines.join("\n");
129
234
  }
@@ -13,6 +13,11 @@
13
13
  * outcome, so an empty reconciliation is a verifiable claim rather than
14
14
  * silence (`../../../../AGENTS.md`).
15
15
  *
16
+ * With `--event-out`, the report's first section names the reconcile event
17
+ * that was recorded (id, directory, duplicate-or-written) — the one section
18
+ * that renders only when the flag was passed, so the default report is
19
+ * byte-identical to a pre-wave-3 run.
20
+ *
16
21
  * This module decides nothing. A formatter that filtered would be a rule
17
22
  * wearing a formatter's name (`./README.md`).
18
23
  */
@@ -62,14 +67,27 @@ function describeOrigin(provenance) {
62
67
  /**
63
68
  * The whole change report.
64
69
  *
65
- * @param {{change: object, coverage: object}} input `change` is
66
- * `../commands/change.mjs`'s result payload; `coverage` its coverage block.
70
+ * @param {{change: object, coverage: object, eventWritten?: object}} input
71
+ * `change` is `../commands/change.mjs`'s result payload; `coverage` its
72
+ * coverage block; `eventWritten` the reconcile event write result (`{dir,
73
+ * id, duplicate}`) — present ONLY when `--event-out` was passed, so the
74
+ * default report is byte-identical to a pre-wave-3 run.
67
75
  * @returns {string}
68
76
  */
69
- export function formatChangeReport({ change, coverage }) {
77
+ export function formatChangeReport({ change, coverage, eventWritten }) {
70
78
  const { intent, baseline, head, reconciliation, constraints, policy } = change;
71
79
  const sections = [];
72
80
 
81
+ // The reconcile event line renders ONLY when `--event-out` was passed and
82
+ // the run wrote one (`../commands/change.mjs`): the write is opt-in output
83
+ // the report makes observable rather than a file appearing silently.
84
+ if (eventWritten !== undefined) {
85
+ sections.push(
86
+ `event reconcile/change ${eventWritten.id.slice(0, 8)} → ${eventWritten.dir}` +
87
+ (eventWritten.duplicate ? " (duplicate — nothing written)" : ""),
88
+ );
89
+ }
90
+
73
91
  sections.push(
74
92
  `intent ${intent.file} — base ${intent.base.commit.slice(0, 8)}` +
75
93
  (intent.summary === undefined ? "" : `\n "${intent.summary}"`),
@@ -41,8 +41,11 @@ function sanitize(text) {
41
41
  *
42
42
  * @param {{ledger: {dir: string, snapshots: number, agings: boolean,
43
43
  * sampleTime: string, entries: {source: string, kind: string,
44
- * severity: string, age: number, count: number, remediationHint: string}[],
45
- * total: number, byKind: object, bySeverity: object},
44
+ * severity: string, age: number, count: number, remediationHint: string,
45
+ * id: string, status: string, introducedBy?: string}[],
46
+ * resolved?: {id: string, status: string, resolvedBy: string}[],
47
+ * total: number, byKind: object, bySeverity: object,
48
+ * lifecycle?: {linked: boolean, note: string|null}},
46
49
  * coverage: object}} input
47
50
  * @returns {string}
48
51
  */
@@ -60,15 +63,14 @@ export function formatDebtReport({ ledger, coverage }) {
60
63
 
61
64
  const orderedKinds = [
62
65
  ["waiver", "waivers (accepted boundary violations)"],
66
+ ["expired-waiver", "expired waivers (accepted violations that lapsed back into force)"],
63
67
  ["aspirational-gap", "aspirational gaps (optional allowed rows not built)"],
64
68
  ["drift", "drift findings"],
65
69
  ["unresolved", "unresolved intent"],
66
70
  ];
67
- let sawAny = false;
68
71
  for (const [kind, label] of orderedKinds) {
69
72
  const items = ledger.entries.filter((e) => e.kind === kind);
70
73
  if (items.length === 0) continue;
71
- sawAny = true;
72
74
  sections.push(`${items.length} ${label}:`);
73
75
  for (const entry of items) {
74
76
  const age = ledger.agings ? `age ${entry.age}` : "age not yet established";
@@ -76,17 +78,51 @@ export function formatDebtReport({ ledger, coverage }) {
76
78
  ` [${entry.kind}] ${entry.severity} ${sanitize(entry.source)} (${age}, count ${entry.count})`,
77
79
  );
78
80
  sections.push(` ${sanitize(entry.remediationHint)}`);
81
+ // The lifecycle fields (design §6) ride the entry as appended lines only;
82
+ // every existing line above keeps its exact bytes. When no event store is
83
+ // linked, the id/status are still printed (they are facts about the entry,
84
+ // always determinable); refs are printed only when actually present.
85
+ sections.push(` id ${entry.id} · status ${entry.status}`);
86
+ if (entry.introducedBy) sections.push(` introducedBy ${entry.introducedBy}`);
79
87
  }
80
88
  }
81
-
82
- if (!sawAny) {
89
+ // The positive claim must be byte-truthful: no CURRENT findings AND no
90
+ // retained resolution history. A ledger with an empty entry list but a
91
+ // non-empty resolved list is not "no architecture debt" — it has history
92
+ // that was resolved and is retained below (F-DEB-4).
93
+ const resolved = ledger.resolved ?? [];
94
+ if (ledger.entries.length === 0 && resolved.length === 0) {
83
95
  sections.push(
84
96
  "✔ no architecture debt — no waivers, aspirational gaps, drift or unresolved intent",
85
97
  );
98
+ } else if (ledger.entries.length === 0 && resolved.length > 0) {
99
+ sections.push(
100
+ `no current architecture debt; ${resolved.length} resolved entry${resolved.length === 1 ? "" : "s"} retained below`,
101
+ );
102
+ }
103
+
104
+ // The resolved surface (design §6): debt whose candidate fact is gone at
105
+ // head AND closed by evidence (a REPAIR event). Entries are retained — the
106
+ // history is never deleted — but they are no longer current findings. This
107
+ // list is empty (and no line is printed) when no event store is linked or
108
+ // nothing has been resolved.
109
+ if (resolved.length > 0) {
110
+ sections.push(`${resolved.length} resolved (no longer current findings):`);
111
+ for (const entry of resolved) {
112
+ // The resolved surface is evidence-backed only — id/status/resolvedBy
113
+ // (F-DEB-2). The kind/severity/hint of the original entry live in the
114
+ // history snapshots the ledger read, never in this row.
115
+ sections.push(` id ${entry.id} · status ${entry.status}`);
116
+ if (entry.resolvedBy) sections.push(` resolvedBy ${entry.resolvedBy}`);
117
+ }
118
+ }
119
+ if (ledger.lifecycle?.note) {
120
+ sections.push(ledger.lifecycle.note);
86
121
  }
87
122
 
88
123
  sections.push(
89
124
  `total ${ledger.total} ${word} · byKind: waiver ${ledger.byKind.waiver}, ` +
125
+ `expired-waiver ${ledger.byKind["expired-waiver"]}, ` +
90
126
  `aspirational-gap ${ledger.byKind["aspirational-gap"]}, drift ${ledger.byKind.drift}, ` +
91
127
  `unresolved ${ledger.byKind.unresolved} · bySeverity: high ${ledger.bySeverity.high}, ` +
92
128
  `medium ${ledger.bySeverity.medium}, low ${ledger.bySeverity.low}`,
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The `decisions` command's text renderer — the deterministic chain
3
+ * decision → governed rows → projects → findings → fitness, one function a
4
+ * test drives for both the resolved and the unresolved walk.
5
+ *
6
+ * The renderer owns the invariant: a chain that could not walk every hop
7
+ * (`walk.ok === false`) renders a loud unresolved block, never a clean-looking
8
+ * chain. The walk (`../governance/decision-graph.mjs`) reports each
9
+ * unresolved reference with a reason; this face prints them all, so a reader
10
+ * sees which hop broke and why — the same refrain as every other surface in
11
+ * this wave ("an empty result is a claim, not a shrug").
12
+ *
13
+ * It is a pure function of the walk result + the record + the fitness level;
14
+ * it reads no files. The fitness line reuses the `fitness:` byte convention
15
+ * `./adr-text.mjs` establishes, so the two decision surfaces cannot disagree
16
+ * about what a level means.
17
+ */
18
+
19
+ /** A status label for a record, in the same terms `./adr-text.mjs` uses. */
20
+ function statusLabel(status) {
21
+ if (status === "accepted") return "accepted";
22
+ if (status === "active") return "active";
23
+ if (status === "superseded") return "superseded";
24
+ if (status === "retired") return "retired";
25
+ return "proposed";
26
+ }
27
+
28
+ /**
29
+ * The `fitness:` line — byte-identical to `./adr-text.mjs`'s own, so a fitness
30
+ * level means the same thing on the `adr` face and here.
31
+ *
32
+ * @param {{level: string, verified: boolean, reason?: string}|undefined} fitness
33
+ * @returns {string}
34
+ */
35
+ function fitnessLine(fitness) {
36
+ if (fitness === undefined) return `fitness: ${"(not measured)"}`;
37
+ if (fitness.verified)
38
+ return `fitness: ${fitness.level} — verified true: bound constraints resolve and pass`;
39
+ if (typeof fitness.reason === "string") {
40
+ return `fitness: ${fitness.level} — ${fitness.reason}`;
41
+ }
42
+ return `fitness: ${fitness.level}`;
43
+ }
44
+
45
+ /** One prose field rendered for a record, first line carrying the label. */
46
+ function proseLines(label, text) {
47
+ const first = text.split("\n")[0];
48
+ const rest = text.split("\n").slice(1);
49
+ const out = [`${label}${first}`];
50
+ for (const line of rest) out.push(`${" ".repeat(label.length)}${line}`);
51
+ return out;
52
+ }
53
+
54
+ /** The prose fields of one record, in the order the wave-2 contract names them. */
55
+ const PROSE_FIELDS = [
56
+ ["context: ", "context"],
57
+ ["decision: ", "decision"],
58
+ ["rationale: ", "rationale"],
59
+ ["alternatives:", "alternatives"],
60
+ ["consequences:", "consequences"],
61
+ ["assumptions: ", "assumptions"],
62
+ ];
63
+
64
+ /** The kind label a governed row node renders as. */
65
+ const ROW_KIND_LABEL = {
66
+ intent: "intent row",
67
+ constraint: "constraint",
68
+ fitness: "fitness rule",
69
+ };
70
+
71
+ /**
72
+ * Renders the deterministic decision chain for one record.
73
+ *
74
+ * @param {object} args
75
+ * @param {string} args.decisionId The id the caller asked about.
76
+ * @param {object} args.record The ADR record the chain is about.
77
+ * @param {object} args.walk The `forwardDecision` walk
78
+ * (`../governance/decision-graph.mjs`): `{ok, nodes, edges, unresolved}`.
79
+ * @param {{level: string, verified: boolean, reason?: string}|undefined}
80
+ * args.fitness The per-decision fitness level, computed by the caller.
81
+ * @returns {string}
82
+ */
83
+ export function formatDecisionChain({ decisionId, record, walk, fitness }) {
84
+ const lines = [];
85
+
86
+ // An id the registry does not know is a named unknown, never a clean chain:
87
+ // the record is null, so there is no header to derive — the walk's
88
+ // unresolved block below is the whole answer. The renderer must not throw
89
+ // over a null record; that is precisely the case it exists to report.
90
+ const header =
91
+ record === null || record === undefined
92
+ ? `${decisionId} (unknown)`
93
+ : `${record.id} (${statusLabel(record.status)})`;
94
+ lines.push(header, "-".repeat(header.length));
95
+
96
+ if (record !== null && record !== undefined) {
97
+ for (const [label, key] of PROSE_FIELDS) {
98
+ const text = record[key];
99
+ if (typeof text === "string" && text.trim() !== "") {
100
+ lines.push(...proseLines(label, text));
101
+ }
102
+ }
103
+
104
+ if ((record.supersedes ?? []).length > 0) {
105
+ lines.push(`supersedes: ${record.supersedes.join(", ")}`);
106
+ }
107
+
108
+ lines.push(fitnessLine(fitness));
109
+ }
110
+
111
+ // The governed rows the decision attaches (decisionRef citations and
112
+ // bindings): the "who stands on this decision" half of the chain.
113
+ const rowNodes = walk.nodes.filter(
114
+ (node) => node.kind === "constraint" || node.kind === "intent" || node.kind === "fitness",
115
+ );
116
+ if (rowNodes.length > 0) {
117
+ lines.push("governs:");
118
+ for (const node of rowNodes) {
119
+ const governed = walk.edges
120
+ .filter((edge) => edge.kind === "governs" && edge.from === node.id)
121
+ .map((edge) => edge.to);
122
+ const kind = ROW_KIND_LABEL[node.kind] ?? node.kind;
123
+ const target = governed.length > 0 ? ` → ${governed.join(", ")}` : "";
124
+ lines.push(` ${node.id} (${kind})${target}`);
125
+ }
126
+ } else {
127
+ lines.push("governs: (none — recorded but not enforceable)");
128
+ }
129
+
130
+ // The evidence leg: every project the rows govern, and each project's
131
+ // current findings. A project with no findings is a fact, stated as such.
132
+ const projectNodes = walk.nodes.filter((node) => node.kind === "project");
133
+ if (projectNodes.length > 0) {
134
+ lines.push("evidence:");
135
+ for (const project of projectNodes) {
136
+ const findings = walk.nodes
137
+ .filter((node) => node.kind === "finding")
138
+ .filter((finding) =>
139
+ walk.edges.some(
140
+ (edge) => edge.kind === "finding" && edge.from === project.id && edge.to === finding.id,
141
+ ),
142
+ );
143
+ if (findings.length === 0) {
144
+ lines.push(` ${project.id}: no current findings`);
145
+ } else {
146
+ for (const finding of findings) {
147
+ const ruleId = finding.data?.ruleId ?? "";
148
+ lines.push(` ${project.id}: ${finding.label}${ruleId ? ` (${ruleId})` : ""}`);
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ // The loud unresolved block — a walk that could not resolve every hop never
155
+ // reads as a clean chain.
156
+ if (!walk.ok) {
157
+ lines.push("unresolved:");
158
+ for (const entry of walk.unresolved) {
159
+ lines.push(` ${entry.ref}: ${entry.reason}`);
160
+ }
161
+ }
162
+
163
+ return lines.join("\n");
164
+ }
@@ -18,7 +18,10 @@
18
18
  * `../commands/delta.mjs` pushes there — fold into the report as their own
19
19
  * lines, so a note that rides the JSON envelope also reaches the terminal.
20
20
  *
21
- * This module decides nothing. A formatter that filtered would be a rule
21
+ * The wave-3 additive block `classifications` and `affected` is appended
22
+ * after every existing line (and only when the payload carries the fields), so
23
+ * a payload that predates them renders exactly what it always did. This module
24
+ * decides nothing. A formatter that filtered would be a rule
22
25
  * wearing a formatter's name (`./README.md`).
23
26
  */
24
27
 
@@ -259,6 +262,38 @@ export function formatDeltaReport({ delta, coverage }) {
259
262
  `no waiver lane, every one gates`,
260
263
  );
261
264
  }
265
+ // The wave-3 additive block: the evolution classification and its affected
266
+ // identities, appended after every existing line so an older report's lines
267
+ // stay byte-identical. Rendered only when the payload carries the fields —
268
+ // a payload that predates them renders exactly what it always did. An empty
269
+ // classification list is not silence here: the closing claims above already
270
+ // state what was compared, and `classifications none` says plainly that no
271
+ // class applies.
272
+ if (delta.classifications !== undefined) {
273
+ sections.push(
274
+ `classifications ${
275
+ delta.classifications.length === 0 ? "none" : delta.classifications.join(", ")
276
+ }`,
277
+ );
278
+ }
279
+ if (delta.affected !== undefined) {
280
+ const affectedParts = [];
281
+ if (delta.affected.projects.length > 0) {
282
+ affectedParts.push(`projects: ${delta.affected.projects.join(", ")}`);
283
+ }
284
+ if (delta.affected.boundaries.length > 0) {
285
+ affectedParts.push(`boundaries: ${delta.affected.boundaries.join(", ")}`);
286
+ }
287
+ if (delta.affected.constraints.length > 0) {
288
+ affectedParts.push(`constraints: ${delta.affected.constraints.join(", ")}`);
289
+ }
290
+ if (delta.affected.decisions.length > 0) {
291
+ affectedParts.push(`decisions: ${delta.affected.decisions.join(", ")}`);
292
+ }
293
+ sections.push(
294
+ `affected ${affectedParts.length === 0 ? "none" : affectedParts.join(" · ")}`,
295
+ );
296
+ }
262
297
 
263
298
  return sections.join("\n");
264
299
  }