@ecoma-io/archkeep 0.15.0 → 0.16.1
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 +3 -3
- package/cli.mjs +126 -4
- package/commands.mjs +6 -0
- package/lsp.mjs +15 -2
- package/package.json +6 -2
- package/src/analysis/analyze.mjs +15 -0
- package/src/analysis/contract.md +36 -18
- package/src/analysis/csharp.mjs +514 -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 +303 -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 +9 -2
- package/src/commands/context.mjs +84 -14
- package/src/commands/provenance.mjs +7 -44
- package/src/commands/rules.mjs +775 -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/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/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/workspace.mjs +115 -23
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gradle manifest reader and edge resolver — the identity-anchor half of JVM
|
|
3
|
+
* support wherever a root Gradle project stands. Static only: no `gradle`,
|
|
4
|
+
* no JVM, no network (`docs/adr/0005-jvm-language-integration.md` Decision 5
|
|
5
|
+
* owns why graphs compute on machines with no toolchain).
|
|
6
|
+
*
|
|
7
|
+
* ## What v1 reads, and the simplification that makes it safe
|
|
8
|
+
*
|
|
9
|
+
* Boundary edges need project matching ONLY. Gradle's version catalogs,
|
|
10
|
+
* dynamic versions, dependency constraints, and configurations decide which
|
|
11
|
+
* artifact downloads, and nothing archkeep evaluates consumes that answer, so
|
|
12
|
+
* no code below ever reads a `version:` element or a catalog's versions block.
|
|
13
|
+
* The version-resolution apparatus is out of scope by construction rather
|
|
14
|
+
* than by omission.
|
|
15
|
+
*
|
|
16
|
+
* Read per REACTOR (ADR 0005 Decision 2's directory mapping): a
|
|
17
|
+
* `settings.gradle`/`settings.gradle.kts` probed at the workspace root or a
|
|
18
|
+
* declared project's root — the two locations a reactor root takes in every
|
|
19
|
+
* layout v1 models, the `.mvn/maven.config` precedent — defines one reactor.
|
|
20
|
+
* Probing rather than walking tracked files is what makes the common layout
|
|
21
|
+
* visible at all: a settings file at a workspace root that no declared
|
|
22
|
+
* project owns appears in no project's `filesOf`, and a reader that found
|
|
23
|
+
* manifests only there drew no edge for the whole reactor — green, and
|
|
24
|
+
* hollow. `include("a:b")` maps the project path `:a:b` onto directory
|
|
25
|
+
* `settingsDir/a/b`, and every declared project whose root a settings file
|
|
26
|
+
* covers gets its own build file read. A settings file anywhere else, and a
|
|
27
|
+
* root project's own build file when the workspace root is no declared
|
|
28
|
+
* project, are unread — documented limits, not omissions. A nested second
|
|
29
|
+
* build file inside one project directory draws no graph edge — the
|
|
30
|
+
* documented modeling limit, with analysis attributing the file instead:
|
|
31
|
+
*
|
|
32
|
+
* - **Identity** from `settings.gradle` / `settings.gradle.kts`: root project
|
|
33
|
+
* name and `include(...)` declarations → the project-directory mapping
|
|
34
|
+
* (ADR 0005 Decision 2). Handles the parenthesized call and Groovy's
|
|
35
|
+
* parenless `include "a", "b"` spelling, single- and multi-arg and
|
|
36
|
+
* multi-line includes, and ignores `includeBuild` in v1 (composite builds
|
|
37
|
+
* are a discovery-only signal for now; edges from included builds are not
|
|
38
|
+
* modeled).
|
|
39
|
+
* - **Edges** from `build.gradle` / `build.gradle.kts` dependency declarations:
|
|
40
|
+
* `project(":x")` references in ANY configuration (`implementation`, `api`,
|
|
41
|
+
* `testImplementation`, `compileOnly`, `runtimeOnly`, `annotationProcessor`,
|
|
42
|
+
* custom configurations…). Both Groovy DSL (quotes optional, parentheses
|
|
43
|
+
* optional) and Kotlin DSL (strings required). Every scope, including test,
|
|
44
|
+
* draws the same edge — project granularity is the unit of law, matching the
|
|
45
|
+
* Maven reader's rule.
|
|
46
|
+
* - **Version catalogs**: `libs.catalog.reference` forms are NOT read in v1.
|
|
47
|
+
* A catalog entry that cannot be resolved to a project in the same workspace
|
|
48
|
+
* stays silent (it may be an external Maven coordinate, a version reference,
|
|
49
|
+
* or a bundle — none of which are modeled here). Catalogs are a Gradle
|
|
50
|
+
* ecosystem feature that exists alongside Maven coordinates; their absence
|
|
51
|
+
* from this reader is a documented limit, not a bug.
|
|
52
|
+
* - **External dependencies**: `implementation "group:artifact:version"` forms
|
|
53
|
+
* are NOT read. They resolve to artifacts from Maven Central or other
|
|
54
|
+
* repositories, not to workspace projects, so they belong to the external
|
|
55
|
+
* node synthesis layer when a rule needs a name.
|
|
56
|
+
*
|
|
57
|
+
* ## Malformed input degrades loudly
|
|
58
|
+
*
|
|
59
|
+
* A malformed settings file, a malformed build file, an `include` that maps
|
|
60
|
+
* onto an untracked directory, a `project(...)` reference no settings file
|
|
61
|
+
* defines, a reference whose directory no declared project owns, a
|
|
62
|
+
* settings-less build file that declares project references, and two
|
|
63
|
+
* settings files claiming the same directory all surface through
|
|
64
|
+
* `gradleManifestFailures` as whole-file failures naming the manifest — the
|
|
65
|
+
* go.work precedent. A
|
|
66
|
+
* broken reactor read as "no dependencies" would mean "no drift" exactly where
|
|
67
|
+
* the tree is most broken, so the CLI funnels these into the could-not-complete
|
|
68
|
+
* class (exit 3) beside Python's unmodelled manifests — and the graph resolver
|
|
69
|
+
* below THROWS on the same list (#364's posture, `../source-util.mjs`'s
|
|
70
|
+
* `refuseUnreadTree`), so `nx affected` fails loudly instead of
|
|
71
|
+
* under-selecting on it.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
import { normalizePath } from "../manifest-util.mjs";
|
|
75
|
+
import { fileFailure, perWorkspace, refuseUnreadTree } from "../source-util.mjs";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse one Gradle settings file's text to extract project information.
|
|
79
|
+
* Handles both Groovy DSL (`settings.gradle`) and Kotlin DSL (`settings.gradle.kts`).
|
|
80
|
+
*
|
|
81
|
+
* Extracts:
|
|
82
|
+
* - Root project name from `rootProject.name = ...` or `rootProject{name = ...}`
|
|
83
|
+
* - Included projects from `include("a", "b")` and from Groovy's parenless
|
|
84
|
+
* spelling `include ":a", ":b"` — both read as a comma-separated list of
|
|
85
|
+
* quoted project paths
|
|
86
|
+
* - Ignores `includeBuild` statements in v1 (composite builds not modeled for edges)
|
|
87
|
+
*
|
|
88
|
+
* @param {string} text Raw file contents.
|
|
89
|
+
* @returns {{ rootProjectName: string|null, includedProjects: string[], reason?: undefined } |
|
|
90
|
+
* { rootProjectName?: undefined, includedProjects?: undefined, reason: string }}
|
|
91
|
+
*/
|
|
92
|
+
export function parseGradleSettings(text) {
|
|
93
|
+
// First, remove block comments to avoid matching include statements inside them
|
|
94
|
+
let processedText = text.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
95
|
+
|
|
96
|
+
// Then preprocess to handle multi-line includes by collapsing them, so the
|
|
97
|
+
// per-line reader below sees each call whole. Both call spellings wrap: the
|
|
98
|
+
// parenthesized call spans to its closing parenthesis, and the parenless
|
|
99
|
+
// Groovy list spans its trailing commas. Reading only the first line of a
|
|
100
|
+
// wrapped call would take one project of the list and stay silent about the
|
|
101
|
+
// rest. Each pattern requires the newline it exists to collapse, so a
|
|
102
|
+
// single-line call is matched by neither.
|
|
103
|
+
processedText = processedText.replace(/include\s*\([^)]*\)/g, (match) => {
|
|
104
|
+
return match.replace(/\r?\n/g, " ");
|
|
105
|
+
});
|
|
106
|
+
processedText = processedText.replace(
|
|
107
|
+
/include\s+(?:['"][^'"]*['"]\s*,?\s*\r?\n\s*)+['"][^'"]*['"]/g,
|
|
108
|
+
(match) => {
|
|
109
|
+
return match.replace(/\r?\n/g, " ");
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const lines = processedText.split(/\r?\n/);
|
|
114
|
+
let rootProjectName = null;
|
|
115
|
+
const includedProjects = [];
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i < lines.length; i++) {
|
|
118
|
+
const line = lines[i];
|
|
119
|
+
const trimmed = line.trim();
|
|
120
|
+
|
|
121
|
+
// Skip empty lines and line comments
|
|
122
|
+
if (trimmed === "" || trimmed.startsWith("//")) continue;
|
|
123
|
+
|
|
124
|
+
// Parse rootProject.name = "name"
|
|
125
|
+
if (trimmed.includes("rootProject.name")) {
|
|
126
|
+
const nameMatch = /rootProject\.name\s*=\s*['"]([^'"]+)['"]/.exec(trimmed);
|
|
127
|
+
if (nameMatch) {
|
|
128
|
+
rootProjectName = nameMatch[1];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Parse rootProject { name = "xyz" }
|
|
133
|
+
if (trimmed.includes("rootProject")) {
|
|
134
|
+
const blockMatch = /rootProject\s*\{[^}]*name\s*=\s*['"]([^'"]+)['"]/.exec(trimmed);
|
|
135
|
+
if (blockMatch) {
|
|
136
|
+
rootProjectName = blockMatch[1];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Parse include("a", "b") and the Groovy parenless spelling
|
|
141
|
+
// include ":a", ":b". Both are a comma-separated list of quoted project
|
|
142
|
+
// paths, so the argument text is taken whichever way the call is written
|
|
143
|
+
// and the quoted strings are lifted out of it. The parenless branch ends
|
|
144
|
+
// at its last well-formed argument rather than at end of line, so a `//`
|
|
145
|
+
// comment trailing the call is never read as an argument; and it reaches
|
|
146
|
+
// neither `includeBuild` spelling, because both branches demand
|
|
147
|
+
// whitespace or `(` directly after `include`.
|
|
148
|
+
const parenthesizedInclude = /include\s*\(([^)]*)\)/.exec(trimmed);
|
|
149
|
+
const parenlessInclude =
|
|
150
|
+
parenthesizedInclude === null
|
|
151
|
+
? /\binclude\s+(['"][^'"]*['"](?:\s*,\s*['"][^'"]*['"])*)/.exec(trimmed)
|
|
152
|
+
: null;
|
|
153
|
+
const includeArgs = parenthesizedInclude?.[1] ?? parenlessInclude?.[1] ?? null;
|
|
154
|
+
if (includeArgs !== null) {
|
|
155
|
+
// Split by commas, handling quoted strings
|
|
156
|
+
const argMatches = includeArgs.match(/(['"])([^'"]+)\1/g) || [];
|
|
157
|
+
for (const arg of argMatches) {
|
|
158
|
+
const projectName = arg.slice(1, -1); // Remove quotes
|
|
159
|
+
// Remove leading ":" if present
|
|
160
|
+
const cleanName = projectName.startsWith(":") ? projectName.slice(1) : projectName;
|
|
161
|
+
if (cleanName && !includedProjects.includes(cleanName)) {
|
|
162
|
+
includedProjects.push(cleanName);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!rootProjectName && includedProjects.length === 0) {
|
|
169
|
+
return { reason: "no rootProject.name or include declarations found" };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { rootProjectName, includedProjects };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Parse one Gradle build file's text to extract project dependencies.
|
|
177
|
+
* Handles both Groovy DSL (`build.gradle`) and Kotlin DSL (`build.gradle.kts`).
|
|
178
|
+
*
|
|
179
|
+
* Extracts `project(":x")` references from ANY configuration:
|
|
180
|
+
* - implementation, api, testImplementation, compileOnly, runtimeOnly, etc.
|
|
181
|
+
* - Custom configurations
|
|
182
|
+
* - Both argument spellings: the positional path (`project(":x")`) and
|
|
183
|
+
* Groovy's named-argument map (`project(path: ':x', configuration: 'default')`)
|
|
184
|
+
*
|
|
185
|
+
* @param {string} text Raw file contents.
|
|
186
|
+
* @returns {{ projectDependencies: string[], reason?: undefined } |
|
|
187
|
+
* { projectDependencies?: undefined, reason: string }}
|
|
188
|
+
*/
|
|
189
|
+
export function parseGradleBuild(text) {
|
|
190
|
+
const lines = text.split(/\r?\n/);
|
|
191
|
+
const projectDependencies = [];
|
|
192
|
+
let inString = false;
|
|
193
|
+
let stringChar = null;
|
|
194
|
+
|
|
195
|
+
for (let i = 0; i < lines.length; i++) {
|
|
196
|
+
const line = lines[i];
|
|
197
|
+
const trimmed = line.trim();
|
|
198
|
+
|
|
199
|
+
// Skip empty lines and line comments
|
|
200
|
+
if (trimmed === "" || trimmed.startsWith("//")) continue;
|
|
201
|
+
|
|
202
|
+
let j = 0;
|
|
203
|
+
let blockComment = false;
|
|
204
|
+
|
|
205
|
+
while (j < trimmed.length) {
|
|
206
|
+
const char = trimmed[j];
|
|
207
|
+
const nextChar = trimmed[j + 1];
|
|
208
|
+
|
|
209
|
+
// Handle string literals
|
|
210
|
+
if (!blockComment) {
|
|
211
|
+
if (!inString && (char === '"' || char === "'")) {
|
|
212
|
+
inString = true;
|
|
213
|
+
stringChar = char;
|
|
214
|
+
j++;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (inString && char === stringChar) {
|
|
218
|
+
// Check for escape
|
|
219
|
+
if (trimmed[j - 1] !== "\\") {
|
|
220
|
+
inString = false;
|
|
221
|
+
stringChar = null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (inString) {
|
|
225
|
+
j++;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Handle comments
|
|
231
|
+
if (!inString) {
|
|
232
|
+
if (char === "/" && nextChar === "*") {
|
|
233
|
+
blockComment = true;
|
|
234
|
+
j += 2;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (char === "*" && nextChar === "/") {
|
|
238
|
+
blockComment = false;
|
|
239
|
+
j += 2;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (blockComment) {
|
|
243
|
+
j++;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (char === "/" && nextChar === "/") {
|
|
247
|
+
break; // rest of line is comment
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Match project(":name") in these forms:
|
|
252
|
+
// - project(":name") / project(':name') - the positional quoted path
|
|
253
|
+
// - project(":") - the root project, an empty path
|
|
254
|
+
// - project(:name) - Groovy DSL without quotes, one path segment
|
|
255
|
+
// - project(path: ':name') and project(path: ':name', configuration:
|
|
256
|
+
// 'default') - Groovy's named-argument map, where further named
|
|
257
|
+
// arguments may follow the path
|
|
258
|
+
// In Kotlin DSL: project(":name") (always quoted)
|
|
259
|
+
// In Groovy DSL: quotes optional on the positional form
|
|
260
|
+
|
|
261
|
+
// The argument list is captured up to the closing parenthesis and the
|
|
262
|
+
// path read out of it, because the path string's closing quote is not
|
|
263
|
+
// where the call ends: in the named-argument spelling `configuration:`
|
|
264
|
+
// (or any further named argument) follows it, and a matcher demanding
|
|
265
|
+
// `)` right at that fixed offset dropped the record while the project
|
|
266
|
+
// existed.
|
|
267
|
+
const projectCall = /project\s*\(([^)]*)\)/.exec(trimmed);
|
|
268
|
+
if (projectCall) {
|
|
269
|
+
const args = projectCall[1];
|
|
270
|
+
// Positional quoted — `[^'"]*` rather than `+` so the root project's
|
|
271
|
+
// own spelling, `":"`, is read too. An empty dep path resolves
|
|
272
|
+
// through the reactor map's root claim (`""`), the same key every
|
|
273
|
+
// settings file registers; missing it would drop the edge silently,
|
|
274
|
+
// which is the one failure this reader must not make.
|
|
275
|
+
const positionalQuoted = /^\s*['"]:([^'"]*)['"]\s*$/.exec(args);
|
|
276
|
+
// Positional unquoted Groovy — one path segment, never empty.
|
|
277
|
+
const positionalUnquoted = /^\s*:([a-zA-Z0-9_-]+)\s*$/.exec(args);
|
|
278
|
+
// The `path:` named argument — the comma-or-start guard keeps a
|
|
279
|
+
// suffix like `somepath:` from reading as `path:`. The value is
|
|
280
|
+
// quoted: an unquoted value would be a Groovy variable reference,
|
|
281
|
+
// not a path this reader can resolve.
|
|
282
|
+
const namedArgument = /(?:^|,)\s*path\s*:\s*['"]:([^'"]*)['"]/.exec(args);
|
|
283
|
+
const pathMatch = positionalQuoted ?? positionalUnquoted ?? namedArgument;
|
|
284
|
+
if (pathMatch) {
|
|
285
|
+
const depName = pathMatch[1];
|
|
286
|
+
if (!projectDependencies.includes(depName)) {
|
|
287
|
+
projectDependencies.push(depName);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
j++;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { projectDependencies };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* One declared project's Gradle facts: what its build file declares.
|
|
301
|
+
*
|
|
302
|
+
* @typedef {object} GradleEntry
|
|
303
|
+
* @property {string} projectName The declared project's name.
|
|
304
|
+
* @property {string} buildPath Workspace-relative path to its build file.
|
|
305
|
+
* @property {string[]} declaredDependencies Project paths referenced by
|
|
306
|
+
* `project(":x")`, as written (`":"`-less, `":"`-separated).
|
|
307
|
+
*/
|
|
308
|
+
|
|
309
|
+
/** The settings basenames a reactor is recognised by. */
|
|
310
|
+
const SETTINGS_FILENAMES = ["settings.gradle.kts", "settings.gradle"];
|
|
311
|
+
|
|
312
|
+
/** The build basenames a project's dependency declarations live in. */
|
|
313
|
+
const BUILD_FILENAMES = ["build.gradle.kts", "build.gradle"];
|
|
314
|
+
|
|
315
|
+
/** The directory a workspace-relative path lives in (`""` for the root). */
|
|
316
|
+
const dirnameOf = (file) => (file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : "");
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Build the workspace-wide Gradle model once per workspace object.
|
|
320
|
+
*
|
|
321
|
+
* A reactor is defined by its SETTINGS file, probed at the workspace root or
|
|
322
|
+
* a declared project's root — a settings file at a root no declared project
|
|
323
|
+
* owns appears in no `filesOf`, so discovery must probe rather than walk.
|
|
324
|
+
* Every DECLARED project whose root a settings file covers gets its build
|
|
325
|
+
* file read for `project(":x")` references, resolved through the settings'
|
|
326
|
+
* path→directory map.
|
|
327
|
+
*
|
|
328
|
+
* @param {object} workspace `{ projects, filesOf(name), readFile(path), root? }`
|
|
329
|
+
* @returns {{
|
|
330
|
+
* entries: GradleEntry[],
|
|
331
|
+
* pathToDirectory: Map<string, string>, // project path -> directory
|
|
332
|
+
* failures: { sourceFile: string, reason: string }[]
|
|
333
|
+
* }}
|
|
334
|
+
*/
|
|
335
|
+
function buildGradleModel(workspace) {
|
|
336
|
+
const readFile = workspace.readFile;
|
|
337
|
+
const failures = [];
|
|
338
|
+
/** Every tracked file, for validation. */
|
|
339
|
+
const tracked = new Set();
|
|
340
|
+
for (const project of workspace.projects) {
|
|
341
|
+
for (const file of workspace.filesOf(project.name)) tracked.add(file);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Reactor map: project path (as written, ":"-less) -> directory. */
|
|
345
|
+
const pathToDirectory = new Map();
|
|
346
|
+
/** directory -> the settings file that claimed it, for loud duplicates. */
|
|
347
|
+
const directoryOwner = new Map();
|
|
348
|
+
|
|
349
|
+
const claim = (settingsPath, projectPath, directory) => {
|
|
350
|
+
const owner = directoryOwner.get(directory);
|
|
351
|
+
if (owner !== undefined) {
|
|
352
|
+
failures.push({
|
|
353
|
+
sourceFile: settingsPath,
|
|
354
|
+
reason: `maps project ':${projectPath}' onto directory '${directory || "/"}', already claimed by ${owner}`,
|
|
355
|
+
});
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
directoryOwner.set(directory, settingsPath);
|
|
359
|
+
pathToDirectory.set(projectPath, directory);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// Pass 1 — probe for settings files at the workspace root and at each
|
|
363
|
+
// declared project's root. A reactor's settings file sits at the reactor
|
|
364
|
+
// root, and a reactor root is the workspace root or a declared project's
|
|
365
|
+
// directory in every layout v1 models — the same two-location rule the
|
|
366
|
+
// Maven reader applies to `.mvn/maven.config`. Probing `readFile` rather
|
|
367
|
+
// than walking tracked files is what makes the common layout visible at
|
|
368
|
+
// all: a settings file at a workspace root that no declared project owns
|
|
369
|
+
// appears in no project's `filesOf`, and a reader that found manifests
|
|
370
|
+
// only there drew no edge for the whole reactor — green, and hollow. A
|
|
371
|
+
// settings file anywhere else is unread (a documented limit; nothing in
|
|
372
|
+
// the tracked tree points at it).
|
|
373
|
+
const settingsFiles = [];
|
|
374
|
+
const candidateDirs = new Set([
|
|
375
|
+
"",
|
|
376
|
+
...workspace.projects.map((project) => normalizePath(project.root ?? "", "")),
|
|
377
|
+
]);
|
|
378
|
+
for (const dir of candidateDirs) {
|
|
379
|
+
for (const name of SETTINGS_FILENAMES) {
|
|
380
|
+
const path = normalizePath(dir, name);
|
|
381
|
+
if (readFile(path) !== null && readFile(path) !== undefined) settingsFiles.push(path);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const settingsPath of settingsFiles) {
|
|
385
|
+
const settingsDir = dirnameOf(settingsPath);
|
|
386
|
+
const settingsText = readFile(settingsPath);
|
|
387
|
+
const settingsParsed = parseGradleSettings(settingsText ?? "");
|
|
388
|
+
if (settingsParsed.reason !== undefined) {
|
|
389
|
+
failures.push({
|
|
390
|
+
sourceFile: settingsPath,
|
|
391
|
+
reason: `its settings file ${settingsParsed.reason}`,
|
|
392
|
+
});
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
// The reactor's root project: path "" -> the settings file's own directory.
|
|
396
|
+
claim(settingsPath, "", settingsDir);
|
|
397
|
+
for (const includePath of settingsParsed.includedProjects) {
|
|
398
|
+
const directory = normalizePath(settingsDir, includePath.split(":").join("/"));
|
|
399
|
+
const trackedThere = [...tracked].some((file) => file.startsWith(`${directory}/`));
|
|
400
|
+
if (!trackedThere) {
|
|
401
|
+
failures.push({
|
|
402
|
+
sourceFile: settingsPath,
|
|
403
|
+
reason: `includes project ':${includePath}' but directory '${directory}' is not tracked`,
|
|
404
|
+
});
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
claim(settingsPath, includePath, directory);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Declared project by normalized root directory. */
|
|
412
|
+
const projectByDirectory = new Map(
|
|
413
|
+
workspace.projects.map((project) => [normalizePath(project.root ?? "", ""), project]),
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
// Pass 2 — read the build file of every declared project that has one. A
|
|
417
|
+
// project a settings file covers gets its references resolved; a build
|
|
418
|
+
// file no settings file covers is loud ONLY when it declares project
|
|
419
|
+
// references, because those are the bytes a silent skip would lose — a
|
|
420
|
+
// settings-less build file with no `project(":x")` claims nothing.
|
|
421
|
+
const coveredDirectories = new Set(pathToDirectory.values());
|
|
422
|
+
/** @type {GradleEntry[]} */
|
|
423
|
+
const entries = [];
|
|
424
|
+
for (const project of workspace.projects) {
|
|
425
|
+
const projectDir = normalizePath(project.root ?? "", "");
|
|
426
|
+
const buildPath =
|
|
427
|
+
BUILD_FILENAMES.map((name) => normalizePath(projectDir, name)).find((path) =>
|
|
428
|
+
tracked.has(path),
|
|
429
|
+
) ?? null;
|
|
430
|
+
if (buildPath === null) continue; // No build file: a reactor member declaring nothing.
|
|
431
|
+
|
|
432
|
+
const buildText = readFile(buildPath);
|
|
433
|
+
if (buildText === null || buildText === undefined) {
|
|
434
|
+
failures.push({ sourceFile: buildPath, reason: "cannot be read" });
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const buildParsed = parseGradleBuild(buildText);
|
|
438
|
+
if (buildParsed.reason !== undefined) {
|
|
439
|
+
failures.push({ sourceFile: buildPath, reason: `its build file ${buildParsed.reason}` });
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (!coveredDirectories.has(projectDir)) {
|
|
444
|
+
if (buildParsed.projectDependencies.length > 0) {
|
|
445
|
+
failures.push({
|
|
446
|
+
sourceFile: buildPath,
|
|
447
|
+
reason:
|
|
448
|
+
"declares project references but its directory is covered by no Gradle settings file — they cannot be judged",
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
entries.push({
|
|
454
|
+
projectName: project.name,
|
|
455
|
+
buildPath,
|
|
456
|
+
declaredDependencies: buildParsed.projectDependencies,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Pass 3 — every reference must name a reactor path onto a declared project.
|
|
461
|
+
for (const entry of entries) {
|
|
462
|
+
for (const depRef of entry.declaredDependencies) {
|
|
463
|
+
const directory = pathToDirectory.get(depRef);
|
|
464
|
+
if (directory === undefined) {
|
|
465
|
+
failures.push({
|
|
466
|
+
sourceFile: entry.buildPath,
|
|
467
|
+
reason: `declares dependency on project ':${depRef}' which no settings file defines`,
|
|
468
|
+
});
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (!projectByDirectory.has(directory)) {
|
|
472
|
+
failures.push({
|
|
473
|
+
sourceFile: entry.buildPath,
|
|
474
|
+
reason: `declares dependency on project ':${depRef}' whose directory '${directory || "/"}' no declared project owns`,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return { entries, pathToDirectory, failures };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export const gradleModelOf = perWorkspace(buildGradleModel);
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Manifest-edge resolver: one edge per declared project dependency whose
|
|
487
|
+
* reference resolves onto another declared project's directory. A reactor
|
|
488
|
+
* whose model records any could-not-complete failure refuses the whole graph
|
|
489
|
+
* (#364's posture, `../source-util.mjs`'s `refuseUnreadTree`) — silently
|
|
490
|
+
* omitting the affected edges is the under-selecting `nx affected` this
|
|
491
|
+
* plugin exists to close — so the loop's skips below are the second line of
|
|
492
|
+
* defense, not the refusal itself.
|
|
493
|
+
*
|
|
494
|
+
* Takes ONE workspace-shaped object (`{ projects, filesOf, readFile }`) rather
|
|
495
|
+
* than the positional triple, because the model is memoized on that object:
|
|
496
|
+
* the caller's one object — shared with the source-track resolvers and with
|
|
497
|
+
* `gradleManifestFailures` — is what keeps the whole model at one parse per
|
|
498
|
+
* run (#363).
|
|
499
|
+
*
|
|
500
|
+
* @param {object} workspace `{ projects, filesOf(name), readFile(path), root? }`
|
|
501
|
+
* @returns {{ source: string, target: string, sourceFile: string, type: string }[]}
|
|
502
|
+
* @throws {Error} when `gradleModelOf` recorded any failure, naming each
|
|
503
|
+
* settings or build file.
|
|
504
|
+
*/
|
|
505
|
+
export function resolveGradleDependencies(workspace) {
|
|
506
|
+
const model = gradleModelOf(workspace);
|
|
507
|
+
refuseUnreadTree("the Gradle model", model.failures);
|
|
508
|
+
const projectByDirectory = new Map(
|
|
509
|
+
workspace.projects.map((project) => [normalizePath(project.root ?? "", ""), project]),
|
|
510
|
+
);
|
|
511
|
+
const dependencies = [];
|
|
512
|
+
|
|
513
|
+
for (const entry of model.entries) {
|
|
514
|
+
for (const depRef of entry.declaredDependencies) {
|
|
515
|
+
const directory = model.pathToDirectory.get(depRef);
|
|
516
|
+
if (directory === undefined) continue; // unreachable while the refusal above holds; kept as the belt beneath it
|
|
517
|
+
const targetProject = projectByDirectory.get(directory);
|
|
518
|
+
if (targetProject === undefined) continue; // unreachable while the refusal above holds; kept as the belt beneath it
|
|
519
|
+
if (targetProject.name === entry.projectName) continue; // a self-reference claims no boundary
|
|
520
|
+
|
|
521
|
+
dependencies.push({
|
|
522
|
+
source: entry.projectName,
|
|
523
|
+
target: targetProject.name,
|
|
524
|
+
sourceFile: entry.buildPath,
|
|
525
|
+
type: "static",
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return dependencies;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Whole-file failures for every Gradle manifest this reader could not fully
|
|
535
|
+
* judge — the funnel `src/commands/context.mjs` spreads beside the analyzers'
|
|
536
|
+
* own failures so a broken reactor exits 3 instead of reporting clean.
|
|
537
|
+
*
|
|
538
|
+
* @param {object} workspace
|
|
539
|
+
* @returns {{ sourceFile: string, line: null, column: null, reason: string }[]}
|
|
540
|
+
*/
|
|
541
|
+
export function gradleManifestFailures(workspace) {
|
|
542
|
+
return gradleModelOf(workspace).failures.map(({ sourceFile, reason }) =>
|
|
543
|
+
fileFailure(sourceFile, `its Gradle manifest cannot be fully read: ${reason}`),
|
|
544
|
+
);
|
|
545
|
+
}
|