@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.
Files changed (68) hide show
  1. package/README.md +11 -5
  2. package/cli.mjs +571 -61
  3. package/commands.mjs +57 -0
  4. package/lsp.mjs +15 -2
  5. package/package.json +8 -2
  6. package/src/analysis/analyze.mjs +15 -0
  7. package/src/analysis/contract.md +36 -18
  8. package/src/analysis/csharp.mjs +485 -0
  9. package/src/analysis/dotnet/csproj.mjs +380 -0
  10. package/src/analysis/dotnet/mask.mjs +178 -0
  11. package/src/analysis/dotnet/namespaces.mjs +172 -0
  12. package/src/analysis/dotnet/resolve.mjs +89 -0
  13. package/src/analysis/go.mjs +289 -5
  14. package/src/analysis/java.mjs +329 -0
  15. package/src/analysis/jvm/gradle.mjs +545 -0
  16. package/src/analysis/jvm/mask.mjs +170 -0
  17. package/src/analysis/jvm/maven.mjs +612 -0
  18. package/src/analysis/jvm/packages.mjs +209 -0
  19. package/src/analysis/jvm/resolve.mjs +139 -0
  20. package/src/analysis/kotlin.mjs +210 -0
  21. package/src/analysis/manifest-util.mjs +30 -0
  22. package/src/analysis/python.mjs +3 -2
  23. package/src/analysis/registry.mjs +11 -0
  24. package/src/analysis/rust.mjs +171 -17
  25. package/src/analysis/source-util.mjs +155 -6
  26. package/src/analysis/typescript.mjs +11 -3
  27. package/src/commands/README.md +52 -1
  28. package/src/commands/change-intent.mjs +461 -0
  29. package/src/commands/change.mjs +612 -0
  30. package/src/commands/check.mjs +2 -1
  31. package/src/commands/context.mjs +124 -16
  32. package/src/commands/custom-rules.mjs +286 -2
  33. package/src/commands/delta-classify.mjs +195 -33
  34. package/src/commands/delta-snapshot.mjs +156 -1
  35. package/src/commands/delta.mjs +142 -17
  36. package/src/commands/diff.mjs +41 -13
  37. package/src/commands/evolution.mjs +473 -0
  38. package/src/commands/history.mjs +130 -103
  39. package/src/commands/policy.mjs +57 -0
  40. package/src/commands/provenance.mjs +7 -44
  41. package/src/commands/rules.mjs +775 -0
  42. package/src/commands/trajectory.mjs +437 -0
  43. package/src/governance/profile-registry.mjs +0 -1
  44. package/src/graph/create-dependencies.mjs +138 -15
  45. package/src/lsp/diagnose.mjs +1 -1
  46. package/src/lsp/server.mjs +97 -1
  47. package/src/lsp/workspace-index.mjs +106 -15
  48. package/src/options.mjs +30 -7
  49. package/src/path-util.mjs +40 -0
  50. package/src/process.mjs +10 -1
  51. package/src/providers/moon.mjs +287 -36
  52. package/src/providers/native/differential.fixtures.mjs +32 -6
  53. package/src/providers/native/discover.mjs +83 -4
  54. package/src/providers/native/graph.mjs +58 -0
  55. package/src/providers/native/model.mjs +59 -1
  56. package/src/report/change-text.mjs +148 -0
  57. package/src/report/delta-text.mjs +82 -1
  58. package/src/report/evolution-text.mjs +83 -0
  59. package/src/report/history-text.mjs +4 -114
  60. package/src/report/sarif.mjs +255 -0
  61. package/src/report/snapshot-text.mjs +123 -0
  62. package/src/report/trajectory-text.mjs +143 -0
  63. package/src/rules/index.mjs +21 -6
  64. package/src/rules/reachability.mjs +2 -0
  65. package/src/rules/tags.mjs +7 -5
  66. package/src/rules/topology.mjs +5 -3
  67. package/src/tsconfig-paths.mjs +3 -2
  68. 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
+ }
@@ -342,7 +342,6 @@ export function loadProfileRegistry(
342
342
  if (violations.length > 0) {
343
343
  throw new Error(`archkeep: ${path} is malformed:\n ${violations.join("\n ")}`);
344
344
  }
345
- /** @type {{profiles: object[]}} */ (parsed);
346
345
  return parsed;
347
346
  }
348
347
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The graph layer: cross-project EDGES for Go, Rust, and Python, in the shape
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 `<projectRoot>/<manifest>`
19
- * existing in the project's tracked files. A resolver may THROW instead of
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
- * Resolver contract (kept identical across languages, see `../analysis/*.mjs`):
27
- * resolve(projects, filesOf, readFile) -> [{ source, target, sourceFile, type }]
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 so the file produces no edge (the `resolvePolyglotDependencies`
85
- // contract is a null read = no edge, not a throw this hook cannot exit
86
- // non-zero by design). A plugin that never resolves outside bytes stays
87
- // silent-green only when the bytes are really inside (`../containment.mjs`).
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");
@@ -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