@ecoma-io/archkeep 0.14.0 → 0.16.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 +11 -5
- package/cli.mjs +571 -61
- package/commands.mjs +57 -0
- package/lsp.mjs +15 -2
- package/package.json +8 -2
- package/src/analysis/analyze.mjs +15 -0
- package/src/analysis/contract.md +36 -18
- package/src/analysis/csharp.mjs +485 -0
- package/src/analysis/dotnet/csproj.mjs +380 -0
- package/src/analysis/dotnet/mask.mjs +178 -0
- package/src/analysis/dotnet/namespaces.mjs +172 -0
- package/src/analysis/dotnet/resolve.mjs +89 -0
- package/src/analysis/go.mjs +289 -5
- package/src/analysis/java.mjs +329 -0
- package/src/analysis/jvm/gradle.mjs +545 -0
- package/src/analysis/jvm/mask.mjs +170 -0
- package/src/analysis/jvm/maven.mjs +612 -0
- package/src/analysis/jvm/packages.mjs +209 -0
- package/src/analysis/jvm/resolve.mjs +139 -0
- package/src/analysis/kotlin.mjs +210 -0
- package/src/analysis/manifest-util.mjs +30 -0
- package/src/analysis/python.mjs +3 -2
- package/src/analysis/registry.mjs +11 -0
- package/src/analysis/rust.mjs +171 -17
- package/src/analysis/source-util.mjs +155 -6
- package/src/analysis/typescript.mjs +11 -3
- package/src/commands/README.md +52 -1
- package/src/commands/change-intent.mjs +461 -0
- package/src/commands/change.mjs +612 -0
- package/src/commands/check.mjs +2 -1
- package/src/commands/context.mjs +124 -16
- package/src/commands/custom-rules.mjs +286 -2
- package/src/commands/delta-classify.mjs +195 -33
- package/src/commands/delta-snapshot.mjs +156 -1
- package/src/commands/delta.mjs +142 -17
- package/src/commands/diff.mjs +41 -13
- package/src/commands/evolution.mjs +473 -0
- package/src/commands/history.mjs +130 -103
- package/src/commands/policy.mjs +57 -0
- package/src/commands/provenance.mjs +7 -44
- package/src/commands/rules.mjs +775 -0
- package/src/commands/trajectory.mjs +437 -0
- package/src/governance/profile-registry.mjs +0 -1
- package/src/graph/create-dependencies.mjs +138 -15
- package/src/lsp/diagnose.mjs +1 -1
- package/src/lsp/server.mjs +97 -1
- package/src/lsp/workspace-index.mjs +106 -15
- package/src/options.mjs +30 -7
- package/src/path-util.mjs +40 -0
- package/src/process.mjs +10 -1
- package/src/providers/moon.mjs +287 -36
- package/src/providers/native/differential.fixtures.mjs +32 -6
- package/src/providers/native/discover.mjs +83 -4
- package/src/providers/native/graph.mjs +58 -0
- package/src/providers/native/model.mjs +59 -1
- package/src/report/change-text.mjs +148 -0
- package/src/report/delta-text.mjs +82 -1
- package/src/report/evolution-text.mjs +83 -0
- package/src/report/history-text.mjs +4 -114
- package/src/report/sarif.mjs +255 -0
- package/src/report/snapshot-text.mjs +123 -0
- package/src/report/trajectory-text.mjs +143 -0
- package/src/rules/index.mjs +21 -6
- package/src/rules/reachability.mjs +2 -0
- package/src/rules/tags.mjs +7 -5
- package/src/rules/topology.mjs +5 -3
- package/src/tsconfig-paths.mjs +3 -2
- package/src/workspace.mjs +115 -23
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The graph layer: cross-project EDGES for Go, Rust, and
|
|
2
|
+
* The graph layer: cross-project EDGES for Go, Rust, Python, Java, and C#/\.NET, in the shape
|
|
3
3
|
* Nx's `createDependencies` hook returns. Nothing else — nodes still come from
|
|
4
4
|
* each project's hand-written `project.json`, and targets are never inferred
|
|
5
5
|
* (`packages/archkeep/AGENTS.md`).
|
|
@@ -14,33 +14,94 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Each resolver reads tracked manifests and sources statically (regex for Go
|
|
16
16
|
* imports, smol-toml for Cargo/pyproject manifests) so the graph computes
|
|
17
|
-
* without any language toolchain installed. A workspace with no Go/Rust/Python
|
|
18
|
-
* projects pays nothing: every resolver keys off
|
|
19
|
-
*
|
|
20
|
-
* returning — the Python one does, for a declared path dependency it cannot
|
|
21
|
-
* attribute to any project (`../analysis/python.mjs` header) — and the throw
|
|
22
|
-
* is deliberate: edges and an error are the only two outputs this hook has,
|
|
23
|
-
* and an edge quietly missing from the graph is the failure mode this plugin
|
|
24
|
-
* exists to close.
|
|
17
|
+
* without any language toolchain installed. A workspace with no Go/Rust/Python/
|
|
18
|
+
* Java/C# projects pays nothing: every resolver keys off what it reads existing in
|
|
19
|
+
* the project's tracked files.
|
|
25
20
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
21
|
+
* ## The hook's failure posture (#364)
|
|
22
|
+
*
|
|
23
|
+
* Edges and a throw are the only two outputs this hook has, and every
|
|
24
|
+
* manifest reader and name index throws for the states its funnel
|
|
25
|
+
* classifies as could-not-complete — the same failure lists the CLI turns
|
|
26
|
+
* into exit 3: the Maven, Gradle and .csproj models, the JVM package index,
|
|
27
|
+
* the C# namespace index, the Go module map and the Rust crate map
|
|
28
|
+
* (`../analysis/source-util.mjs`'s `refuseUnreadTree`).
|
|
29
|
+
* Nx wraps a throwing hook's error and fails the whole graph computation —
|
|
30
|
+
* measured against nx 23.1.1, which also has a dedicated `ProcessDependenciesError`
|
|
31
|
+
* for the event and whose daemon refuses with an error rather than serving a
|
|
32
|
+
* stale graph — so the throw is a designed-for channel, and `nx affected`
|
|
33
|
+
* fails loudly on a broken reactor instead of under-selecting on it.
|
|
34
|
+
*
|
|
35
|
+
* The boundary: manifests and workspace-wide name indexes throw — one
|
|
36
|
+
* unreadable entry corrupts resolution for every project that names that
|
|
37
|
+
* identity, arbitrarily far from the file, so the failure cannot be
|
|
38
|
+
* attributed to the file's own edges — while per-source import reads keep
|
|
39
|
+
* the null-read posture the hook's `readFile` states below (a dropped source
|
|
40
|
+
* loses only its own edges; a dropped manifest or index entry loses
|
|
41
|
+
* everyone's). Python's dangling-path throw is the precedent that predates
|
|
42
|
+
* the rule; its malformed-TOML tolerance stays the documented exception its
|
|
43
|
+
* own header pins (`../analysis/python.mjs`).
|
|
44
|
+
*
|
|
45
|
+
* Resolver contract (see `../analysis/*.mjs`): every resolver returns raw Nx
|
|
46
|
+
* edges — { source, target, sourceFile, type } and nothing else. Go, Rust and
|
|
47
|
+
* Python take `resolve(projects, filesOf, readFile)`; the C# and JVM halves
|
|
48
|
+
* instead take ONE workspace-shaped object (`{ projects, filesOf, readFile }`),
|
|
49
|
+
* because everything they read is `perWorkspace`-memoized on that object — the
|
|
50
|
+
* C# namespace index, the JVM package index, the Maven and Gradle models — and
|
|
51
|
+
* a resolver destructuring its own arguments would have to build a fresh
|
|
52
|
+
* object per call, defeating the memo (#363: three builds of the same index
|
|
53
|
+
* per run).
|
|
28
54
|
*/
|
|
29
55
|
import { readFileSync } from "node:fs";
|
|
30
56
|
import { join } from "node:path";
|
|
31
57
|
|
|
32
58
|
import { containmentViolation } from "../containment.mjs";
|
|
59
|
+
import { resolveCsharpDependencies } from "../analysis/csharp.mjs";
|
|
60
|
+
import { resolveCsprojDependencies, dotnetManifestFailures } from "../analysis/dotnet/csproj.mjs";
|
|
33
61
|
import { resolveGoDependencies } from "../analysis/go.mjs";
|
|
62
|
+
import { resolveJavaDependencies } from "../analysis/java.mjs";
|
|
63
|
+
import { resolveKotlinDependencies } from "../analysis/kotlin.mjs";
|
|
64
|
+
import { resolveMavenDependencies, mavenManifestFailures } from "../analysis/jvm/maven.mjs";
|
|
65
|
+
import { resolveGradleDependencies, gradleManifestFailures } from "../analysis/jvm/gradle.mjs";
|
|
66
|
+
import { dotnetIndexFailures } from "../analysis/dotnet/namespaces.mjs";
|
|
34
67
|
import { resolvePythonDependencies } from "../analysis/python.mjs";
|
|
35
68
|
import { resolveRustDependencies } from "../analysis/rust.mjs";
|
|
36
69
|
import { resolveOptions } from "../options.mjs";
|
|
37
70
|
|
|
38
71
|
/** Pure core over an abstract workspace; injectable for tests. */
|
|
39
72
|
export function resolvePolyglotDependencies(projects, filesOf, readFile) {
|
|
73
|
+
// The C# and JVM halves read the same tree through ONE workspace-shaped
|
|
74
|
+
// object: their memoized reads (namespace index, package index, Maven and
|
|
75
|
+
// Gradle models) all key on the object itself, so every resolver handed the
|
|
76
|
+
// same one shares a single build of each — and the memoized read behind the
|
|
77
|
+
// object means no file's content is fetched twice on one graph computation.
|
|
78
|
+
// The positional resolvers (Go, Rust, Python) hold no such memo, so they
|
|
79
|
+
// keep taking the three values directly.
|
|
80
|
+
const reads = new Map();
|
|
81
|
+
const sharedWorkspace = {
|
|
82
|
+
projects,
|
|
83
|
+
filesOf,
|
|
84
|
+
readFile: (path) => {
|
|
85
|
+
if (!reads.has(path)) reads.set(path, readFile(path));
|
|
86
|
+
return reads.get(path);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
40
89
|
const deps = [
|
|
41
90
|
...resolveGoDependencies(projects, filesOf, readFile),
|
|
42
91
|
...resolveRustDependencies(projects, filesOf, readFile),
|
|
43
92
|
...resolvePythonDependencies(projects, filesOf, readFile),
|
|
93
|
+
...resolveJavaDependencies(sharedWorkspace),
|
|
94
|
+
...resolveKotlinDependencies(sharedWorkspace),
|
|
95
|
+
...resolveCsharpDependencies(sharedWorkspace),
|
|
96
|
+
// Manifest edges for .csproj trees: ProjectReference resolution,
|
|
97
|
+
// independent of (and complementary to) the source-track edges above.
|
|
98
|
+
...resolveCsprojDependencies(sharedWorkspace),
|
|
99
|
+
// Manifest edges for Maven/Gradle trees: the identity-anchor half of JVM
|
|
100
|
+
// support, independent of (and complementary to) the import edges above
|
|
101
|
+
// — a declared-but-unused dependency and an undeclared-but-imported one
|
|
102
|
+
// are both findings.
|
|
103
|
+
...resolveMavenDependencies(sharedWorkspace),
|
|
104
|
+
...resolveGradleDependencies(sharedWorkspace),
|
|
44
105
|
];
|
|
45
106
|
// One edge per (source, target, sourceFile) — a Go project importing a
|
|
46
107
|
// sibling from ten files yields ten sourceFile-attributed edges upstream
|
|
@@ -54,6 +115,64 @@ export function resolvePolyglotDependencies(projects, filesOf, readFile) {
|
|
|
54
115
|
});
|
|
55
116
|
}
|
|
56
117
|
|
|
118
|
+
/**
|
|
119
|
+
* The manifest-track edges in one list — `<ProjectReference>` (plus
|
|
120
|
+
* `<Using Include>`) for .NET trees, Maven coordinate matching and Gradle
|
|
121
|
+
* `project(":x")` for JVM ones. `resolvePolyglotDependencies` above already
|
|
122
|
+
* folds these into the Nx plugin hook's answer; the CLI's native and Moon
|
|
123
|
+
* branches and the language server's index build their graphs from import
|
|
124
|
+
* sites alone, with no plugin host to call the hook, so they fold this list
|
|
125
|
+
* themselves (`../providers/native/graph.mjs`'s `mergeDeclaredEdges`) — the
|
|
126
|
+
* two-track contract of `../../../../docs/adr/0006-dotnet-language-integration.md`
|
|
127
|
+
* (Decision 3) and `../../../../docs/adr/0005-jvm-language-integration.md`
|
|
128
|
+
* (Decision 4) names no face it does not hold on.
|
|
129
|
+
*
|
|
130
|
+
* Each resolver refuses — throws — on exactly the failure list its
|
|
131
|
+
* `*ManifestFailures` twin reports from the same memoized model
|
|
132
|
+
* (`../../analysis/source-util.mjs`'s `perWorkspace` memoizes on the
|
|
133
|
+
* workspace object, so twin calls share one build): a caller that has found
|
|
134
|
+
* `mavenManifestFailures`/`gradleManifestFailures`/`dotnetManifestFailures`/
|
|
135
|
+
* `dotnetIndexFailures` empty on THIS workspace cannot hit the throw.
|
|
136
|
+
*
|
|
137
|
+
* @param {object} workspace The shared workspace-shaped object.
|
|
138
|
+
* @returns {{source: string, target: string, sourceFile: string, type: string}[]}
|
|
139
|
+
*/
|
|
140
|
+
export function resolveDeclaredManifestEdges(workspace) {
|
|
141
|
+
return [
|
|
142
|
+
...resolveCsprojDependencies(workspace),
|
|
143
|
+
...resolveMavenDependencies(workspace),
|
|
144
|
+
...resolveGradleDependencies(workspace),
|
|
145
|
+
];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The could-not-complete failures behind `resolveDeclaredManifestEdges`'s
|
|
150
|
+
* refusal — the four manifest and index failure lists folded into one. A
|
|
151
|
+
* caller uses this list twice: folded into its own whole-file failure
|
|
152
|
+
* reporting, and as the no-throw guard for the edge call — each resolver
|
|
153
|
+
* refuses on exactly the failures its `*ManifestFailures` twin reports from
|
|
154
|
+
* the same memoized model, so an empty list here makes
|
|
155
|
+
* `resolveDeclaredManifestEdges` unable to throw on this workspace. Both
|
|
156
|
+
* halves of that sentence live beside each other here so the identity cannot
|
|
157
|
+
* drift between a caller that forgets to report and one that skips the guard.
|
|
158
|
+
*
|
|
159
|
+
* `jvmIndexFailures` and `pythonUnmodelledFailures` are deliberately absent:
|
|
160
|
+
* no resolver here reads the JVM package index or refuses on Python's
|
|
161
|
+
* unmodelled posture, so neither is a condition of the no-throw guarantee —
|
|
162
|
+
* callers keep those lists flowing through their own funnels.
|
|
163
|
+
*
|
|
164
|
+
* @param {object} workspace The shared workspace-shaped object.
|
|
165
|
+
* @returns {{sourceFile: string, reason: string}[]}
|
|
166
|
+
*/
|
|
167
|
+
export function resolveDeclaredManifestFailures(workspace) {
|
|
168
|
+
return [
|
|
169
|
+
...mavenManifestFailures(workspace),
|
|
170
|
+
...gradleManifestFailures(workspace),
|
|
171
|
+
...dotnetManifestFailures(workspace),
|
|
172
|
+
...dotnetIndexFailures(workspace),
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
|
|
57
176
|
/**
|
|
58
177
|
* The Nx hook.
|
|
59
178
|
*
|
|
@@ -81,10 +200,14 @@ export const createDependencies = (options, context) => {
|
|
|
81
200
|
// attacker-supplied the moment a PR adds a tracked path. A tracked symlink
|
|
82
201
|
// whose realpath leaves the workspace would draw a dependency edge from
|
|
83
202
|
// outside bytes into `nx affected`'s graph; refusing (null) drops the
|
|
84
|
-
// read
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
203
|
+
// read. For a SOURCE file an import walk reads, that is the end of it:
|
|
204
|
+
// null read = no edge, the file's own edges and nothing wider. For a
|
|
205
|
+
// MANIFEST or an index-feeding file (a pom, a settings or build file, a
|
|
206
|
+
// .csproj, a .java/.kt/.cs a name index reads), the reader records the
|
|
207
|
+
// null read as a could-not-complete failure and its resolver throws on
|
|
208
|
+
// it — the posture this file's header owns (#364). A plugin that never
|
|
209
|
+
// resolves outside bytes stays silent-green only when the bytes are
|
|
210
|
+
// really inside (`../containment.mjs`).
|
|
88
211
|
if (containmentViolation(context.workspaceRoot, abs) !== null) return null;
|
|
89
212
|
try {
|
|
90
213
|
return readFileSync(abs, "utf8");
|
package/src/lsp/diagnose.mjs
CHANGED
|
@@ -63,7 +63,7 @@ import { indexGaps } from "./workspace-index.mjs";
|
|
|
63
63
|
* @param {string} request.text Its current contents — the editor's buffer, not
|
|
64
64
|
* what is on disk. Diagnosing the saved file would answer a question nobody
|
|
65
65
|
* asked while the developer is looking at their unsaved edit.
|
|
66
|
-
* @param {{workspace: object, graph: object, skippedProjects?: object[], fileFailures?: object[], importSites?: object[], nativeMarker?: boolean, nativeModelFailure?: string|null, moonModelFailure?: string|null, nxModelFailure?: string|null, workspaceLayoutFailure?: string|null}} request.index
|
|
66
|
+
* @param {{workspace: object, graph: object, skippedProjects?: object[], fileFailures?: object[], importSites?: object[], duplicateProjects?: object[], nativeMarker?: boolean, nativeModelFailure?: string|null, moonModelFailure?: string|null, nxModelFailure?: string|null, workspaceLayoutFailure?: string|null}} request.index
|
|
67
67
|
* From `./workspace-index.mjs`. `importSites` is the whole tree's retained
|
|
68
68
|
* analysis output — the evidence half of the run below; absent (an index
|
|
69
69
|
* built before it existed) reads as none, which degrades evidence, never a
|