@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,612 @@
1
+ /**
2
+ * Maven manifest reader and edge resolver — the identity-anchor half of JVM
3
+ * support wherever a root `pom.xml` stands for a project. Static only: no
4
+ * `mvn`, no JVM, no network (`docs/reference/languages.md` owns why graphs
5
+ * compute on machines with no toolchain).
6
+ *
7
+ * ## What v1 reads, and the simplification that makes it safe
8
+ *
9
+ * Boundary edges need `(groupId, artifactId)` matching ONLY. Versions — hence
10
+ * `<dependencyManagement>`, BOM imports, version ranges, mediation, and
11
+ * profile activation — decide which jar downloads, and nothing archkeep
12
+ * evaluates consumes that answer, so no code below ever reads a `<version>`
13
+ * element. The version-resolution apparatus is out of scope by construction
14
+ * rather than by omission.
15
+ *
16
+ * Read per ROOT pom (the one-manifest-per-project-root rule; a nested second
17
+ * pom inside a project draws no graph edge — the documented modeling limit,
18
+ * with analysis attributing the file instead):
19
+ *
20
+ * - `<groupId>` inherited along a parent chain found INSIDE the tracked tree.
21
+ * Each link resolves the way a reactor build resolves it: the declared
22
+ * `<relativePath>` first (default `../pom.xml`; a directory spelling gets
23
+ * `pom.xml` appended, matching Maven's own two-step resolution; an EMPTY
24
+ * element names no path at all — resolution by declaration only), then the
25
+ * parent's `(groupId, artifactId)` across every tracked root pom when the
26
+ * path step holds nothing. A parent neither step can name leaves the
27
+ * child's groupId unresolved: the project still draws its OUTBOUND edges,
28
+ * contributes no identity others can name, and `mavenManifestFailures`
29
+ * records the pom loudly — silence would read as "nobody depends on it",
30
+ * which is exactly what is unknown.
31
+ * - `<properties>` merged down the same chain (nearest wins), then
32
+ * `-Dkey=value` lines from `.mvn/maven.config` beside the workspace root
33
+ * and beside the declaring project (user properties outrank pom
34
+ * properties). `${project.groupId}`-style built-ins resolve against the
35
+ * finished model. A placeholder resolving to nothing keeps its literal
36
+ * text visible inside a failure naming the pom — never shipped into a
37
+ * comparison, never silently dropped.
38
+ * - main-scope `<dependencies>`; every scope, including `test`, draws the
39
+ * same edge (project granularity is the unit of law; the Gradle reader
40
+ * lands on the identical rule). `<profiles>` are NOT read: reading them
41
+ * would fabricate edges from configurations that never activate — the
42
+ * false-violation direction — while source-level analysis backstops
43
+ * whatever a skipped profile really carries.
44
+ * - `<modules>` belong to discovery (the native provider phase), not edges.
45
+ *
46
+ * ## Malformed input degrades loudly
47
+ *
48
+ * A missing parser package, XML that does not parse, unresolvable placeholder
49
+ * coordinates, duplicate identities across projects, and parent cycles all
50
+ * surface through `mavenManifestFailures` as whole-file failures naming the
51
+ * pom — the go.work precedent. A broken reactor read as "no dependencies"
52
+ * would mean "no drift" exactly where the tree is most broken, so the CLI
53
+ * funnels these into the could-not-complete class (exit 3) beside Python's
54
+ * unmodelled manifests — and the graph resolver below THROWS on the same
55
+ * list (#364's posture, `../source-util.mjs`'s `refuseUnreadTree`), so
56
+ * `nx affected` fails loudly instead of under-selecting on it.
57
+ */
58
+
59
+ import { createRequire } from "node:module";
60
+
61
+ import { normalizePath, resolveWithinWorkspace } from "../manifest-util.mjs";
62
+ import { fileFailure, perWorkspace, refuseUnreadTree } from "../source-util.mjs";
63
+
64
+ /** The parser's specifier, named once — the failure message quotes it. */
65
+ const XML_PARSER = "fast-xml-parser";
66
+
67
+ /** Resolved once, success or failure, and remembered either way. */
68
+ let parserLoad = null;
69
+
70
+ /**
71
+ * The XML parser, loaded on first pom encounter through `createRequire`. An
72
+ * optional peer: trees with no Maven pay nothing for it, and a missing
73
+ * install becomes a failure record naming what is absent — never a throw
74
+ * (`vue/compiler-sfc`'s precedent, `../vue.mjs`). Entity processing is off:
75
+ * first-party tracked poms have no business carrying entity expansions, and
76
+ * disabling the feature removes the XXE/billion-laughs class rather than
77
+ * bounding it.
78
+ */
79
+ function xmlParser() {
80
+ if (parserLoad === null) {
81
+ try {
82
+ const require = createRequire(import.meta.url);
83
+ const { XMLParser, XMLValidator } = require(XML_PARSER);
84
+ // XMLValidator is a namespace; its .validate is the well-formedness gate.
85
+ parserLoad = {
86
+ parser: new XMLParser({ processEntities: false }),
87
+ validate: typeof XMLValidator?.validate === "function" ? XMLValidator.validate : null,
88
+ error: null,
89
+ };
90
+ } catch (cause) {
91
+ parserLoad = { parser: null, validate: null, error: cause?.message ?? String(cause) };
92
+ }
93
+ }
94
+ return parserLoad;
95
+ }
96
+
97
+ /**
98
+ * Every value of a possibly-repeated element as an object list:
99
+ * fast-xml-parser collapses singletons to scalars, and a pom with one
100
+ * `<dependency>` must read exactly like one with two.
101
+ *
102
+ * @param {unknown} value
103
+ * @returns {Record<string, unknown>[]}
104
+ */
105
+ function elementsOf(value) {
106
+ if (value === undefined || value === null) return [];
107
+ const list = Array.isArray(value) ? value : [value];
108
+ return list.filter((item) => typeof item === "object" && item !== null);
109
+ }
110
+
111
+ /** An element's trimmed text, or null when absent or empty. */
112
+ const textOf = (value) => {
113
+ if (typeof value !== "string") return null;
114
+ const trimmed = value.trim();
115
+ return trimmed === "" ? null : trimmed;
116
+ };
117
+
118
+ /**
119
+ * Parse one pom's text down to its `<project>` element.
120
+ *
121
+ * Well-formedness goes through the parser's own validator FIRST: the
122
+ * tree-builder is lenient by design (it auto-closes unclosed tags), and a
123
+ * pom an editor truncated mid-element must read as malformed, never as a
124
+ * project with whatever elements happened to survive.
125
+ *
126
+ * @param {string} text Raw file contents.
127
+ * @returns {{ project: Record<string, unknown>, reason?: undefined } |
128
+ * { project?: undefined, reason: string }}
129
+ */
130
+ export function parsePomProject(text) {
131
+ const { parser, validate, error } = xmlParser();
132
+ if (parser === null) {
133
+ return { reason: `${XML_PARSER} is unavailable (${error})` };
134
+ }
135
+ const verdict = validate?.(text, { allowBooleanAttributes: true });
136
+ if (verdict && verdict !== true) {
137
+ return { reason: `malformed XML (${verdict.err.msg})` };
138
+ }
139
+ let document;
140
+ try {
141
+ document = /** @type {Record<string, unknown>} */ (parser.parse(text));
142
+ } catch {
143
+ return { reason: "malformed XML" };
144
+ }
145
+ const rawProject = document?.project;
146
+ if (typeof rawProject !== "object" || rawProject === null) {
147
+ return { reason: "no <project> element" };
148
+ }
149
+ return { project: /** @type {Record<string, unknown>} */ (rawProject) };
150
+ }
151
+
152
+ /**
153
+ * One root pom's facts. `effectiveGroupId`, `properties`, and
154
+ * `resolvedDependencies` are filled in by the model builder — everything else
155
+ * is what this pom itself says.
156
+ *
157
+ * @typedef {object} PomEntry
158
+ * @property {string} pomPath Workspace-relative.
159
+ * @property {string} projectName The project whose root this pom anchors.
160
+ * @property {string|null} declaredGroupId Own `<groupId>`.
161
+ * @property {string|null} artifactId
162
+ * @property {{ groupId: string|null, artifactId: string|null,
163
+ * relativePath: string|null, explicitRemote: boolean }|null} parent
164
+ * @property {Record<string, string>} ownProperties
165
+ * @property {{ groupIdRaw: string, artifactIdRaw: string }[]} declaredDependencies
166
+ * @property {string[]} declaredModules `<modules><module>` entries as written.
167
+ * @property {string|null} effectiveGroupId
168
+ * @property {Record<string, string>} properties Effective property table.
169
+ * @property {{ groupId: string, artifactId: string }[]} resolvedDependencies
170
+ */
171
+
172
+ /**
173
+ * Extract one pom entry. Never throws; shapes it cannot trust become the
174
+ * nulls the model builder already handles.
175
+ *
176
+ * @param {string} projectName
177
+ * @param {string} pomPath
178
+ * @param {string} text
179
+ * @returns {{ entry: PomEntry, reason?: undefined } | { entry?: undefined, reason: string }}
180
+ */
181
+ export function pomEntryOf(projectName, pomPath, text) {
182
+ const parsed = parsePomProject(text);
183
+ if (parsed.reason !== undefined) return { reason: parsed.reason };
184
+ const project = parsed.project;
185
+
186
+ const parentRaw = elementsOf(project.parent)[0];
187
+ let parent = null;
188
+ if (parentRaw) {
189
+ const relativeText = textOf(parentRaw.relativePath);
190
+ parent = {
191
+ groupId: textOf(parentRaw.groupId),
192
+ artifactId: textOf(parentRaw.artifactId),
193
+ // Absent means Maven's documented `../pom.xml` default; present-but-
194
+ // empty (`<relativePath/>`) means repository resolution by declaration.
195
+ relativePath: relativeText,
196
+ explicitRemote: relativeText === null && "relativePath" in parentRaw,
197
+ };
198
+ }
199
+
200
+ const ownProperties = /** @type {Record<string, string>} */ ({});
201
+ for (const [key, value] of Object.entries(elementsOf(project.properties)[0] ?? {})) {
202
+ const propertyText = textOf(value);
203
+ if (propertyText !== null) ownProperties[key] = propertyText;
204
+ }
205
+
206
+ const declaredDependencies = elementsOf(project.dependencies)
207
+ .flatMap((block) => elementsOf(block.dependency))
208
+ .map((dep) => ({
209
+ groupIdRaw: textOf(dep.groupId) ?? "",
210
+ artifactIdRaw: textOf(dep.artifactId) ?? "",
211
+ }))
212
+ .filter((dep) => dep.groupIdRaw !== "" && dep.artifactIdRaw !== "");
213
+
214
+ // <module> children are TEXT elements, so fast-xml-parser hands them over
215
+ // as strings (a bare string when there is one) — unlike <dependency>,
216
+ // whose children are objects.
217
+ const rawModules = elementsOf(project.modules)[0]?.module;
218
+ const moduleValues = Array.isArray(rawModules)
219
+ ? rawModules
220
+ : rawModules === undefined
221
+ ? []
222
+ : [rawModules];
223
+ const declaredModules = moduleValues
224
+ .map((moduleName) => textOf(moduleName) ?? "")
225
+ .filter((moduleName) => moduleName !== "");
226
+
227
+ return {
228
+ entry: {
229
+ pomPath,
230
+ projectName,
231
+ declaredGroupId: textOf(project.groupId),
232
+ artifactId: textOf(project.artifactId),
233
+ parent,
234
+ ownProperties,
235
+ declaredDependencies,
236
+ declaredModules,
237
+ effectiveGroupId: null,
238
+ properties: /** @type {Record<string, string>} */ ({}),
239
+ resolvedDependencies: [],
240
+ },
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Where a parent `<relativePath>` lands — trying the file spelling and the
246
+ * directory spelling Maven itself accepts — or null when it escapes the
247
+ * workspace. `resolveWithinWorkspace` answers null on escape, so a
248
+ * `../../..` chain cannot clamp onto a directory that was never in the tree.
249
+ *
250
+ * @param {string} pomPath The CHILD pom's workspace-relative path.
251
+ * @param {string} relativePath As written; never empty (empty is remote).
252
+ * @returns {string|null}
253
+ */
254
+ export function parentPomPath(pomPath, relativePath) {
255
+ const dir = pomPath.includes("/") ? pomPath.slice(0, pomPath.lastIndexOf("/")) : "";
256
+ const direct = resolveWithinWorkspace(dir, relativePath);
257
+ if (direct === null) return null;
258
+ return direct.endsWith(".xml") ? direct : normalizePath(direct, "pom.xml");
259
+ }
260
+
261
+ /**
262
+ * The `-Dkey=value` user properties a `.mvn/maven.config` contributes, one
263
+ * token per line, optionally quoted. Only the two conventional locations are
264
+ * consulted — the workspace root and the declaring project's root; a deeper
265
+ * `.mvn` directory is a pinned limit, compensated by the loud-failure path
266
+ * for any placeholder only it would have resolved.
267
+ *
268
+ * @param {(path: string) => string|null} readFile Workspace-relative reader.
269
+ * @param {string[]} candidatePaths
270
+ * @returns {Record<string, string>}
271
+ */
272
+ export function mavenConfigProperties(readFile, candidatePaths) {
273
+ const props = /** @type {Record<string, string>} */ ({});
274
+ for (const path of candidatePaths) {
275
+ const text = readFile(path);
276
+ if (text === null || text === undefined) continue;
277
+ for (const rawLine of text.split(/\r?\n/)) {
278
+ const token = rawLine.trim().replace(/^["']+|["']+$/g, "");
279
+ const definition = /^-D([^=]+)=(.*)$/.exec(token);
280
+ if (definition) props[definition[1]] = definition[2];
281
+ else {
282
+ // `-Dkey` alone sets an empty user property in Maven.
283
+ const bare = /^-D([^=]+)$/.exec(token);
284
+ if (bare) props[bare[1]] = "";
285
+ }
286
+ }
287
+ }
288
+ return props;
289
+ }
290
+
291
+ /**
292
+ * Interpolate one coordinate string against the effective table plus the
293
+ * built-ins. Unresolvable placeholders stay visible in the output and fail
294
+ * the coordinate, so a caller can name the pom instead of comparing against
295
+ * a literal `${...}`.
296
+ *
297
+ * @param {string} value
298
+ * @param {Record<string, string>} props
299
+ * @param {Record<string, string>} builtins
300
+ * @returns {{ value: string, resolved: boolean }}
301
+ */
302
+ export function interpolateCoordinate(value, props, builtins) {
303
+ let resolved = true;
304
+ const out = value.replace(/\$\{([^}]+)\}/g, (_, key) => {
305
+ if (Object.hasOwn(builtins, key)) return builtins[key];
306
+ if (Object.hasOwn(props, key)) return props[key];
307
+ resolved = false;
308
+ return `\${${key}}`;
309
+ });
310
+ return { value: out, resolved };
311
+ }
312
+
313
+ /**
314
+ * Build the workspace-wide Maven model once per workspace object: entries
315
+ * with effective coordinates, the identity table, and the failures both
316
+ * consumers read.
317
+ *
318
+ * @param {object} workspace `{ projects, filesOf(name), readFile(path), root? }`
319
+ * @returns {{
320
+ * entries: PomEntry[],
321
+ * identityHolders: Map<string, { projectName: string, pomPath: string }[]>,
322
+ * failures: { sourceFile: string, reason: string }[]
323
+ * }}
324
+ */
325
+ function buildMavenModel(workspace) {
326
+ const readFile = workspace.readFile;
327
+ const failures = [];
328
+ /** Every tracked file, for the module-drift check below. */
329
+ const tracked = new Set();
330
+ for (const project of workspace.projects) {
331
+ for (const file of workspace.filesOf(project.name)) tracked.add(file);
332
+ }
333
+ /** @type {PomEntry[]} */
334
+ const entries = [];
335
+
336
+ // Pass 1 — parse every root pom.
337
+ for (const project of workspace.projects) {
338
+ const pomPath = normalizePath(project.root ?? "", "pom.xml");
339
+ if (!workspace.filesOf(project.name).includes(pomPath)) continue;
340
+ const text = readFile(pomPath);
341
+ if (text === null || text === undefined) {
342
+ failures.push({ sourceFile: pomPath, reason: "cannot be read" });
343
+ continue;
344
+ }
345
+ const extracted = pomEntryOf(project.name, pomPath, text);
346
+ if (extracted.reason !== undefined) {
347
+ failures.push({ sourceFile: pomPath, reason: extracted.reason });
348
+ continue;
349
+ }
350
+ entries.push(extracted.entry);
351
+ }
352
+
353
+ const entryByPomPath = new Map(entries.map((entry) => [entry.pomPath, entry]));
354
+
355
+ // Pass 2 — parent links, each resolved the way a reactor build resolves
356
+ // one: the declared <relativePath> first (Maven's default `../pom.xml`; a
357
+ // directory spelling gets `pom.xml` appended), then — when that path holds
358
+ // no tracked pom, or an empty <relativePath/> skipped the path step on
359
+ // purpose — the parent's coordinates across every tracked root pom. A
360
+ // reactor whose parent sits at the workspace root while its children sit
361
+ // two levels deep is the shape the path step alone cannot serve, and the
362
+ // coordinate step is what real Maven's own reactor resolution does there.
363
+ /** @type {Map<string, PomEntry>} pom path -> the parent entry it resolved to. */
364
+ const parentLink = new Map();
365
+ for (const entry of entries) {
366
+ const ref = entry.parent;
367
+ if (!ref || ref.explicitRemote || ref.groupId === null) continue;
368
+ const path = parentPomPath(entry.pomPath, ref.relativePath ?? "../pom.xml");
369
+ const target = path === null ? undefined : entryByPomPath.get(path);
370
+ if (target !== undefined) parentLink.set(entry.pomPath, target);
371
+ }
372
+
373
+ /**
374
+ * The chain above one entry under the links resolved so far, with the pom a
375
+ * cycle was entered through when the chain closes on itself. Pure on
376
+ * purpose: the fixpoint below walks every entry once per round, and a walk
377
+ * that recorded failures would record them once per round.
378
+ *
379
+ * @param {PomEntry} entry
380
+ * @returns {{ chain: PomEntry[], cycleThrough: string|null }}
381
+ */
382
+ const walkFrom = (entry) => {
383
+ const chain = [entry];
384
+ const visited = new Set([entry.pomPath]);
385
+ let current = entry;
386
+ let cycleThrough = null;
387
+ for (;;) {
388
+ const next = parentLink.get(current.pomPath);
389
+ if (next === undefined) break;
390
+ if (visited.has(next.pomPath)) {
391
+ cycleThrough = next.pomPath;
392
+ break;
393
+ }
394
+ visited.add(next.pomPath);
395
+ chain.push(next);
396
+ current = next;
397
+ }
398
+ return { chain, cycleThrough };
399
+ };
400
+
401
+ // Linking iterates to a fixpoint because a parent found by coordinates can
402
+ // complete the identity another entry's declaration is waiting on: the
403
+ // child of a child whose groupIds both come from a root parent needs one
404
+ // round per coordinate link. Each round links at least one more entry or
405
+ // is the last — links only grow, over a finite set — so the loop cannot
406
+ // outlive `entries`.
407
+ for (;;) {
408
+ /** Identities as they stand: "g:a" -> the entries carrying it. */
409
+ const holdersNow = new Map();
410
+ for (const entry of entries) {
411
+ const { chain } = walkFrom(entry);
412
+ const groupId = chain.find((link) => link.declaredGroupId !== null)?.declaredGroupId;
413
+ if (groupId === undefined || entry.artifactId === null) continue;
414
+ const key = `${groupId}:${entry.artifactId}`;
415
+ holdersNow.set(key, [...(holdersNow.get(key) ?? []), entry]);
416
+ }
417
+ let linked = false;
418
+ for (const entry of entries) {
419
+ if (parentLink.has(entry.pomPath)) continue;
420
+ const ref = entry.parent;
421
+ if (!ref || ref.groupId === null || ref.artifactId === null) continue;
422
+ // Ambiguous coordinates link nothing — pass 3 below fails the run on
423
+ // the duplicate identity; a guess here would hide that there were two.
424
+ const holders = holdersNow.get(`${ref.groupId}:${ref.artifactId}`);
425
+ if (holders === undefined || holders.length !== 1) continue;
426
+ parentLink.set(entry.pomPath, holders[0]);
427
+ linked = true;
428
+ }
429
+ if (!linked) break;
430
+ }
431
+
432
+ // Per entry: the chain walked to its end (not just to the first groupId:
433
+ // properties inherit from ancestors a nearer groupId would hide), its
434
+ // failures, and its effective facts.
435
+ for (const entry of entries) {
436
+ const { chain, cycleThrough } = walkFrom(entry);
437
+ if (cycleThrough !== null) {
438
+ failures.push({
439
+ sourceFile: entry.pomPath,
440
+ reason: `sits on a parent cycle through ${cycleThrough}`,
441
+ });
442
+ }
443
+
444
+ // Effective groupId: nearest link that declares one, own included.
445
+ const owner = chain.find((link) => link.declaredGroupId !== null);
446
+ entry.effectiveGroupId = owner?.declaredGroupId ?? null;
447
+ if (entry.effectiveGroupId === null) {
448
+ // The unresolved link belongs to the chain's top — the walk stopped
449
+ // there — and that link's own shape decides which sentence names the
450
+ // pom back.
451
+ const top = chain[chain.length - 1];
452
+ const ref = top.parent;
453
+ failures.push({
454
+ sourceFile: entry.pomPath,
455
+ reason:
456
+ ref === null
457
+ ? "declares no groupId and no parent — its identity cannot be established"
458
+ : ref.groupId === null
459
+ ? "declares a <parent> without a groupId — its identity cannot be established"
460
+ : `declares no groupId and its parent ${ref.groupId}:${ref.artifactId ?? "?"} is not a ` +
461
+ `tracked workspace pom (${
462
+ ref.explicitRemote
463
+ ? "its <relativePath/> names no path"
464
+ : `looked for ${
465
+ parentPomPath(top.pomPath, ref.relativePath ?? "../pom.xml") ??
466
+ "(outside the workspace)"
467
+ }`
468
+ }, and no tracked pom declares the identity ${ref.groupId}:${ref.artifactId ?? "?"})`,
469
+ });
470
+ }
471
+
472
+ // Effective properties: root-most merges first, own last, then the
473
+ // maven.config user properties on top. Both conventional locations are
474
+ // workspace-relative paths — the workspace-root config and one beside
475
+ // the declaring pom.
476
+ const merged = /** @type {Record<string, string>} */ ({});
477
+ for (let i = chain.length - 1; i >= 0; i--) Object.assign(merged, chain[i].ownProperties);
478
+ const pomDir = entry.pomPath.includes("/")
479
+ ? entry.pomPath.slice(0, entry.pomPath.lastIndexOf("/"))
480
+ : "";
481
+ Object.assign(
482
+ merged,
483
+ mavenConfigProperties(readFile, [
484
+ ".mvn/maven.config",
485
+ ...(pomDir === "" ? [] : [`${pomDir}/.mvn/maven.config`]),
486
+ ]),
487
+ );
488
+ entry.properties = merged;
489
+ }
490
+
491
+ // Pass 3 — identities, duplicates surfaced rather than picked.
492
+ const identityHolders = new Map();
493
+ for (const entry of entries) {
494
+ if (entry.effectiveGroupId === null || entry.artifactId === null) continue;
495
+ const key = `${entry.effectiveGroupId}:${entry.artifactId}`;
496
+ const holders = identityHolders.get(key) ?? [];
497
+ holders.push({ projectName: entry.projectName, pomPath: entry.pomPath });
498
+ identityHolders.set(key, holders);
499
+ }
500
+ for (const [key, holders] of identityHolders) {
501
+ if (holders.length > 1) {
502
+ failures.push({
503
+ sourceFile: holders[0].pomPath,
504
+ reason:
505
+ `${holders.map((holder) => holder.pomPath).join(", ")} all declare the ` +
506
+ `Maven identity ${key} — an edge toward either would be a guess`,
507
+ });
508
+ }
509
+ }
510
+
511
+ // Pass 3b — reactor drift: a <module> whose pom.xml the tree does not
512
+ // track means the reactor model is incomplete — the subtree's identity
513
+ // cannot be established and no rule can judge it. Recorded as a failure
514
+ // naming the AGGREGATOR's pom (the go.work precedent: incompleteness here
515
+ // is could-not-judge, not judged-and-violating), so `check` exits 3
516
+ // instead of reporting clean over a hole.
517
+ for (const entry of entries) {
518
+ for (const moduleName of entry.declaredModules) {
519
+ const dir = entry.pomPath.includes("/")
520
+ ? entry.pomPath.slice(0, entry.pomPath.lastIndexOf("/"))
521
+ : "";
522
+ const childPom = resolveWithinWorkspace(dir, `${moduleName}/pom.xml`);
523
+ if (childPom !== null && tracked.has(childPom)) continue;
524
+ failures.push({
525
+ sourceFile: entry.pomPath,
526
+ reason:
527
+ `declares module '${moduleName}' but ${childPom ?? "its pom.xml"} is not a ` +
528
+ `tracked file — that subtree's projects cannot be discovered`,
529
+ });
530
+ }
531
+ }
532
+
533
+ // Pass 4 — dependency coordinates interpolated per declaring entry.
534
+ for (const entry of entries) {
535
+ const builtins = {
536
+ "project.groupId": entry.effectiveGroupId ?? "",
537
+ "pom.groupId": entry.effectiveGroupId ?? "",
538
+ "project.artifactId": entry.artifactId ?? "",
539
+ "pom.artifactId": entry.artifactId ?? "",
540
+ };
541
+ for (const dep of entry.declaredDependencies) {
542
+ const groupId = interpolateCoordinate(dep.groupIdRaw, entry.properties, builtins);
543
+ const artifactId = interpolateCoordinate(dep.artifactIdRaw, entry.properties, builtins);
544
+ if (!groupId.resolved || !artifactId.resolved) {
545
+ failures.push({
546
+ sourceFile: entry.pomPath,
547
+ reason:
548
+ `declares a dependency whose coordinates do not statically resolve ` +
549
+ `(${dep.groupIdRaw}:${dep.artifactIdRaw})`,
550
+ });
551
+ continue;
552
+ }
553
+ entry.resolvedDependencies.push({ groupId: groupId.value, artifactId: artifactId.value });
554
+ }
555
+ }
556
+
557
+ return { entries, identityHolders, failures };
558
+ }
559
+
560
+ export const mavenModelOf = perWorkspace(buildMavenModel);
561
+
562
+ /**
563
+ * Manifest-edge resolver: one edge per declared dependency whose coordinates
564
+ * equal another project's SOLE identity. A reactor whose model records any
565
+ * could-not-complete failure refuses the whole graph (#364's posture) —
566
+ * silently omitting the affected edges is the under-selecting `nx affected`
567
+ * this plugin exists to close — so the loop's skips below are the second
568
+ * line of defense, not the refusal itself.
569
+ *
570
+ * Takes ONE workspace-shaped object (`{ projects, filesOf, readFile }`) rather
571
+ * than the positional triple, because the model is memoized on that object:
572
+ * the caller's one object — shared with the source-track resolvers and with
573
+ * `mavenManifestFailures` — is what keeps the whole model at one parse per
574
+ * run (#363).
575
+ *
576
+ * @param {object} workspace `{ projects, filesOf(name), readFile(path), root? }`
577
+ * @returns {{ source: string, target: string, sourceFile: string, type: string }[]}
578
+ * @throws {Error} when `mavenModelOf` recorded any failure, naming each pom.
579
+ */
580
+ export function resolveMavenDependencies(workspace) {
581
+ const model = mavenModelOf(workspace);
582
+ refuseUnreadTree("the Maven model", model.failures);
583
+ const dependencies = [];
584
+ for (const entry of model.entries) {
585
+ for (const dep of entry.resolvedDependencies) {
586
+ const holders = model.identityHolders.get(`${dep.groupId}:${dep.artifactId}`);
587
+ if (!holders || holders.length !== 1) continue; // unreachable while the refusal above holds; kept as the belt beneath it
588
+ if (holders[0].projectName === entry.projectName) continue;
589
+ dependencies.push({
590
+ source: entry.projectName,
591
+ target: holders[0].projectName,
592
+ sourceFile: entry.pomPath,
593
+ type: "static",
594
+ });
595
+ }
596
+ }
597
+ return dependencies;
598
+ }
599
+
600
+ /**
601
+ * Whole-file failures for every pom this reader could not fully judge — the
602
+ * funnel `src/commands/context.mjs` spreads beside the analyzers' own
603
+ * failures so a broken reactor exits 3 instead of reporting clean.
604
+ *
605
+ * @param {object} workspace
606
+ * @returns {{ sourceFile: string, line: null, column: null, reason: string }[]}
607
+ */
608
+ export function mavenManifestFailures(workspace) {
609
+ return mavenModelOf(workspace).failures.map(({ sourceFile, reason }) =>
610
+ fileFailure(sourceFile, `its pom.xml cannot be fully read: ${reason}`),
611
+ );
612
+ }