@ecoma-io/archkeep 0.17.0 → 0.18.1

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,362 @@
1
+ /**
2
+ * The append-only evolution event store (design §3): one event per file,
3
+ * `<NNNN>-<id8>.json`, written atomically and idempotently. `docs/concepts/evolution.md`
4
+ * states the store semantics; this module implements them.
5
+ *
6
+ * Four properties, each with a mechanism:
7
+ *
8
+ * - **Append-only.** There is no update and no delete in this module, by
9
+ * construction — the only write path creates a new file. State change is a
10
+ * new event; rewriting history is impossible.
11
+ * - **Idempotent.** `writeEvent` scans the directory for an existing event
12
+ * with the same `dedupeKey` BEFORE writing. The key is the canonical
13
+ * `{base, head, declarationDigest}` tuple `evolution-event.mjs` defines — the
14
+ * same tuple the id hashes — so a rerun over the same transition finds the
15
+ * earlier event and returns `{duplicate: true}` writing nothing, the
16
+ * always-present `duplicate` sibling `commands/history.mjs`'s capture uses so
17
+ * the envelope shape never depends on directory state. A file that cannot be
18
+ * parsed during the scan throws: a corrupt store must never silently
19
+ * manufacture a duplicate of the record it could not read.
20
+ * - **Atomic.** The write goes to `<path>.json.tmp` opened with `{flag: "wx"}`
21
+ * (refusing rather than following a symlink already sitting there), then
22
+ * `rename` over the final name — the same mechanism as history's
23
+ * `writeSnapshotFile` (`../commands/history.mjs`). Reads filter `.json.tmp`
24
+ * out, so an interrupted write leaves a partial file the store will never
25
+ * read. The final-name rule from containment still applies: the `.tmp` name
26
+ * is not walked by the containment probe, and `wx` is what refuses a planted
27
+ * symlink at it.
28
+ * - **Contained.** A write is checked against the workspace root the same way
29
+ * `--output` is (`../containment.mjs`): `io.root` is REQUIRED, and a path
30
+ * whose intermediate components are workspace-controlled symlinks is refused
31
+ * loudly rather than silently landing outside the tree. A directory the
32
+ * caller names outside the root string is the caller's explicit choice and
33
+ * proceeds, exactly like `--output /tmp`.
34
+ *
35
+ * Sequence numbers are zero-based (`0000`, `0001`, …) and widen from a
36
+ * four-digit minimum rather than overflowing — the same mechanism as history's
37
+ * `nextSequence` (`../commands/history.mjs`), offset by one because a capture
38
+ * sequence is an ordinal while an event log is an index. `shortId` is history's
39
+ * `shortId` (first 8 hex chars of the id).
40
+ */
41
+
42
+ import {
43
+ mkdirSync as defaultMkdir,
44
+ readdirSync as defaultReaddir,
45
+ readFileSync as defaultReadFile,
46
+ renameSync as defaultRename,
47
+ writeFileSync as defaultWriteFile,
48
+ lstatSync as defaultLstat,
49
+ realpathSync as defaultRealpath,
50
+ } from "node:fs";
51
+ import { join, resolve } from "node:path";
52
+
53
+ import { containmentViolation } from "../containment.mjs";
54
+ import {
55
+ eventDedupeKey,
56
+ eventId,
57
+ EVOLUTION_EVENT_SCHEMA_VERSION,
58
+ EVENT_CLASSIFICATIONS,
59
+ EVENT_DISPOSITIONS,
60
+ } from "./evolution-event.mjs";
61
+
62
+ /**
63
+ * The zero-padded sequence number for the next event, taken from the highest
64
+ * existing event filename: `0000` for a fresh directory. An event log is
65
+ * zero-based — the first event is index 0 — unlike history's capture
66
+ * ordinals, which start at `0001`. The width widens from a four-digit minimum
67
+ * rather than overflowing, for the same reason history's `nextSequence`
68
+ * documents: a `10000` padded to four digits would byte-sort before `9999-…`
69
+ * and silently rewind the log, and the sequence regex would stop seeing the
70
+ * 5-digit name so repeated writes would clobber one file.
71
+ *
72
+ * @param {string[]} names Event filenames from the directory read.
73
+ * @returns {string} Zero-padded sequence, at least four digits.
74
+ */
75
+ function nextSequence(names) {
76
+ // `max` starts at -1 so a fresh directory sequences from 0000.
77
+ let max = -1;
78
+ for (const name of names) {
79
+ const match = /^(\d+)-/.exec(name);
80
+ if (match) max = Math.max(max, Number.parseInt(match[1], 10));
81
+ }
82
+ const width = Math.max(4, String(max + 1).length);
83
+ return String(max + 1).padStart(width, "0");
84
+ }
85
+ /**
86
+ * The short filename suffix for an event id — history's `shortId` (`../commands/history.mjs`):
87
+ * first 8 hex characters.
88
+ *
89
+ * @param {string} id Full hex SHA-256 from `eventId`.
90
+ * @returns {string} First 8 hex characters.
91
+ */
92
+ function shortId(id) {
93
+ return id.slice(0, 8);
94
+ }
95
+
96
+ /**
97
+ * Refuses an event whose identity does not match its content. `id` and
98
+ * `dedupeKey` are derived, never free-form: a record whose fields disagree
99
+ * with the canonical tuple would dedupe against the wrong key on rerun and
100
+ * silently manufacture duplicates — the failure shape this store exists to
101
+ * rule out.
102
+ *
103
+ * @param {object} event The event to write.
104
+ * @throws {Error} naming the mismatch.
105
+ */
106
+ function validateEventForWrite(event) {
107
+ if (typeof event !== "object" || event === null || Array.isArray(event)) {
108
+ throw new Error("archkeep: writeEvent requires an EvolutionEvent object");
109
+ }
110
+ const expectedId = eventId(event);
111
+ const expectedKey = eventDedupeKey(event);
112
+ if (typeof event.id !== "string") {
113
+ throw new Error("archkeep: refusing to write the evolution event: the record carries no 'id'");
114
+ }
115
+ if (event.id !== expectedId) {
116
+ throw new Error(
117
+ "archkeep: refusing to write the evolution event: its 'id' does not match the canonical " +
118
+ "tuple {base, head, declarationDigest}",
119
+ );
120
+ }
121
+ if (typeof event.dedupeKey !== "string") {
122
+ throw new Error(
123
+ "archkeep: refusing to write the evolution event: the record carries no 'dedupeKey'",
124
+ );
125
+ }
126
+ if (event.dedupeKey !== expectedKey) {
127
+ throw new Error(
128
+ "archkeep: refusing to write the evolution event: its 'dedupeKey' does not match the canonical " +
129
+ "tuple {base, head, declarationDigest}",
130
+ );
131
+ }
132
+ }
133
+
134
+ /**
135
+ * The three validations every stored event must pass: the schema version, the
136
+ * classification subset, and the disposition. Any other shape is a malformed
137
+ * store — thrown, never read as an event.
138
+ *
139
+ * @param {object} parsed The parsed record.
140
+ * @param {string} path The file it came from, for the error message.
141
+ * @throws {Error} naming the file and the violated check.
142
+ */
143
+ function validateEventRecord(parsed, path) {
144
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
145
+ throw new Error(
146
+ `archkeep: malformed evolution event '${path}': the record is not a JSON object`,
147
+ );
148
+ }
149
+ if (parsed.schemaVersion !== EVOLUTION_EVENT_SCHEMA_VERSION) {
150
+ throw new Error(
151
+ `archkeep: malformed evolution event '${path}': schemaVersion ${JSON.stringify(
152
+ parsed.schemaVersion,
153
+ )} is not ${EVOLUTION_EVENT_SCHEMA_VERSION}`,
154
+ );
155
+ }
156
+ if (
157
+ !Array.isArray(parsed.classifications) ||
158
+ parsed.classifications.some((entry) => !EVENT_CLASSIFICATIONS.includes(entry))
159
+ ) {
160
+ throw new Error(
161
+ `archkeep: malformed evolution event '${path}': classifications must be a subset of ` +
162
+ `[${EVENT_CLASSIFICATIONS.join(", ")}]`,
163
+ );
164
+ }
165
+ if (!EVENT_DISPOSITIONS.includes(parsed.disposition)) {
166
+ throw new Error(
167
+ `archkeep: malformed evolution event '${path}': disposition ${JSON.stringify(
168
+ parsed.disposition,
169
+ )} is not one of [${EVENT_DISPOSITIONS.join(", ")}]`,
170
+ );
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Appends one event to the store at `dir`.
176
+ *
177
+ * Idempotency first: the directory is scanned for an event whose `dedupeKey`
178
+ * matches BEFORE anything is written; a match returns `{id, duplicate: true}`
179
+ * and writes nothing. Then the event is written atomically
180
+ * (`<path>.json.tmp` + rename, `{flag: "wx"}`) under a containment check
181
+ * against `io.root`. A missing directory is an empty store: it is created
182
+ * before the scan, so the first event in a fresh store lands at `0000`.
183
+ *
184
+ * The event's `id`/`dedupeKey` must match the canonical tuple (see
185
+ * `validateEventForWrite`) — a caller cannot persist a record whose identity
186
+ * lies about its content.
187
+ *
188
+ * @param {string} dir Absolute or relative path to the event store directory.
189
+ * @param {object} event The EvolutionEvent to append.
190
+ * @param {{root?: string, readdirSync?: (path: string) => string[],
191
+ * readFileSync?: (path: string, encoding: "utf8") => string,
192
+ * writeFileSync?: (path: string, text: string, options: object) => void,
193
+ * renameSync?: (from: string, to: string) => void,
194
+ * mkdirSync?: (path: string, options: {recursive: boolean}) => void,
195
+ * lstatSync?: (path: string) => {isSymbolicLink: () => boolean},
196
+ * realpathSync?: (path: string) => string}} [io]
197
+ * Injectable filesystem seams, defaulting to the sync `node:fs` calls this
198
+ * module uses; `root` is the workspace root the containment check is made
199
+ * against and is REQUIRED for a write.
200
+ * @returns {{id: string, duplicate: boolean}} The event id, and whether this
201
+ * call wrote nothing because the event already existed.
202
+ * @throws {Error} on a mismatched event identity, an unreadable or malformed
203
+ * store, a containment violation, or a `wx` refusal.
204
+ */
205
+ export function writeEvent(dir, event, io = {}) {
206
+ validateEventForWrite(event);
207
+
208
+ const readDir = io.readdirSync ?? defaultReaddir;
209
+ const readFile = io.readFileSync ?? defaultReadFile;
210
+ const writeFile = io.writeFileSync ?? defaultWriteFile;
211
+ const rename = io.renameSync ?? defaultRename;
212
+ const makeDir = io.mkdirSync ?? defaultMkdir;
213
+ const lstat = io.lstatSync ?? defaultLstat;
214
+ const realpath = io.realpathSync ?? defaultRealpath;
215
+
216
+ // Resolved once: the identical string feeds the containment check and the
217
+ // actual write (`../containment.mjs`, "One contract binds the WRITE call
218
+ // sites"). `resolve` also collapses any `..`, which containment refuses raw.
219
+ const dirAbs = resolve(dir);
220
+
221
+ let names;
222
+ try {
223
+ names = readDir(dirAbs);
224
+ } catch (cause) {
225
+ if (cause?.code === "ENOENT") {
226
+ // An absent optional store is an empty store: the caller's first event.
227
+ makeDir(dirAbs, { recursive: true });
228
+ names = readDir(dirAbs);
229
+ } else {
230
+ throw new Error(
231
+ `archkeep: cannot read the event store '${dirAbs}': ${cause?.message ?? cause}`,
232
+ { cause },
233
+ );
234
+ }
235
+ }
236
+
237
+ const eventNames = names.filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"));
238
+ eventNames.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
239
+
240
+ // The dedupe scan. A file that cannot be parsed, or that parses to a record
241
+ // without a string dedupeKey, throws: a store this module cannot read must
242
+ // not be silently appended to — the unreadable file may BE the duplicate.
243
+ for (const name of eventNames) {
244
+ const existingPath = join(dirAbs, name);
245
+ let text;
246
+ try {
247
+ text = readFile(existingPath, "utf8");
248
+ } catch (cause) {
249
+ throw new Error(
250
+ `archkeep: cannot read the evolution event '${existingPath}': ${cause?.message ?? cause}`,
251
+ { cause },
252
+ );
253
+ }
254
+ let parsed;
255
+ try {
256
+ parsed = JSON.parse(text);
257
+ } catch (cause) {
258
+ throw new Error(
259
+ `archkeep: malformed evolution event '${existingPath}': ${cause?.message ?? cause}`,
260
+ { cause },
261
+ );
262
+ }
263
+ if (typeof parsed?.dedupeKey !== "string") {
264
+ throw new Error(
265
+ `archkeep: malformed evolution event '${existingPath}': the record carries no string 'dedupeKey'`,
266
+ );
267
+ }
268
+ if (parsed.dedupeKey === event.dedupeKey) {
269
+ return { id: event.id, duplicate: true };
270
+ }
271
+ }
272
+
273
+ const sequence = nextSequence(eventNames);
274
+ const name = `${sequence}-${shortId(event.id)}.json`;
275
+ const path = join(dirAbs, name);
276
+
277
+ if (io.root === undefined) {
278
+ throw new Error(
279
+ "archkeep: writing an evolution event requires io.root — the workspace root — so the " +
280
+ "write can be proven to stay inside the workspace",
281
+ );
282
+ }
283
+ const violation = containmentViolation(io.root, path, {
284
+ forWrite: true,
285
+ lstatSync: lstat,
286
+ realpathSync: realpath,
287
+ });
288
+ if (violation !== null) {
289
+ throw new Error(`archkeep: refusing to write the evolution event '${path}': ${violation}`);
290
+ }
291
+
292
+ const tmp = `${path}.tmp`;
293
+ writeFile(tmp, `${JSON.stringify(event, null, 2)}\n`, { flag: "wx" });
294
+ rename(tmp, path);
295
+
296
+ return { id: event.id, duplicate: false };
297
+ }
298
+
299
+ /**
300
+ * Reads and validates every event in the store at `dir`, in filename order
301
+ * (the append order). A malformed file — JSON that does not parse, a
302
+ * non-object record, a wrong `schemaVersion`, a classification outside the
303
+ * vocabulary, a disposition outside the vocabulary — THROWS; the command layer
304
+ * maps that to exit 3, and "could not read the store" never reads as "no
305
+ * events recorded" (the invariant: an empty result is a claim, not a shrug).
306
+ * A missing directory is `[]` — an absent OPTIONAL store is not an error, and
307
+ * the caller states "no events recorded" itself when that matters.
308
+ *
309
+ * `.json.tmp` files are filtered out: an interrupted write leaves one behind,
310
+ * and it must never count as an event.
311
+ *
312
+ * @param {string} dir Path to the event store directory.
313
+ * @param {{readdirSync?: (path: string) => string[],
314
+ * readFileSync?: (path: string, encoding: "utf8") => string}} [io]
315
+ * @returns {object[]} The validated events, in append order.
316
+ * @throws {Error} on the first unreadable or malformed event file.
317
+ */
318
+ export function readEvents(dir, io = {}) {
319
+ const readDir = io.readdirSync ?? defaultReaddir;
320
+ const readFile = io.readFileSync ?? defaultReadFile;
321
+
322
+ const dirAbs = resolve(dir);
323
+
324
+ let names;
325
+ try {
326
+ names = readDir(dirAbs);
327
+ } catch (cause) {
328
+ if (cause?.code === "ENOENT") return [];
329
+ throw new Error(
330
+ `archkeep: cannot read the event store '${dirAbs}': ${cause?.message ?? cause}`,
331
+ { cause },
332
+ );
333
+ }
334
+
335
+ const eventNames = names.filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"));
336
+ eventNames.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
337
+
338
+ const events = [];
339
+ for (const name of eventNames) {
340
+ const path = join(dirAbs, name);
341
+ let text;
342
+ try {
343
+ text = readFile(path, "utf8");
344
+ } catch (cause) {
345
+ throw new Error(
346
+ `archkeep: cannot read the evolution event '${path}': ${cause?.message ?? cause}`,
347
+ { cause },
348
+ );
349
+ }
350
+ let parsed;
351
+ try {
352
+ parsed = JSON.parse(text);
353
+ } catch (cause) {
354
+ throw new Error(`archkeep: malformed evolution event '${path}': ${cause?.message ?? cause}`, {
355
+ cause,
356
+ });
357
+ }
358
+ validateEventRecord(parsed, path);
359
+ events.push(parsed);
360
+ }
361
+ return events;
362
+ }
@@ -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}`,
@@ -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
  }