Skip to content

feat(workspace): add Gradle multi-project workspace discovery#494

Open
Strum355 wants to merge 2 commits intoguacsec:mainfrom
Strum355:TC-4261
Open

feat(workspace): add Gradle multi-project workspace discovery#494
Strum355 wants to merge 2 commits intoguacsec:mainfrom
Strum355:TC-4261

Conversation

@Strum355
Copy link
Copy Markdown
Member

@Strum355 Strum355 commented Apr 28, 2026

Summary

  • Discover subprojects in Gradle multi-project builds using a custom init script (daListProjects) that emits structured project listings
  • Support Groovy (build.gradle) and Kotlin DSL (build.gradle.kts) variants with automatic detection
  • Integrate gradlew wrapper detection via upward directory traversal, controlled by TRUSTIFY_DA_PREFER_GRADLEW
  • Apply workspaceDiscoveryIgnore filtering to discovered subproject paths
  • Add Gradle detection in detectWorkspaceManifests() between Maven and JavaScript

Test plan

  • 7 new tests covering: no settings.gradle, multi-project, nested subprojects, mixed Groovy/Kotlin, single project, gradle failure fallback, ignore pattern filtering
  • All 25 workspace tests pass
  • ESLint clean

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:

  • Discover Maven multi-module projects and collect all pom.xml manifests starting from a root pom.xml.
  • Discover Gradle multi-project builds and collect all build.gradle and build.gradle.kts manifests using a custom init script.
  • Expose Maven and Gradle workspace discovery functions from the public API.

Enhancements:

  • Prefer Maven and Gradle wrapper binaries via configurable wrapper discovery when resolving build tools.
  • Extend default workspace discovery ignore patterns to skip common Java build output and Gradle metadata directories.
  • Update workspace manifest detection to recognize Maven and Gradle projects alongside existing Cargo and JavaScript ecosystems.

Tests:

  • Add unit tests and fixture projects covering Maven module discovery, nested aggregators, failure handling, and ignore-pattern filtering.
  • Add unit tests and fixture projects covering Gradle multi-project discovery, nested subprojects, mixed Groovy/Kotlin builds, failure handling, and ignore-pattern filtering.

Strum355 and others added 2 commits April 28, 2026 11:40
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>
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Apr 28, 2026

Reviewer's Guide

Adds 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 discovery

sequenceDiagram
    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)
Loading

Sequence diagram for Maven multi-module workspace discovery

sequenceDiagram
    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
Loading

Flow diagram for updated workspace manifest detection with Maven and Gradle

flowchart 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: [] }"]
Loading

File-Level Changes

Change Details Files
Introduce Maven multi-module discovery with wrapper-aware mvn resolution and ignore filtering.
  • Add resolveMavenBinary() using getWrapperPreference(), traverseForWrapper(), and getCustomPath() to prefer mvnw when configured
  • Implement traverseForWrapper() to walk from a start directory up to the git root or filesystem root looking for an executable wrapper script
  • Implement discoverMavenModules() that resolves the root pom.xml, invokes Maven to read project.modules, recurses into nested aggregators, and filters results via workspaceDiscoveryIgnore
  • Use listMavenModules() and parseMavenModuleList() to shell out to mvn help:evaluate and parse the [module-a, module-b] style output, handling failures by returning only the root pom.xml
src/workspace.js
Introduce Gradle multi-project discovery using a temporary init script and wrapper-aware gradle resolution, including Groovy/Kotlin DSL support and ignore filtering.
  • Define a GRADLE_INIT_SCRIPT that adds a daListProjects task to allprojects and prints ::DA_PROJECT:::: lines
  • Add resolveGradleBinary() mirroring Maven wrapper logic but for gradlew/gradle
  • Implement discoverGradleSubprojects() to detect settings.gradle[.kts], collect root build.gradle[.kts], run Gradle with the init script to list projects, map each project dir to build.gradle[.kts] if present (else build.gradle), and filter via workspaceDiscoveryIgnore
  • Implement parseGradleInitScriptOutput() to parse ::DA_PROJECT:: lines from Gradle stdout into { path, dir } objects and ignore non-matching lines
src/workspace.js
Extend workspace detection to recognize Maven and Gradle ecosystems and export the new discovery functions.
  • Export discoverGradleSubprojects and discoverMavenModules from the public index
  • Update detectWorkspaceManifests() to check for pom.xml and settings.gradle[.kts] after Cargo but before JavaScript, returning ecosystem identifiers 'maven' and 'gradle' with discovered manifest paths when applicable
src/index.js
Expand default workspace discovery ignore patterns to cover common Java build outputs.
  • Add /target/, /build/, and /.gradle/ to DEFAULT_WORKSPACE_DISCOVERY_IGNORE to avoid scanning typical Maven/Gradle build artefact directories
src/workspace.js
Add comprehensive unit tests and test fixtures for Maven and Gradle workspace discovery, including edge cases and failure modes.
  • Add tests for discoverMavenModules() covering absence of pom.xml, no modules, simple multi-module projects, nested aggregator modules, mvn failures, and workspaceDiscoveryIgnore filtering using esmock to stub tools.js functions
  • Add tests for discoverGradleSubprojects() covering missing settings, multi-project builds, nested subprojects, mixed Groovy/Kotlin build files, single-project builds, Gradle failures, and workspaceDiscoveryIgnore filtering, again via esmock stubs
  • Introduce Maven test fixtures for single-module, multi-module, and nested-aggregator layouts under test/providers/tst_manifests/maven
  • Introduce Gradle test fixtures for multi-project, nested-subproject, mixed-variant, and no-subproject layouts under test/providers/tst_manifests/gradle
test/providers/workspace.test.js
test/providers/tst_manifests/maven/maven_multi_module/pom.xml
test/providers/tst_manifests/maven/maven_multi_module/module-a/pom.xml
test/providers/tst_manifests/maven/maven_multi_module/module-b/pom.xml
test/providers/tst_manifests/maven/maven_nested_aggregator/pom.xml
test/providers/tst_manifests/maven/maven_nested_aggregator/parent/pom.xml
test/providers/tst_manifests/maven/maven_nested_aggregator/parent/child/pom.xml
test/providers/tst_manifests/maven/maven_no_modules/pom.xml
test/providers/tst_manifests/gradle/gradle_multi_project/build.gradle
test/providers/tst_manifests/gradle/gradle_multi_project/app/build.gradle
test/providers/tst_manifests/gradle/gradle_multi_project/lib/build.gradle
test/providers/tst_manifests/gradle/gradle_multi_project/settings.gradle
test/providers/tst_manifests/gradle/gradle_nested_subprojects/build.gradle
test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/core/build.gradle
test/providers/tst_manifests/gradle/gradle_nested_subprojects/libs/util/build.gradle
test/providers/tst_manifests/gradle/gradle_nested_subprojects/settings.gradle
test/providers/tst_manifests/gradle/gradle_mixed_variants/build.gradle.kts
test/providers/tst_manifests/gradle/gradle_mixed_variants/app/build.gradle
test/providers/tst_manifests/gradle/gradle_mixed_variants/lib/build.gradle.kts
test/providers/tst_manifests/gradle/gradle_mixed_variants/settings.gradle.kts
test/providers/tst_manifests/gradle/gradle_no_subprojects/build.gradle
test/providers/tst_manifests/gradle/gradle_no_subprojects/settings.gradle

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • 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.
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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/workspace.js
manifestPaths.push(rootBuild)
}

const initScriptPath = path.join(os.tmpdir(), `da-list-projects-${process.pid}.gradle`)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 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.

Comment thread src/workspace.js
Comment on lines +484 to +488
for (const line of raw.split('\n')) {
if (!line.startsWith('::DA_PROJECT::')) {
continue
}
const parts = line.split('::').filter(Boolean)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

@ruromero ruromero left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/workspace.js
* @param {string} [repoRoot] - Stop boundary (defaults to git root or filesystem root)
* @returns {string | undefined}
*/
function traverseForWrapper(startDir, wrapperName, repoRoot = undefined) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is exactly the same as the one in base_java.js except for the startDir

Comment thread src/workspace.js
* @param {import('./index.js').Options} [opts={}]
* @returns {string} Path to the Maven binary
*/
function resolveMavenBinary(startDir, opts = {}) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/workspace.js
* @param {import('./index.js').Options} [opts={}]
* @returns {Promise<string[]>} Paths to pom.xml files (absolute)
*/
export async function discoverMavenModules(workspaceRoot, opts = {}) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants