@ecoma-io/archkeep 0.14.0 → 0.15.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.
@@ -0,0 +1,437 @@
1
+ /**
2
+ * The `trajectory` command: which deterministic signals moved across a
3
+ * snapshot history, aggregated over every observation the directory holds.
4
+ *
5
+ * `history <dir>` answers "what happened at each transition" — one record per
6
+ * consecutive pair, each classified by the signals it carries. `trajectory
7
+ * <dir>` reads the same directory through the same reader and the same
8
+ * transition classifier (`./history.mjs`'s `readSnapshots` and
9
+ * `classifyTransition`) and answers the question an event list cannot:
10
+ * **over the whole series, which signals fired how often, what did the graph
11
+ * gain and lose in total, and what persisted through every observation.**
12
+ *
13
+ * ## What a trend means here — and what it does not
14
+ *
15
+ * A trend is a count or a set that changed over an ORDERED sequence of
16
+ * observations. It is never a judgment: nothing here scores the architecture,
17
+ * weights a signal, or decides whether more edges or fewer violations is
18
+ * "better". Every number is either read off stored bytes or derived from them
19
+ * by a stated rule; a human or an agent decides what the facts mean. Doctrine
20
+ * owns that split (`../../../../docs/doctrine/architecture-authority.md`):
21
+ * this command produces evidence, never a verdict — it is descriptive, and it
22
+ * never exits 1.
23
+ *
24
+ * ## The observation basis
25
+ *
26
+ * One observation is ONE stored `graph --format json` snapshot — a capture
27
+ * point. Snapshot capture deduplicates unchanged architectures, so the number
28
+ * of observations is the number of recorded states, NOT a count of commits,
29
+ * days, or captures attempted (`../../../../docs/usage/history.md`). The
30
+ * result names its basis explicitly (`observations.basis`), and no field
31
+ * converts observations into any unit of time.
32
+ *
33
+ * ## Stable identity, proven not guessed
34
+ *
35
+ * Persistence claims are only as honest as the identity beneath them. Both
36
+ * identities here are ones `diff` already defines and uses (`./diff.mjs`): a
37
+ * project IS its `name`, an edge IS its `(source, target, type)` triple
38
+ * (`edgeIdentityKey`). Nothing weaker — no array index, no display order, no
39
+ * line number — participates in any cross-observation claim.
40
+ *
41
+ * Findings have NO stable identity in stored snapshots, because a snapshot
42
+ * carries no findings at all — only the graph and the policy fingerprint
43
+ * (the disclosure `./history.mjs` states). So this command reports NO
44
+ * violation-level trajectory: no introduced/resolved/persisting counts for
45
+ * boundary violations can be reconstructed from stored evidence, and
46
+ * inventing one would fabricate persistence for facts the snapshots never
47
+ * held. `delta` classifies real violations between two live points;
48
+ * `debt` ages today's ledger facts across snapshots. Neither job is repeated
49
+ * here under a second definition of time.
50
+ *
51
+ * ## Unknown evidence never becomes zero
52
+ *
53
+ * The empty-result invariant (`../../../../AGENTS.md`) applied to an
54
+ * aggregate: a value this history cannot establish reads `null` beside an
55
+ * explicit reason, never as a clean zero. Three cases, all loud:
56
+ *
57
+ * - **No transitions derivable** — a directory with ONE snapshot yields no
58
+ * consecutive pair, so `available` is `false`, `unavailableReason` is
59
+ * `"insufficient_history"`, and every derived number is `null`. Reporting
60
+ * delta 0 there would claim stability over a history that cannot show
61
+ * movement.
62
+ * - **Incomparable metadata** — a fingerprint or a provenance on one side of
63
+ * a pair only. `snapshot-meta.mjs` refuses to call that "the same", and so
64
+ * does this aggregation: such a transition counts under
65
+ * `signals.incomparable` (and its specific disclosure), and — stricter than
66
+ * `history`'s per-transition label, which keeps the note beside the record —
67
+ * it does NOT count as `unchanged`. An aggregate has no notes line to carry
68
+ * the disclosure, so the exclusion is the disclosure.
69
+ * - **An unreadable or malformed snapshot** stops the whole run (exit 3,
70
+ * through `readSnapshots`), never silently drops out of the aggregate —
71
+ * a missing observation must not read as a quiet one.
72
+ *
73
+ * An empty directory is refused outright, exactly like `history` and `debt`
74
+ * refuse it: zero observations is no record at all, not a clean trajectory.
75
+ *
76
+ * ## Determinism and complexity
77
+ *
78
+ * Pure function of the snapshot bytes: plain `<` string comparison everywhere,
79
+ * fixed key insertion order, no clock, no locale. Complexity is linear in the
80
+ * input — O(N) classification passes over N snapshots, each O(P + E) in the
81
+ * projects and edges those two snapshots hold, plus one linear persistence
82
+ * sweep over every project and edge ever seen. There is no pairwise
83
+ * O(N²) comparison and no re-analysis of source files: everything is read
84
+ * from stored envelopes.
85
+ *
86
+ * What it needs from its caller is a workspace root context (for the
87
+ * envelope's workspace header and the directory-containment check — the
88
+ * trajectory itself never touches the live graph) and the directory path.
89
+ * It does not print, and it does not decide the process's exit code —
90
+ * `../../cli.mjs` owns those (`./README.md`).
91
+ */
92
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
93
+ import { formatTrajectoryReport } from "../report/trajectory-text.mjs";
94
+ import { edgeIdentityKey } from "./diff.mjs";
95
+ import { classifyTransition, readSnapshots } from "./history.mjs";
96
+ import { resolveProvenance } from "./provenance.mjs";
97
+
98
+ /**
99
+ * What one observation is. Stated as a value rather than left implicit, so a
100
+ * consumer reading `observations: 12` knows exactly what was counted — and
101
+ * what was not (commits, days, capture attempts).
102
+ */
103
+ const OBSERVATION_BASIS = "graph_snapshots";
104
+
105
+ /**
106
+ * The reason `available` is `false` for a one-snapshot history. A named
107
+ * constant in the envelope, so a consumer branches on a documented value
108
+ * rather than on prose.
109
+ */
110
+ export const INSUFFICIENT_HISTORY = "insufficient_history";
111
+
112
+ /**
113
+ * The derived-number block for one structural axis (projects keyed by name,
114
+ * edges keyed by `edgeIdentityKey`). Fields ending in `Events` count
115
+ * transition EVENTS — cumulative add/remove occurrences, which can exceed the
116
+ * endpoint movement when an entity churns (add → remove → add is two added
117
+ * events, one removed, and equal first/current sets). The endpoint fields
118
+ * compare FIRST and LAST observation sets only:
119
+ *
120
+ * - `introduced` — present in the last observation, absent from the first.
121
+ * - `resolved` — present in the first observation, absent from the last.
122
+ * - `persistent` — present in EVERY observation, first through last.
123
+ * - `delta` — `current − first`.
124
+ *
125
+ * Every derived field is `null` when the history holds fewer than two
126
+ * observations — unavailable is never folded into a zero.
127
+ *
128
+ * @typedef {object} TrajectoryAxis
129
+ * @property {number} first Projects/edges in the first observation.
130
+ * @property {number} current Projects/edges in the last observation.
131
+ * @property {number|null} delta
132
+ * @property {number|null} addedEvents
133
+ * @property {number|null} removedEvents
134
+ * @property {number|null} changedEvents Projects only: metadata churn
135
+ * (tags/type/root changes per `computeDiff`). Edges carry no changed-event
136
+ * count — a type flip IS a removal plus an addition under the triple
137
+ * identity (`./diff.mjs`).
138
+ * @property {number|null} introduced
139
+ * @property {number|null} resolved
140
+ * @property {number|null} persistent
141
+ */
142
+
143
+ /**
144
+ * Aggregates the deterministic trajectory over an ordered snapshot set.
145
+ * Pure: same bytes in, same object out. All keys are always present — shape
146
+ * never depends on history content (E-F05); unavailable values are `null`
147
+ * with `available`/`unavailableReason` saying why.
148
+ *
149
+ * @param {{name: string, path: string, envelope: object, id: string}[]} files
150
+ * From `readSnapshots(dir)`, in history order.
151
+ * @returns {{observations: {count: number, basis: string,
152
+ * first: string|null, last: string|null, withProvenance: number,
153
+ * dirtyProvenance: number}, available: boolean, unavailableReason: string|null,
154
+ * transitions: {count: number, architecture: number, policy: number,
155
+ * provider: number, codeDrift: number, incomparable: number, unchanged: number},
156
+ * disclosures: {policyOneSided: number, provenanceOneSided: number, crossRepo: number},
157
+ * projects: TrajectoryAxis, edges: TrajectoryAxis}}
158
+ */
159
+ export function computeTrajectory(files) {
160
+ const n = files.length;
161
+ const available = n >= 2;
162
+
163
+ // Per-observation facts, straight off the envelopes — no pairing needed.
164
+ let withProvenance = 0;
165
+ let dirtyProvenance = 0;
166
+ for (const file of files) {
167
+ const provenance = file.envelope.workspace.provenance ?? null;
168
+ if (provenance !== null) withProvenance += 1;
169
+ if (provenance?.dirty === true) dirtyProvenance += 1;
170
+ }
171
+
172
+ /** @type {{count: number, architecture: number, policy: number, provider: number,
173
+ codeDrift: number, incomparable: number, unchanged: number}} */
174
+ const transitions = {
175
+ count: 0,
176
+ architecture: 0,
177
+ policy: 0,
178
+ provider: 0,
179
+ codeDrift: 0,
180
+ incomparable: 0,
181
+ unchanged: 0,
182
+ };
183
+ const disclosures = { policyOneSided: 0, provenanceOneSided: 0, crossRepo: 0 };
184
+
185
+ // Cumulative transition events, accumulated while classifying. Kept as
186
+ // scalars rather than deferred to a second pass — one walk over the pairs.
187
+ let addedProjectEvents = 0;
188
+ let removedProjectEvents = 0;
189
+ let changedProjectEvents = 0;
190
+ let addedEdgeEvents = 0;
191
+ let removedEdgeEvents = 0;
192
+
193
+ // Persistence sets: entity key → number of observations containing it.
194
+ // Built once per axis in the same walk that reads each snapshot's members,
195
+ // so cost stays linear in the total snapshot content.
196
+ /** @type {Map<string, number>} */
197
+ const projectPresence = new Map();
198
+ /** @type {Map<string, number>} */
199
+ const edgePresence = new Map();
200
+
201
+ let firstProjects = null;
202
+ let lastProjects = null;
203
+ let firstEdges = null;
204
+ let lastEdges = null;
205
+
206
+ for (let i = 0; i < n; i++) {
207
+ const file = files[i];
208
+ const projectKeys = new Set(file.envelope.result.projects.map((p) => p.name));
209
+ // Project identity is the name — the same key `computeDiff` indexes by
210
+ // (`./diff.mjs`). Edge identity is the `(source, target, type)` triple,
211
+ // shared through `edgeIdentityKey` so both commands answer "same edge?"
212
+ // from one definition.
213
+ const edgeKeys = new Set(file.envelope.result.dependencies.map(edgeIdentityKey));
214
+
215
+ for (const key of projectKeys) projectPresence.set(key, (projectPresence.get(key) ?? 0) + 1);
216
+ for (const key of edgeKeys) edgePresence.set(key, (edgePresence.get(key) ?? 0) + 1);
217
+
218
+ if (i === 0) {
219
+ firstProjects = projectKeys;
220
+ firstEdges = edgeKeys;
221
+ }
222
+ if (i === n - 1) {
223
+ lastProjects = projectKeys;
224
+ lastEdges = edgeKeys;
225
+ }
226
+
227
+ if (i + 1 < n) {
228
+ const { record, meta } = classifyTransition(file, files[i + 1]);
229
+ transitions.count += 1;
230
+ if (record.architectureChanged) transitions.architecture += 1;
231
+ if (record.policyChanged === true) transitions.policy += 1;
232
+ if (record.providerChanged) transitions.provider += 1;
233
+ if (record.codeDrift) transitions.codeDrift += 1;
234
+
235
+ // The asymmetric-evidence cases, counted from `meta` itself — never
236
+ // parsed back out of the record's prose notes.
237
+ if (meta.policyOneSided) disclosures.policyOneSided += 1;
238
+ if (meta.provenanceOneSided) disclosures.provenanceOneSided += 1;
239
+ if (meta.crossRepo) disclosures.crossRepo += 1;
240
+ const incomparable = meta.policyOneSided || meta.provenanceOneSided;
241
+ if (incomparable) transitions.incomparable += 1;
242
+
243
+ // `unchanged` is deliberately STRICTER than the label `history`'s text
244
+ // renderer prints for the same transition: an aggregate has no
245
+ // per-transition note to disclose "one side carried no fingerprint",
246
+ // so a pair whose metadata could not be compared cannot land in the
247
+ // bucket whose plain meaning is "checked, nothing moved".
248
+ if (
249
+ !record.architectureChanged &&
250
+ !record.providerChanged &&
251
+ record.policyChanged !== true &&
252
+ !record.codeDrift &&
253
+ !incomparable
254
+ ) {
255
+ transitions.unchanged += 1;
256
+ }
257
+
258
+ if (record.changes) {
259
+ addedProjectEvents += record.changes.addedProjects.length;
260
+ removedProjectEvents += record.changes.removedProjects.length;
261
+ changedProjectEvents += record.changes.changedProjects.length;
262
+ addedEdgeEvents += record.changes.addedEdges.length;
263
+ removedEdgeEvents += record.changes.removedEdges.length;
264
+ }
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Counts entities of `presence` that appeared in ALL `n` observations.
270
+ * Only meaningful when `n >= 2`; the caller nulls it otherwise.
271
+ *
272
+ * @param {Map<string, number>} presence
273
+ * @returns {number}
274
+ */
275
+ const persistentCount = (presence) => {
276
+ let count = 0;
277
+ for (const seen of presence.values()) if (seen === n) count += 1;
278
+ return count;
279
+ };
280
+
281
+ /**
282
+ * The endpoint-set movement between the first and last observation.
283
+ *
284
+ * @param {Set<string>|null} first
285
+ * @param {Set<string>|null} current
286
+ * @returns {{introduced: number, resolved: number}}
287
+ */
288
+ const endpointMovement = (first, current) => {
289
+ let introduced = 0;
290
+ for (const key of current) if (!first.has(key)) introduced += 1;
291
+ let resolved = 0;
292
+ for (const key of first) if (!current.has(key)) resolved += 1;
293
+ return { introduced, resolved };
294
+ };
295
+
296
+ const projectMovement =
297
+ available && firstProjects !== null && lastProjects !== null
298
+ ? endpointMovement(firstProjects, lastProjects)
299
+ : null;
300
+ const edgeMovement =
301
+ available && firstEdges !== null && lastEdges !== null
302
+ ? endpointMovement(firstEdges, lastEdges)
303
+ : null;
304
+
305
+ /** @type {TrajectoryAxis} */
306
+ const projects = {
307
+ first: firstProjects === null ? 0 : firstProjects.size,
308
+ current: lastProjects === null ? 0 : lastProjects.size,
309
+ delta: available ? lastProjects.size - firstProjects.size : null,
310
+ addedEvents: available ? addedProjectEvents : null,
311
+ removedEvents: available ? removedProjectEvents : null,
312
+ changedEvents: available ? changedProjectEvents : null,
313
+ introduced: available ? projectMovement.introduced : null,
314
+ resolved: available ? projectMovement.resolved : null,
315
+ persistent: available ? persistentCount(projectPresence) : null,
316
+ };
317
+ /** @type {TrajectoryAxis} */
318
+ const edges = {
319
+ first: firstEdges === null ? 0 : firstEdges.size,
320
+ current: lastEdges === null ? 0 : lastEdges.size,
321
+ delta: available ? lastEdges.size - firstEdges.size : null,
322
+ addedEvents: available ? addedEdgeEvents : null,
323
+ removedEvents: available ? removedEdgeEvents : null,
324
+ // No `changedEvents` on this axis: under the triple identity an edge
325
+ // type flip is already a removal plus an addition (`./diff.mjs`).
326
+ changedEvents: null,
327
+ introduced: available ? edgeMovement.introduced : null,
328
+ resolved: available ? edgeMovement.resolved : null,
329
+ persistent: available ? persistentCount(edgePresence) : null,
330
+ };
331
+
332
+ return {
333
+ observations: {
334
+ count: n,
335
+ basis: OBSERVATION_BASIS,
336
+ first: n > 0 ? files[0].name : null,
337
+ last: n > 0 ? files[n - 1].name : null,
338
+ withProvenance,
339
+ dirtyProvenance,
340
+ },
341
+ available,
342
+ unavailableReason: available ? null : INSUFFICIENT_HISTORY,
343
+ transitions,
344
+ disclosures,
345
+ projects,
346
+ edges,
347
+ };
348
+ }
349
+
350
+ /**
351
+ * Runs the `trajectory` command: reads the snapshot directory, aggregates the
352
+ * deterministic trajectory, and builds the report.
353
+ *
354
+ * Unlike `debt`, no boundary law is loaded: the fingerprints being compared
355
+ * travel INSIDE the snapshots, so the law a run judges is the law each
356
+ * observation was captured under — there is no `--config` override because
357
+ * there is no current-law input to override with.
358
+ *
359
+ * @param {string} dir Absolute path to the history directory.
360
+ * @param {object} commandContext From `resolveCommandContext` — used for the
361
+ * envelope's workspace header and `readSnapshots`' containment check. The
362
+ * trajectory itself never touches the live graph.
363
+ * @param {{io?: {readSnapshots?: Function, resolveProvenance?: Function}}} [options]
364
+ * Injectable IO so a test drives the aggregation without the filesystem or
365
+ * git, mirroring `./history.mjs`'s seam.
366
+ * @returns {{status: "ok", trajectory: object, coverage: object,
367
+ * report: {text: string, json: string}}}
368
+ * @throws {Error} when the directory contains no snapshots or a snapshot
369
+ * cannot be read or validated (exit-3 class, via `readSnapshots` and the
370
+ * same refusal `history`/`debt` make).
371
+ */
372
+ export function trajectoryCommand(dir, commandContext, options = {}) {
373
+ const { root, provider, marker } = commandContext;
374
+ const io = options.io ?? {};
375
+
376
+ const read = (io.readSnapshots ?? readSnapshots)(dir, root);
377
+
378
+ if (read.files.length === 0) {
379
+ // An empty directory is not a clean trajectory — it is no record at all.
380
+ // Zero observations would read as "nothing ever changed", a claim about a
381
+ // history that does not exist. Same refusal, word for word in spirit, as
382
+ // `./history.mjs` and `./debt.mjs` make.
383
+ throw new Error(
384
+ `archkeep: the history directory '${dir}' contains no snapshots — there is no history to ` +
385
+ `aggregate. Capture one first with 'archkeep history <dir> --capture' (or point the ` +
386
+ `command at the directory where you keep graph snapshots).`,
387
+ );
388
+ }
389
+
390
+ const result = computeTrajectory(read.files);
391
+ const full = { dir, ...result };
392
+
393
+ const lastSnapshot = read.files[read.files.length - 1].envelope;
394
+ const coverage = {
395
+ complete: true,
396
+ projects: lastSnapshot.result.projects.length,
397
+ analyzedFiles: lastSnapshot.coverage.analyzedFiles,
398
+ imports: lastSnapshot.coverage.imports,
399
+ notAnalyzed: [],
400
+ blindSpots: [],
401
+ notes: [
402
+ `counts are snapshot-relative: ${result.observations.count} observation${
403
+ result.observations.count === 1 ? "" : "s"
404
+ } are stored graph snapshots — capture points, not commits, days, or captures attempted`,
405
+ "rule-impact cannot be recomputed from stored snapshots — snapshots carry the graph and " +
406
+ "the policy fingerprint, not the constraint table or import sites, so no violation-level " +
407
+ "trajectory is reported. Run `delta` between two live points, or `check` at any commit.",
408
+ ],
409
+ };
410
+
411
+ const envelope = jsonEnvelope({
412
+ command: "trajectory",
413
+ context: {
414
+ root,
415
+ provider,
416
+ marker,
417
+ // The same field every other envelope carries — THIS run's git origin,
418
+ // not the head snapshot's (`workspace.provenance` means one thing across
419
+ // the whole envelope surface; `docs/reference/json-output.md`).
420
+ provenance: (io.resolveProvenance ?? resolveProvenance)(root),
421
+ },
422
+ status: "ok",
423
+ exitCode: 0,
424
+ coverage,
425
+ result: full,
426
+ });
427
+
428
+ return {
429
+ status: "ok",
430
+ trajectory: full,
431
+ coverage,
432
+ report: {
433
+ text: formatTrajectoryReport({ trajectory: full, coverage }),
434
+ json: renderJson(envelope),
435
+ },
436
+ };
437
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Path utilities — operations that would otherwise be scattered as inline
3
+ * expressions, each a copy that could drift.
4
+ *
5
+ * Every function here is pure and typed, so tests need no filesystem.
6
+ */
7
+
8
+ /**
9
+ * Strip trailing slashes from a path — the O(n) alternative to
10
+ * `.replace(/\/+$/u, "")`.
11
+ *
12
+ * The regex form is **vulnerable to polynomial ReDoS** on a run of slashes
13
+ * that does not reach the string end: `/" + "/×n + "a"` forces the engine to
14
+ * scan from each position within the run, turning linear work into O(n²).
15
+ * Measured on V8:
16
+ *
17
+ * | n (slash count) | regex | linear scan |
18
+ * |-----------------|--------------|-------------|
19
+ * | 20,000 | 0.24 s | <0.001 s |
20
+ * | 40,000 | 0.86 s | <0.001 s |
21
+ * | 80,000 | 3.4 s | 0.004 s |
22
+ * | 160,000 | 13.6 s | 0.14 ms |
23
+ *
24
+ * Workspace roots are normally bounded by PATH_MAX (~4096), so the practical
25
+ * impact is low; but this function is shipped in a published package and
26
+ * CodeQL correctly flags the `workspaceRoot` argument as library input — the
27
+ * fix removes the alert class rather than arguing severity. Five call sites
28
+ * in the codebase used this pattern; all now route through this one helper.
29
+ *
30
+ * Semantics are identical to the regex on all edge cases (empty string,
31
+ * single slash, multiple slashes, no trailing slash, mixed content).
32
+ *
33
+ * @param {string} path The path to strip.
34
+ * @returns {string} `path` with trailing `/` characters removed.
35
+ */
36
+ export function stripTrailingSlashes(path) {
37
+ let end = path.length;
38
+ while (end > 0 && path[end - 1] === "/") end -= 1;
39
+ return path.slice(0, end);
40
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The terminal report for the `change` command: a declared change-intent
3
+ * contract against the architectural delta actually observed
4
+ * (`../commands/change.mjs`).
5
+ *
6
+ * The report is a review document, so it renders every axis separately and in
7
+ * full: what was declared, what matched, what appeared without a declaration,
8
+ * which declared changes never happened, how each declared constraint judged,
9
+ * and — informational, never this command's verdict — how many live boundary
10
+ * violations the tree currently carries under the current law, with `check`
11
+ * named as the authority on that axis. A section renders only when it has
12
+ * content; the closing line always states what was compared and with what
13
+ * outcome, so an empty reconciliation is a verifiable claim rather than
14
+ * silence (`../../../../AGENTS.md`).
15
+ *
16
+ * This module decides nothing. A formatter that filtered would be a rule
17
+ * wearing a formatter's name (`./README.md`).
18
+ */
19
+
20
+ /** One expected-fact row as report lines. */
21
+ function factLines(entry) {
22
+ switch (entry.kind) {
23
+ case "project-added":
24
+ return [` + project ${entry.project}`];
25
+ case "project-removed":
26
+ return [` - project ${entry.project}`];
27
+ case "edge-added":
28
+ return [
29
+ ` + edge ${entry.from} -> ${entry.to}${entry.type === undefined ? "" : ` (${entry.type})`}`,
30
+ ];
31
+ case "edge-removed":
32
+ return [
33
+ ` - edge ${entry.from} -> ${entry.to}${entry.type === undefined ? "" : ` (${entry.type})`}`,
34
+ ];
35
+ case "project-changed": {
36
+ const lines = [` ! project ${entry.project} changed:`];
37
+ for (const change of entry.changes ?? []) {
38
+ lines.push(
39
+ ` ${change.field}: ${JSON.stringify(change.baseline)} -> ${JSON.stringify(change.head)}`,
40
+ );
41
+ }
42
+ return lines;
43
+ }
44
+ default:
45
+ return [` ? ${entry.kind} ${entry.project ?? `${entry.from} -> ${entry.to}`}`];
46
+ }
47
+ }
48
+
49
+ /** One constraint verdict row as its report lines. */
50
+ function constraintLines(row) {
51
+ const glyph = row.verdict === "pass" ? "✔" : row.verdict === "fail" ? "✗" : "?";
52
+ return [`${glyph} ${row.name}: ${row.verdict} — ${row.message}`];
53
+ }
54
+
55
+ /** A side's identity for prose: its commit prefix, or an honest absence. */
56
+ function describeOrigin(provenance) {
57
+ if (!provenance || typeof provenance.commit !== "string") return "unverified origin";
58
+ const dirty = provenance.dirty ? ", dirty" : "";
59
+ return `${provenance.commit.slice(0, 8)}${dirty}`;
60
+ }
61
+
62
+ /**
63
+ * The whole change report.
64
+ *
65
+ * @param {{change: object, coverage: object}} input `change` is
66
+ * `../commands/change.mjs`'s result payload; `coverage` its coverage block.
67
+ * @returns {string}
68
+ */
69
+ export function formatChangeReport({ change, coverage }) {
70
+ const { intent, baseline, head, reconciliation, constraints, policy } = change;
71
+ const sections = [];
72
+
73
+ sections.push(
74
+ `intent ${intent.file} — base ${intent.base.commit.slice(0, 8)}` +
75
+ (intent.summary === undefined ? "" : `\n "${intent.summary}"`),
76
+ );
77
+ sections.push(
78
+ `baseline ${baseline.path} — ${describeOrigin(baseline.provenance)}, ` +
79
+ `${baseline.records} record${baseline.records === 1 ? "" : "s"}, ` +
80
+ `${baseline.projects} project${baseline.projects === 1 ? "" : "s"}`,
81
+ );
82
+ sections.push(
83
+ `head ${describeOrigin(head.provenance)}, ` +
84
+ `${head.projects} project${head.projects === 1 ? "" : "s"}`,
85
+ );
86
+
87
+ for (const note of coverage.notes ?? []) sections.push(`⚠ ${note}`);
88
+
89
+ const verdictLine = {
90
+ matched: "✔ MATCHED — the delta is exactly the declared change",
91
+ undeclared: "⚠ UNDECLARED — the delta contains changes no declaration covers",
92
+ unfulfilled: "✗ UNFULFILLED — nothing undeclared, but declared changes never happened",
93
+ unproven: "? UNPROVEN — the base identity could not be established",
94
+ }[reconciliation.verdict];
95
+ sections.push(`reconciliation ${verdictLine}`);
96
+ for (const reason of reconciliation.reasons) sections.push(` because: ${reason}`);
97
+
98
+ if (reconciliation.matched.length > 0) {
99
+ sections.push(
100
+ `✔ ${reconciliation.matched.length} declared change${reconciliation.matched.length === 1 ? "" : "s"} observed`,
101
+ ...reconciliation.matched.flatMap(factLines),
102
+ );
103
+ }
104
+ if (reconciliation.unexpected.length > 0) {
105
+ sections.push(
106
+ `! ${reconciliation.unexpected.length} undeclared material change${reconciliation.unexpected.length === 1 ? "" : "s"} — a review signal, not a law verdict`,
107
+ ...reconciliation.unexpected.flatMap(factLines),
108
+ );
109
+ }
110
+ if (reconciliation.missingExpected.length > 0) {
111
+ sections.push(
112
+ `? ${reconciliation.missingExpected.length} declared change${reconciliation.missingExpected.length === 1 ? "" : "s"} never observed`,
113
+ ...reconciliation.missingExpected.flatMap(factLines),
114
+ );
115
+ }
116
+
117
+ if (constraints.length > 0) {
118
+ sections.push("declared constraints");
119
+ sections.push(...constraints.flatMap(constraintLines));
120
+ }
121
+
122
+ // Informational on purpose: this number says what `check` would count right
123
+ // now. It gates nothing here — collapsing it into the intent verdict would
124
+ // hide one signal behind the other.
125
+ sections.push(
126
+ policy.liveViolations === null
127
+ ? `workspace law not evaluated — the run could not prove the base identity (archkeep check remains the authority)`
128
+ : `workspace law ${policy.liveViolations} live violation${policy.liveViolations === 1 ? "" : "s"} under the current law${policy.changedSinceBase ? ", which changed since capture" : ""} — informational; archkeep check remains the authoritative verdict`,
129
+ );
130
+
131
+ // The closing claim always states what was compared, so every outcome —
132
+ // including a full match over an unchanged tree — is a verifiable statement
133
+ // rather than silence.
134
+ const declaredCount =
135
+ intent.declared.projectsAdd +
136
+ intent.declared.projectsRemove +
137
+ intent.declared.edgesAdd +
138
+ intent.declared.edgesRemove;
139
+ sections.push(
140
+ `reconciled ${declaredCount} declared change${declaredCount === 1 ? "" : "s"} and ` +
141
+ `${constraints.length} declared constraint${constraints.length === 1 ? "" : "s"} — ` +
142
+ `base ${describeOrigin(baseline.provenance)} (${baseline.projects} projects, ` +
143
+ `${baseline.records} records) against head ${describeOrigin(head.provenance)} ` +
144
+ `(${head.projects} projects)`,
145
+ );
146
+
147
+ return sections.join("\n");
148
+ }