@ecoma-io/archkeep 0.13.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.
Files changed (37) hide show
  1. package/README.md +9 -3
  2. package/cli.mjs +599 -55
  3. package/commands.mjs +51 -0
  4. package/package.json +3 -1
  5. package/src/analysis/typescript.mjs +2 -1
  6. package/src/commands/README.md +70 -1
  7. package/src/commands/change-intent.mjs +461 -0
  8. package/src/commands/change.mjs +612 -0
  9. package/src/commands/check.mjs +84 -17
  10. package/src/commands/context.mjs +92 -16
  11. package/src/commands/coverage-acceptance.mjs +113 -0
  12. package/src/commands/custom-rules.mjs +286 -2
  13. package/src/commands/delta-classify.mjs +664 -0
  14. package/src/commands/delta-snapshot.mjs +672 -0
  15. package/src/commands/delta.mjs +606 -0
  16. package/src/commands/diff.mjs +41 -13
  17. package/src/commands/evolution.mjs +473 -0
  18. package/src/commands/explain.mjs +39 -0
  19. package/src/commands/history.mjs +130 -103
  20. package/src/commands/policy.mjs +93 -1
  21. package/src/commands/trajectory.mjs +437 -0
  22. package/src/commands/waivers.mjs +53 -3
  23. package/src/config.mjs +129 -11
  24. package/src/lsp/boundary-config.mjs +9 -4
  25. package/src/path-util.mjs +40 -0
  26. package/src/providers/native/model.mjs +17 -0
  27. package/src/report/change-text.mjs +148 -0
  28. package/src/report/delta-text.mjs +264 -0
  29. package/src/report/evolution-text.mjs +83 -0
  30. package/src/report/explain-text.mjs +27 -0
  31. package/src/report/history-text.mjs +4 -114
  32. package/src/report/sarif.mjs +280 -0
  33. package/src/report/snapshot-text.mjs +123 -0
  34. package/src/report/text.mjs +36 -0
  35. package/src/report/trajectory-text.mjs +143 -0
  36. package/src/report/waivers-text.mjs +35 -2
  37. package/src/tsconfig-paths.mjs +3 -2
@@ -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
+ }
@@ -43,6 +43,8 @@ import { referenceTime } from "../governance/clock.mjs";
43
43
  import { isWaiver, remainingMs, waiverStatus } from "../governance/waiver.mjs";
44
44
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
45
45
  import { formatWaiversReport } from "../report/waivers-text.mjs";
46
+ import { partitionUnownedCoverage } from "./coverage-acceptance.mjs";
47
+ import { unownedGapWithoutRunConfiguration } from "./context.mjs";
46
48
  import { refuseIncompleteGraph } from "./drift.mjs";
47
49
  import { resolveProvenance } from "./provenance.mjs";
48
50
  import { evaluateRun } from "../rules/index.mjs";
@@ -144,7 +146,10 @@ export function computeWaivers(suppressions, rawViolations, now = referenceTime(
144
146
  * by the same three-way call `check` makes — `cli.mjs`'s `runWaivers`
145
147
  * resolves `--config` against the working directory exactly as `runCheck`
146
148
  * does, so the surface listed is the surface the law actually enforces.
147
- * @param {{now?: string}} [io] The injected clock.
149
+ * @param {{now?: string, policySource?: string|null}} [io] The injected
150
+ * clock, and — from `cli.mjs`'s `runWaivers` — the workspace-relative path
151
+ * the run's law actually resolved from, so the `coverage.unowned` matching
152
+ * below subtracts the same configuration files `check` subtracts.
148
153
  * @returns {Promise<{status: "ok", waivers: object, report: {text: string, json: string}}>}
149
154
  * @throws {Error} whenever the run's law is malformed, or the tree has
150
155
  * whole-file analysis failures — exit-3 class, the same posture `check` takes
@@ -155,15 +160,37 @@ export async function waiversCommand(commandContext, boundaryConfig, io = {}) {
155
160
  const now = io.now ?? referenceTime();
156
161
  const config = boundaryConfig;
157
162
 
163
+ // The policy's `coverage.unowned` acceptances, matched through the SAME
164
+ // partition `check` runs (`./coverage-acceptance.mjs`) so the two surfaces
165
+ // cannot disagree about what a row covers. This command is the surface the
166
+ // acceptances' REASONS live on: `check` states the accepted files and
167
+ // points here, this names each row with its reason and current coverage —
168
+ // the same division of labour the suppression table already has between
169
+ // `check`'s accepted-violations section and this command's rows.
170
+ const unownedCoverage = partitionUnownedCoverage({
171
+ rows: config?.coverage?.unowned ?? [],
172
+ unownedGap: unownedGapWithoutRunConfiguration(commandContext.unownedGap, [
173
+ io.policySource ?? null,
174
+ commandContext.options.tsConfig,
175
+ ]),
176
+ unclaimedFiles: commandContext.unclaimedGap.files,
177
+ tracked: commandContext.tracked,
178
+ });
179
+
158
180
  // A waiver surface over a tree it could not fully read is a lottery ticket,
159
181
  // not a surface: a file the analyzer never judged contributes no raw
160
182
  // violation, so every waiver that names it reads as stale and the report
161
183
  // says "covers nothing" about a finding the run never looked at. Refuse
162
184
  // loudly on whole-file failures, the same posture `impact`, `drift`, and
163
185
  // `history` take — "could not look" must never read as "looked and found
164
- // nothing" (`./impact.mjs`'s refusal names the same silence).
186
+ // nothing" (`./impact.mjs`'s refusal names the same silence). A whole-file
187
+ // failure whose file a `coverage.unowned` row accepts is withdrawn first,
188
+ // exactly as `check` withdraws it (`./check.mjs`'s `acceptedUnclaimed`):
189
+ // its state is a recorded acceptance this very report is about to name,
190
+ // not a hole the run failed to look at.
165
191
  const notAnalyzed = analysis.failures
166
192
  .filter(isWholeFileFailure)
193
+ .filter(({ sourceFile }) => !unownedCoverage.acceptedFiles.has(sourceFile))
167
194
  .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
168
195
 
169
196
  if (notAnalyzed.length > 0) {
@@ -218,7 +245,30 @@ export async function waiversCommand(commandContext, boundaryConfig, io = {}) {
218
245
  };
219
246
 
220
247
  const context = { root, provider, marker, provenance: resolveProvenance(root) };
221
- const result = { waivers, covered, expired, stale, suppressions, suppressed };
248
+ const result = {
249
+ waivers,
250
+ covered,
251
+ expired,
252
+ stale,
253
+ suppressions,
254
+ suppressed,
255
+ // The third surface, present only when the policy declares the channel —
256
+ // the same absent-is-a-decision key discipline `check`'s envelope keeps
257
+ // for `fitness`/`customRules`, so a workspace without the key gets a
258
+ // byte-identical envelope. Each row is the declared acceptance with how
259
+ // many unowned files it currently covers; a `covered: 0` row reads like
260
+ // a stale waiver above — dead weight this command surfaces and `check`
261
+ // refuses (`./check.mjs`'s dead-row block).
262
+ ...(config?.coverage === undefined
263
+ ? {}
264
+ : {
265
+ unownedAcceptances: unownedCoverage.rows.map(({ path, reason, files }) => ({
266
+ path,
267
+ reason,
268
+ covered: files.length,
269
+ })),
270
+ }),
271
+ };
222
272
 
223
273
  const envelope = jsonEnvelope({
224
274
  command: "waivers",