@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.
- package/cli.mjs +161 -22
- 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/diff.mjs +15 -7
- package/src/commands/evolution.mjs +758 -5
- package/src/commands/explain.mjs +82 -1
- package/src/commands/history.mjs +81 -5
- package/src/commands/plan-context-command.mjs +163 -2
- package/src/commands/rules.mjs +3 -1
- 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,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
|
|
66
|
-
* `../commands/change.mjs`'s result payload; `coverage` its
|
|
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}"`),
|
package/src/report/debt-text.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
}
|