Skip to content

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

Open
Strum355 wants to merge 1 commit intomainfrom
TC-4262
Open

feat(workspace): add Gradle multi-project workspace discovery#444
Strum355 wants to merge 1 commit intomainfrom
TC-4262

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
  • Add gradlew wrapper detection via traverseForMvnw() reuse, controlled by TRUSTIFY_DA_PREFER_GRADLEW
  • Apply workspaceDiscoveryIgnore filtering to discovered subproject paths
  • Add **/build/** and **/.gradle/** to default ignore patterns
  • Add Gradle detection in discoverWorkspaceManifests() between Cargo and JavaScript

Test plan

  • 5 parse tests: standard output, nested projects, null/empty input, non-prefixed line filtering
  • 8 discovery tests: multi-project, nested subprojects, mixed Groovy/Kotlin, no subprojects, gradle failure, missing subproject directory, ignore patterns, default ignore patterns
  • CI validates all tests pass

Jira: TC-4262

🤖 Generated with Claude Code

Summary by Sourcery

Add Gradle multi-project workspace discovery and integrate it into generic workspace manifest detection.

New Features:

  • Discover Gradle multi-project subprojects via a custom init script that enumerates project paths and directories.
  • Support both Groovy and Kotlin DSL Gradle builds when collecting workspace manifest files.
  • Prefer the Gradle wrapper when resolving the Gradle executable for workspace discovery when configured.

Enhancements:

  • Extend default workspace discovery ignore patterns to include Maven target, Gradle build, and .gradle directories to reduce noise in scanning results.

Tests:

  • Add unit tests covering Gradle init script output parsing and various Gradle workspace layouts, including nested subprojects, mixed DSL variants, failure cases, missing subprojects, and ignore pattern filtering.

Discover subprojects in Gradle multi-project builds using a custom init
script that emits structured project listings. Supports Groovy and Kotlin
DSL variants, gradlew wrapper detection, and workspaceDiscoveryIgnore
filtering. Adds **/build/** and **/.gradle/** to default ignore patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Apr 28, 2026

Reviewer's Guide

Adds Gradle multi-project workspace discovery to ExhortApi using a custom init script and Gradle wrapper detection, extends workspace ignore patterns, and introduces comprehensive tests for parsing Gradle output and manifest discovery behavior.

Sequence diagram for Gradle multi-project manifest discovery

sequenceDiagram
  actor User
  participant WorkspaceDiscovery
  participant ExhortApi
  participant FileSystem
  participant Operations
  participant JavaMavenProvider
  participant WorkspaceUtils

  User ->> WorkspaceDiscovery: discoverWorkspaceManifests(workspaceDir)
  WorkspaceDiscovery ->> ExhortApi: discoverWorkspaceManifests(workspaceDir, ignorePatterns)

  ExhortApi ->> FileSystem: check settings.gradle / settings.gradle.kts
  FileSystem -->> ExhortApi: hasGradleSettings = true

  ExhortApi ->> ExhortApi: discoverGradleSubprojects(workspaceDir, ignorePatterns)

  ExhortApi ->> ExhortApi: resolveGradleBinary(workspaceDir)
  ExhortApi ->> Operations: getWrapperPreference("gradle")
  Operations -->> ExhortApi: wrapperPreferred
  alt wrapperPreferred
    ExhortApi ->> Operations: isWindows()
    Operations -->> ExhortApi: isWindows
    ExhortApi ->> JavaMavenProvider: traverseForMvnw(wrapperName, buildGradlePath)
    JavaMavenProvider -->> ExhortApi: wrapperPath or null
    alt wrapperFound
      ExhortApi ->> ExhortApi: use wrapperPath as gradleBin
    else noWrapper
      ExhortApi ->> Operations: getCustomPathOrElse("gradle")
      Operations -->> ExhortApi: gradleBin
    end
  else wrapperNotPreferred
    ExhortApi ->> Operations: getCustomPathOrElse("gradle")
    Operations -->> ExhortApi: gradleBin
  end

  ExhortApi ->> FileSystem: createTempFile(initScript)
  FileSystem -->> ExhortApi: initScriptPath
  ExhortApi ->> FileSystem: writeString(initScriptPath, GRADLE_INIT_SCRIPT)

  ExhortApi ->> Operations: runProcessGetFullOutput(workspaceDir, [gradleBin, -q, --no-daemon, --init-script, initScriptPath, daListProjects], null)
  Operations -->> ExhortApi: ProcessExecOutput

  alt exitCode != 0
    ExhortApi ->> WorkspaceUtils: filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns)
    WorkspaceUtils -->> ExhortApi: filteredPaths
    ExhortApi -->> WorkspaceDiscovery: filteredPaths
  else exitCode == 0
    ExhortApi ->> ExhortApi: parseGradleInitScriptOutput(output)
    ExhortApi ->> FileSystem: resolve build.gradle[.kts] for each subproject
    FileSystem -->> ExhortApi: manifestPaths
    ExhortApi ->> WorkspaceUtils: filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns)
    WorkspaceUtils -->> ExhortApi: filteredPaths
    ExhortApi -->> WorkspaceDiscovery: filteredPaths
  end

  ExhortApi ->> FileSystem: deleteIfExists(initScriptPath)
Loading

Class diagram for Gradle multi-project workspace discovery in ExhortApi

classDiagram
  class ExhortApi {
    -Set~String~ DEFAULT_WORKSPACE_DISCOVERY_IGNORE
    -String GRADLE_INIT_SCRIPT
    +Set~String~ resolveIgnorePatterns(Set~String~ callerPatterns)
    +List~Path~ discoverWorkspaceManifests(Path workspaceDir, Set~String~ ignorePatterns)
    -String resolveGradleBinary(Path startDir)
    -List~Path~ discoverGradleSubprojects(Path workspaceDir, Set~String~ ignorePatterns)
    +List~GradleProject~ parseGradleInitScriptOutput(String raw)
  }

  class GradleProject {
    +String path
    +String dir
  }

  class Operations {
    +boolean getWrapperPreference(String tool)
    +boolean isWindows()
    +String getCustomPathOrElse(String tool)
    +ProcessExecOutput runProcessGetFullOutput(Path workDir, String[] command, Map~String,String~ env)
  }

  class JavaMavenProvider {
    +String traverseForMvnw(String wrapperName, String referencePath)
  }

  class WorkspaceUtils {
    +List~Path~ filterByIgnorePatterns(Path workspaceDir, List~Path~ manifestPaths, Set~String~ ignorePatterns)
  }

  class ProcessExecOutput {
    +int getExitCode()
    +String getOutput()
  }

  ExhortApi *-- GradleProject
  ExhortApi ..> Operations
  ExhortApi ..> JavaMavenProvider
  ExhortApi ..> WorkspaceUtils
  Operations o-- ProcessExecOutput
Loading

Flow diagram for parsing Gradle init script output

flowchart TD
  A[Start parseGradleInitScriptOutput] --> B[Check if raw is null or blank]
  B -->|yes| C[Return empty list]
  B -->|no| D[Split raw by newline into lines]
  D --> E[Initialize empty list projects]
  E --> F[For each line in lines]

  F --> G{line startsWith ::DA_PROJECT::}
  G -->|no| F
  G -->|yes| H[Split line by :: into parts]
  H --> I[Initialize nonEmpty list]
  I --> J[For each part in parts]
  J --> K{part is not empty}
  K -->|yes| L[Add part to nonEmpty]
  K -->|no| J
  L --> J

  J -->|done| M{nonEmpty size >= 3}
  M -->|no| F
  M -->|yes| N["Create GradleProject(nonEmpty[1], nonEmpty[2])"]
  N --> O[Add GradleProject to projects]
  O --> F

  F -->|done| P[Return projects]
Loading

File-Level Changes

Change Details Files
Introduce Gradle multi-project workspace discovery in workspace manifest resolution.
  • Extend discoverWorkspaceManifests to detect Gradle workspaces via settings.gradle/settings.gradle.kts and delegate to a new Gradle-specific discovery method.
  • Add a Gradle init script string that defines a daListProjects task emitting structured project listings for all projects.
  • Implement discoverGradleSubprojects to run Gradle with the custom init script, parse the project listing, and collect build.gradle/build.gradle.kts manifests for root and subprojects, falling back gracefully on failures and always applying ignore pattern filtering.
  • Add a GradleProject record and a parseGradleInitScriptOutput helper that parses only well-formed ::DA_PROJECT:: lines, handling nested projects and edge cases.
src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java
Add Gradle binary resolution with wrapper support.
  • Implement resolveGradleBinary to prefer a gradlew wrapper discovered via JavaMavenProvider.traverseForMvnw when wrapper preference is enabled, with OS-specific wrapper names.
  • Fallback to a configurable gradle binary via Operations.getCustomPathOrElse when no wrapper is used or found.
src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java
Tighten default workspace ignore patterns and verify Gradle-related ignores.
  • Expand DEFAULT_WORKSPACE_DISCOVERY_IGNORE to include Maven target directories, Gradle build output, and .gradle metadata directories.
  • Ensure resolveIgnorePatterns merges defaults with any caller or environment overrides.
src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java
Add unit tests covering Gradle init script parsing and Gradle workspace discovery scenarios.
  • Add pure-function tests for parseGradleInitScriptOutput covering standard, nested, null/empty, and mixed log output cases.
  • Add discovery tests for Gradle multi-projects, nested subprojects, mixed Groovy/Kotlin DSL variants, no subprojects, Gradle command failures, missing subproject directories, ignore pattern filtering, and default ignore patterns including build and .gradle directories.
  • Use Mockito to mock static Operations calls (wrapper preference, OS detection, Gradle path resolution, and process execution) to simulate Gradle behavior and outputs.
  • Validate that discovered manifests match expected root and subproject build files and that ignore patterns are correctly applied.
src/test/java/io/github/guacsec/trustifyda/impl/GradleWorkspaceDiscoveryTest.java

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

@github-actions
Copy link
Copy Markdown
Contributor

Test Results

0 tests   0 ✅  0s ⏱️
0 suites  0 💤
0 files    0 ❌

Results for commit eaae875.

Copy link
Copy Markdown
Contributor

@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:

  • In parseGradleInitScriptOutput, splitting lines only on "\n" can leave trailing \r on Windows output and potentially corrupt parsed paths; consider using split("\\R") or trimming each line before processing.
  • The discoverGradleSubprojects catch block logs only e.getMessage(); using LOG.log(Level.WARNING, "Failed to discover Gradle subprojects", e) (or equivalent) would make debugging Gradle execution failures much easier by preserving the stack trace.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `parseGradleInitScriptOutput`, splitting lines only on `"\n"` can leave trailing `\r` on Windows output and potentially corrupt parsed paths; consider using `split("\\R")` or trimming each line before processing.
- The `discoverGradleSubprojects` catch block logs only `e.getMessage()`; using `LOG.log(Level.WARNING, "Failed to discover Gradle subprojects", e)` (or equivalent) would make debugging Gradle execution failures much easier by preserving the stack trace.

## Individual Comments

### Comment 1
<location path="src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java" line_range="991-1000" />
<code_context>
+      initScriptPath = Files.createTempFile("da-list-projects-", ".gradle");
+      Files.writeString(initScriptPath, GRADLE_INIT_SCRIPT);
+
+      Operations.ProcessExecOutput output =
+          Operations.runProcessGetFullOutput(
+              workspaceDir,
+              new String[] {
+                gradleBin,
+                "-q",
+                "--no-daemon",
+                "--init-script",
+                initScriptPath.toString(),
+                "daListProjects"
+              },
+              null);
+
+      if (output.getExitCode() != 0) {
+        LOG.warning("gradle daListProjects failed with exit code " + output.getExitCode());
+        return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns);
</code_context>
<issue_to_address>
**suggestion:** Include stderr (and possibly stdout) in the warning when the Gradle task fails to aid diagnosability.

Right now the warning only includes the exit code. Please also log `output.getErrorOutput()` (and possibly a truncated `getOutput()`) to make remote diagnosis easier (e.g., wrong Gradle version, missing wrapper, init script issues). In the catch block, prefer logging the full exception (e.g., `LOG.log(Level.WARNING, msg, e)`) instead of only `e.getMessage()` so the stack trace is preserved.

Suggested implementation:

```java
      if (output.getExitCode() != 0) {
        StringBuilder msg =
            new StringBuilder("gradle daListProjects failed with exit code ")
                .append(output.getExitCode());

        String stderr = output.getErrorOutput();
        if (stderr != null && !stderr.isBlank()) {
          msg.append(", stderr: ").append(truncateForLog(stderr));
        }

        String stdout = output.getOutput();
        if (stdout != null && !stdout.isBlank()) {
          msg.append(", stdout: ").append(truncateForLog(stdout));
        }

        LOG.warning(msg.toString());
        return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns);
      }

```

```java
public class ExhortApi {

  private static String truncateForLog(String text) {
    if (text == null) {
      return "";
    }
    final int max = 4000;
    String normalized = text.strip();
    if (normalized.length() <= max) {
      return normalized;
    }
    return normalized.substring(0, max) + "...[truncated]";
  }

```

To fully implement your review comment, you should also update the `catch` block that surrounds this `try` to log the full exception instead of only `e.getMessage()`. For example, change any pattern like:
- `LOG.warning("...: " + e.getMessage());`
to:
- `LOG.log(Level.WARNING, "...", e);`

You may need to ensure `java.util.logging.Level` is imported if it is not already (`import java.util.logging.Level;` near the top of the file).
</issue_to_address>

### Comment 2
<location path="src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java" line_range="1039-1048" />
<code_context>
+
+  record GradleProject(String path, String dir) {}
+
+  static List<GradleProject> parseGradleInitScriptOutput(String raw) {
+    if (raw == null || raw.isBlank()) {
+      return List.of();
+    }
+    List<GradleProject> projects = new ArrayList<>();
+    for (String line : raw.split("\n")) {
+      if (!line.startsWith("::DA_PROJECT::")) {
+        continue;
+      }
+      String[] parts = line.split("::");
+      List<String> nonEmpty = new ArrayList<>();
+      for (String part : parts) {
+        if (!part.isEmpty()) {
+          nonEmpty.add(part);
+        }
+      }
+      if (nonEmpty.size() >= 3) {
+        projects.add(new GradleProject(nonEmpty.get(1), nonEmpty.get(2)));
+      }
</code_context>
<issue_to_address>
**issue (bug_risk):** Handle Windows-style newlines and trailing CR characters when parsing Gradle output.

Because `raw.split("\n")` doesn’t remove `\r` from Windows `\r\n` line endings, the last token can include a trailing `\r`. That can end up in `proj.dir()` / `proj.path()` (e.g. `"C:\\proj\\subproj\r"`) and break `Path` resolution. Please normalize line endings or trim lines/parts (e.g. use `raw.lines()`, `line.strip()`, and `part.strip()`) before parsing.
</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 on lines +991 to +1000
Operations.ProcessExecOutput output =
Operations.runProcessGetFullOutput(
workspaceDir,
new String[] {
gradleBin,
"-q",
"--no-daemon",
"--init-script",
initScriptPath.toString(),
"daListProjects"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: Include stderr (and possibly stdout) in the warning when the Gradle task fails to aid diagnosability.

Right now the warning only includes the exit code. Please also log output.getErrorOutput() (and possibly a truncated getOutput()) to make remote diagnosis easier (e.g., wrong Gradle version, missing wrapper, init script issues). In the catch block, prefer logging the full exception (e.g., LOG.log(Level.WARNING, msg, e)) instead of only e.getMessage() so the stack trace is preserved.

Suggested implementation:

      if (output.getExitCode() != 0) {
        StringBuilder msg =
            new StringBuilder("gradle daListProjects failed with exit code ")
                .append(output.getExitCode());

        String stderr = output.getErrorOutput();
        if (stderr != null && !stderr.isBlank()) {
          msg.append(", stderr: ").append(truncateForLog(stderr));
        }

        String stdout = output.getOutput();
        if (stdout != null && !stdout.isBlank()) {
          msg.append(", stdout: ").append(truncateForLog(stdout));
        }

        LOG.warning(msg.toString());
        return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns);
      }
public class ExhortApi {

  private static String truncateForLog(String text) {
    if (text == null) {
      return "";
    }
    final int max = 4000;
    String normalized = text.strip();
    if (normalized.length() <= max) {
      return normalized;
    }
    return normalized.substring(0, max) + "...[truncated]";
  }

To fully implement your review comment, you should also update the catch block that surrounds this try to log the full exception instead of only e.getMessage(). For example, change any pattern like:

  • LOG.warning("...: " + e.getMessage());
    to:
  • LOG.log(Level.WARNING, "...", e);

You may need to ensure java.util.logging.Level is imported if it is not already (import java.util.logging.Level; near the top of the file).

Comment on lines +1039 to +1048
static List<GradleProject> parseGradleInitScriptOutput(String raw) {
if (raw == null || raw.isBlank()) {
return List.of();
}
List<GradleProject> projects = new ArrayList<>();
for (String line : raw.split("\n")) {
if (!line.startsWith("::DA_PROJECT::")) {
continue;
}
String[] parts = line.split("::");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): Handle Windows-style newlines and trailing CR characters when parsing Gradle output.

Because raw.split("\n") doesn’t remove \r from Windows \r\n line endings, the last token can include a trailing \r. That can end up in proj.dir() / proj.path() (e.g. "C:\\proj\\subproj\r") and break Path resolution. Please normalize line endings or trim lines/parts (e.g. use raw.lines(), line.strip(), and part.strip()) before parsing.

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.

1 participant