feat(workspace): add Gradle multi-project workspace discovery#494
feat(workspace): add Gradle multi-project workspace discovery#494Strum355 wants to merge 2 commits intoguacsec:mainfrom
Conversation
Add `discoverMavenModules()` to discover all pom.xml manifest paths in Maven multi-module projects. Uses `mvn help:evaluate -Dexpression=project.modules` to list declared modules, with recursive traversal for nested aggregators. Supports Maven wrapper (mvnw) via `TRUSTIFY_DA_PREFER_MVNW` preference, reusing the same traversal pattern as provider-level wrapper detection. Adds Maven detection (`pom.xml` presence) to `detectWorkspaceManifests()` between Cargo and JavaScript in the ecosystem detection order. Also adds `**/target/**` to the default workspace discovery ignore patterns. Implements TC-4259 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Assisted-by: Claude Code
Discover subprojects in Gradle multi-project builds using a custom init script that emits structured project listings. Supports Groovy and Kotlin DSL variants, wrapper detection via gradlew traversal, and workspaceDiscoveryIgnore filtering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reviewer's GuideAdds Maven and Gradle multi-project workspace discovery (including wrapper-aware binary resolution and ignore-pattern filtering), wires them into workspace manifest detection, and extends tests/fixtures to cover various Maven and Gradle layouts and failure modes. Sequence diagram for Gradle multi-project workspace discoverysequenceDiagram
participant Caller as detectWorkspaceManifests
participant Gradle as discoverGradleSubprojects
participant WrapperPref as getWrapperPreference
participant GitRoot as getGitRootDir
participant FS as fs
participant Tools as getCustomPath
participant Cmd as invokeCommand
Caller->>Gradle: discoverGradleSubprojects(workspaceRoot, opts)
Gradle->>FS: existsSync(settings.gradle / settings.gradle.kts)
FS-->>Gradle: hasSettings
Gradle->>Gradle: resolveGradleBinary(root, opts)
Gradle->>WrapperPref: getWrapperPreference(gradle, opts)
WrapperPref-->>Gradle: useWrapper
alt useWrapper
Gradle->>GitRoot: getGitRootDir(root)
Gradle->>Gradle: traverseForWrapper(root, gradlew)
alt wrapperFound
Gradle-->>Gradle: gradleBin = wrapperPath
else noWrapper
Gradle->>Tools: getCustomPath(gradle, opts)
Tools-->>Gradle: globalGradle
Gradle-->>Gradle: gradleBin = globalGradle
end
else noWrapperPreference
Gradle->>Tools: getCustomPath(gradle, opts)
Tools-->>Gradle: globalGradle
Gradle-->>Gradle: gradleBin = globalGradle
end
Gradle->>FS: existsSync(root/build.gradle.kts)
FS-->>Gradle: boolKts
Gradle->>FS: existsSync(root/build.gradle)
FS-->>Gradle: boolGroovy
Gradle-->>Gradle: manifestPaths += root build script if present
Gradle->>FS: writeFileSync(tmpInitScript, GRADLE_INIT_SCRIPT)
Gradle->>Cmd: invokeCommand(gradleBin, ["-q", "--no-daemon", "--init-script", tmpInitScript, "daListProjects"], { cwd: root })
alt gradleSuccess
Cmd-->>Gradle: stdout
Gradle->>Gradle: parseGradleInitScriptOutput(stdout)
Gradle-->>Gradle: projects list
loop for each project != ':'
Gradle->>FS: existsSync(projectDir/build.gradle.kts)
FS-->>Gradle: hasKts
Gradle->>FS: existsSync(projectDir/build.gradle)
FS-->>Gradle: hasGroovy
Gradle-->>Gradle: manifestPaths += project build script if present
end
else gradleFailure
Gradle->>Gradle: resolveWorkspaceDiscoveryIgnore(opts)
Gradle->>Gradle: filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
Gradle-->>Caller: filteredPaths (ecosystem gradle)
Gradle->>FS: unlinkSync(tmpInitScript)
Gradle->>Gradle: return
end
Gradle->>Gradle: resolveWorkspaceDiscoveryIgnore(opts)
Gradle->>Gradle: filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
Gradle-->>Caller: filteredPaths (ecosystem gradle)
Gradle->>FS: unlinkSync(tmpInitScript)
Sequence diagram for Maven multi-module workspace discoverysequenceDiagram
participant Caller as detectWorkspaceManifests
participant Maven as discoverMavenModules
participant WrapperPref as getWrapperPreference
participant GitRoot as getGitRootDir
participant FS as fs
participant Tools as getCustomPath
participant Cmd as invokeCommand
Caller->>Maven: discoverMavenModules(workspaceRoot, opts)
Maven->>FS: existsSync(root/pom.xml)
FS-->>Maven: hasRootPom
alt noRootPom
Maven-->>Caller: []
else hasRootPom
Maven->>Maven: resolveMavenBinary(root, opts)
Maven->>WrapperPref: getWrapperPreference(mvn, opts)
WrapperPref-->>Maven: useWrapper
alt useWrapper
Maven->>GitRoot: getGitRootDir(root)
Maven->>Maven: traverseForWrapper(root, mvnw)
alt wrapperFound
Maven-->>Maven: mvnBin = wrapperPath
else noWrapper
Maven->>Tools: getCustomPath(mvn, opts)
Tools-->>Maven: globalMvn
Maven-->>Maven: mvnBin = globalMvn
end
else noWrapperPreference
Maven->>Tools: getCustomPath(mvn, opts)
Tools-->>Maven: globalMvn
Maven-->>Maven: mvnBin = globalMvn
end
Maven-->>Maven: visited = Set(), manifestPaths = [rootPom]
Maven->>Maven: collectMavenModules(root, mvnBin, visited, manifestPaths)
loop collectMavenModules recursion
Maven->>Cmd: invokeCommand(mvnBin, ["help:evaluate", "-Dexpression=project.modules", "-q", "-DforceStdout", "-f", pomPath, "--batch-mode"], { cwd: dir })
alt mvnSuccess
Cmd-->>Maven: stdout
Maven->>Maven: parseMavenModuleList(stdout)
Maven-->>Maven: modules[]
loop for each module
Maven->>FS: existsSync(moduleDir/pom.xml)
FS-->>Maven: hasPom
alt hasPom
Maven-->>Maven: manifestPaths += modulePom
Maven->>Maven: collectMavenModules(moduleDir, mvnBin, visited, manifestPaths)
end
end
else mvnFailure
Maven-->>Maven: modules = []
end
end
Maven->>Maven: resolveWorkspaceDiscoveryIgnore(opts)
Maven->>Maven: filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns)
Maven-->>Caller: filteredPaths (ecosystem maven)
end
Flow diagram for updated workspace manifest detection with Maven and Gradleflowchart TD
A_start["detectWorkspaceManifests(root, opts)"] --> B_checkCargoToml["fs.existsSync(Cargo.toml) && fs.existsSync(Cargo.lock)"]
B_checkCargoToml -->|true| B_cargoReturn["return { ecosystem: 'cargo', manifestPaths: discoverWorkspaceCrates(root, opts) }"]
B_checkCargoToml -->|false| C_checkPom["fs.existsSync(pom.xml)"]
C_checkPom -->|true| C_discoverMaven["manifestPaths = discoverMavenModules(root, opts)"]
C_discoverMaven --> C_anyMaven["manifestPaths.length > 0"]
C_anyMaven -->|true| C_mavenReturn["return { ecosystem: 'maven', manifestPaths }"]
C_anyMaven -->|false| D_checkGradleSettings
C_checkPom -->|false| D_checkGradleSettings["hasGradleSettings = fs.existsSync(settings.gradle) || fs.existsSync(settings.gradle.kts)"]
D_checkGradleSettings -->|true| D_discoverGradle["manifestPaths = discoverGradleSubprojects(root, opts)"]
D_discoverGradle --> D_anyGradle["manifestPaths.length > 0"]
D_anyGradle -->|true| D_gradleReturn["return { ecosystem: 'gradle', manifestPaths }"]
D_anyGradle -->|false| E_checkJs
D_checkGradleSettings -->|false| E_checkJs["hasJsLock = pnpm-lock.yaml || yarn.lock || package-lock.json"]
E_checkJs -->|true| E_jsReturn["return { ecosystem: 'javascript', manifestPaths: discoverWorkspacePackages(root, opts) }"]
E_checkJs -->|false| F_unknown["return { ecosystem: 'unknown', manifestPaths: [] }"]
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- The
traverseForWrapperhelper only recurses onENOENTand throws for otherfs.accessSyncerrors likeEACCES, which will abort workspace discovery instead of just skipping a non-executable wrapper; consider treating non-ENOENT errors as "no usable wrapper here" and continuing the upward traversal. - The Gradle init script is written to a deterministic path in
os.tmpdir()based only onprocess.pid, which can cause collisions and race conditions if multiple calls run in the same process; usingfs.mkdtempor a random suffix per invocation would make this more robust.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The `traverseForWrapper` helper only recurses on `ENOENT` and throws for other `fs.accessSync` errors like `EACCES`, which will abort workspace discovery instead of just skipping a non-executable wrapper; consider treating non-ENOENT errors as "no usable wrapper here" and continuing the upward traversal.
- The Gradle init script is written to a deterministic path in `os.tmpdir()` based only on `process.pid`, which can cause collisions and race conditions if multiple calls run in the same process; using `fs.mkdtemp` or a random suffix per invocation would make this more robust.
## Individual Comments
### Comment 1
<location path="src/workspace.js" line_range="439" />
<code_context>
+ manifestPaths.push(rootBuild)
+ }
+
+ const initScriptPath = path.join(os.tmpdir(), `da-list-projects-${process.pid}.gradle`)
+ try {
+ fs.writeFileSync(initScriptPath, GRADLE_INIT_SCRIPT)
</code_context>
<issue_to_address>
**🚨 issue (security):** Using a predictable temp init-script path can cause races and potential interference between concurrent runs.
Because the filename is just `tmpdir` + `process.pid`, concurrent `discoverGradleSubprojects` calls in the same process (or another process guessing the path) will share this file, causing races (overwrites, early deletion) and enabling symlink attacks. Please generate the path via `fs.mkdtemp` or add a random component (e.g. `crypto.randomUUID()`) to avoid collisions and make it non-predictable.
</issue_to_address>
### Comment 2
<location path="src/workspace.js" line_range="484-488" />
<code_context>
+ */
+function parseGradleInitScriptOutput(raw) {
+ const projects = []
+ for (const line of raw.split('\n')) {
+ if (!line.startsWith('::DA_PROJECT::')) {
+ continue
+ }
+ const parts = line.split('::').filter(Boolean)
+ if (parts.length >= 3) {
+ projects.push({ path: parts[1], dir: parts[2] })
</code_context>
<issue_to_address>
**issue:** Gradle output parsing may leave Windows-style `\r` in project paths and dirs.
On Windows, Gradle output lines end with `
`, so splitting only on `
` leaves a trailing `
` in each `line`. That `
` will end up in `parts[1]`/`parts[2]`, potentially breaking `path.resolve` and file checks. Consider trimming each line before splitting, or trimming `parts[1]` and `parts[2]` after parsing to make this work reliably on Windows.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| manifestPaths.push(rootBuild) | ||
| } | ||
|
|
||
| const initScriptPath = path.join(os.tmpdir(), `da-list-projects-${process.pid}.gradle`) |
There was a problem hiding this comment.
🚨 issue (security): Using a predictable temp init-script path can cause races and potential interference between concurrent runs.
Because the filename is just tmpdir + process.pid, concurrent discoverGradleSubprojects calls in the same process (or another process guessing the path) will share this file, causing races (overwrites, early deletion) and enabling symlink attacks. Please generate the path via fs.mkdtemp or add a random component (e.g. crypto.randomUUID()) to avoid collisions and make it non-predictable.
| for (const line of raw.split('\n')) { | ||
| if (!line.startsWith('::DA_PROJECT::')) { | ||
| continue | ||
| } | ||
| const parts = line.split('::').filter(Boolean) |
There was a problem hiding this comment.
issue: Gradle output parsing may leave Windows-style \r in project paths and dirs.
On Windows, Gradle output lines end with , so splitting only on leaves a trailing in each line. That will end up in parts[1]/parts[2], potentially breaking path.resolve and file checks. Consider trimming each line before splitting, or trimming parts[1] and parts[2] after parsing to make this work reliably on Windows.
ruromero
left a comment
There was a problem hiding this comment.
Let's be careful with what's added to the workspace.js file and try to be generic by moving all provider specific logic to the right implementation.
| * @param {string} [repoRoot] - Stop boundary (defaults to git root or filesystem root) | ||
| * @returns {string | undefined} | ||
| */ | ||
| function traverseForWrapper(startDir, wrapperName, repoRoot = undefined) { |
There was a problem hiding this comment.
This function is exactly the same as the one in base_java.js except for the startDir
| * @param {import('./index.js').Options} [opts={}] | ||
| * @returns {string} Path to the Maven binary | ||
| */ | ||
| function resolveMavenBinary(startDir, opts = {}) { |
There was a problem hiding this comment.
This function is almost the same as Base_Java.selectToolBinary. You should reuse it or extract it as a reusable function inside the Base_java as it is something that only Mvn and Gradle will benefit from.
| * @param {import('./index.js').Options} [opts={}] | ||
| * @returns {Promise<string[]>} Paths to pom.xml files (absolute) | ||
| */ | ||
| export async function discoverMavenModules(workspaceRoot, opts = {}) { |
There was a problem hiding this comment.
Each provider should own the discovery logic for its ecosystem. The workspace.js should only own generic scaffolding (ignore patterns, and other helpers).
I'm afraid that was introduced with Cargo and discoverWorkspaceCrates method but it turned out more evident here.
Summary
daListProjects) that emits structured project listingsbuild.gradle) and Kotlin DSL (build.gradle.kts) variants with automatic detectiongradlewwrapper detection via upward directory traversal, controlled byTRUSTIFY_DA_PREFER_GRADLEWworkspaceDiscoveryIgnorefiltering to discovered subproject pathsdetectWorkspaceManifests()between Maven and JavaScriptTest plan
Jira: TC-4261
🤖 Generated with Claude Code
Summary by Sourcery
Add Maven and Gradle multi-project workspace discovery and integrate them into automatic workspace manifest detection.
New Features:
Enhancements:
Tests: