feat(workspace): add Gradle multi-project workspace discovery#494
Conversation
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.
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.
Verification Report — TC-4261Review Feedback — PASS5 comment threads enumerated, all classified:
All code change requests were addressed in follow-up commits. No sub-tasks created. Scope Containment — PASS19 changed files, all within expected scope:
Note: Task originally specified Commit Traceability — PASS3 commits, all with clear feature/refactor context:
Sensitive Patterns — PASSNo secrets, credentials, or API keys detected. CI Status — PASSAll CI checks pass (lint, tests on Node 22 and 24, PR title, commit messages). Acceptance Criteria — PASS (7/7)
Test Quality — PASS7 test cases covering:
Verification Command — PASS
Overall: PASS This comment was AI-generated by sdlc-workflow/verify-pr v0.5.11. |
…e.js Address review feedback: provider-specific discovery functions don't belong in workspace.js. Extract traverseForWrapper to tools.js as shared utility, update base_java.js to delegate to it, and move all Gradle workspace discovery functions (discoverGradleSubprojects, resolveGradleBinary, parseGradleInitScriptOutput, GRADLE_INIT_SCRIPT) to their provider file. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move normalizePath from Base_java class and resolveGradleBinary from java_gradle.js into tools.js as shared standalone functions. Update traverseForWrapper to normalize paths consistently on Windows. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace resolveBinary with selectToolBinary (via Base_java) in discoverGradleSubprojects, matching the Maven discovery pattern and gaining normalizePath via traverseForWrapper. Remove esmock invokeCommand mocks from happy-path Gradle workspace tests — they now invoke a real gradle binary. Keep esmock only for the error path (gradle not available). Fix settings.gradle.kts fixture to use valid Kotlin DSL syntax. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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: