@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,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
|
+
}
|