@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.
- package/README.md +9 -3
- package/cli.mjs +599 -55
- package/commands.mjs +51 -0
- package/package.json +3 -1
- package/src/analysis/typescript.mjs +2 -1
- package/src/commands/README.md +70 -1
- package/src/commands/change-intent.mjs +461 -0
- package/src/commands/change.mjs +612 -0
- package/src/commands/check.mjs +84 -17
- package/src/commands/context.mjs +92 -16
- package/src/commands/coverage-acceptance.mjs +113 -0
- package/src/commands/custom-rules.mjs +286 -2
- package/src/commands/delta-classify.mjs +664 -0
- package/src/commands/delta-snapshot.mjs +672 -0
- package/src/commands/delta.mjs +606 -0
- package/src/commands/diff.mjs +41 -13
- package/src/commands/evolution.mjs +473 -0
- package/src/commands/explain.mjs +39 -0
- package/src/commands/history.mjs +130 -103
- package/src/commands/policy.mjs +93 -1
- package/src/commands/trajectory.mjs +437 -0
- package/src/commands/waivers.mjs +53 -3
- package/src/config.mjs +129 -11
- package/src/lsp/boundary-config.mjs +9 -4
- package/src/path-util.mjs +40 -0
- package/src/providers/native/model.mjs +17 -0
- package/src/report/change-text.mjs +148 -0
- package/src/report/delta-text.mjs +264 -0
- package/src/report/evolution-text.mjs +83 -0
- package/src/report/explain-text.mjs +27 -0
- package/src/report/history-text.mjs +4 -114
- package/src/report/sarif.mjs +280 -0
- package/src/report/snapshot-text.mjs +123 -0
- package/src/report/text.mjs +36 -0
- package/src/report/trajectory-text.mjs +143 -0
- package/src/report/waivers-text.mjs +35 -2
- 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
|
+
}
|
package/src/commands/waivers.mjs
CHANGED
|
@@ -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
|
|
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 = {
|
|
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",
|