Skip to content

feat(workspace): add Maven multi-module workspace discovery#443

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

feat(workspace): add Maven multi-module workspace discovery#443
Strum355 wants to merge 1 commit intomainfrom
TC-4260

Conversation

@Strum355
Copy link
Copy Markdown
Member

@Strum355 Strum355 commented Apr 28, 2026

Summary

  • Add discoverMavenModules() to discover all pom.xml manifest paths in Maven multi-module projects using mvn help:evaluate
  • Support recursive nested aggregator discovery with cycle detection via visited set
  • Support Maven wrapper (mvnw) via resolveMavenBinary() reusing JavaMavenProvider.traverseForMvnw()
  • Add Maven ecosystem detection to discoverWorkspaceManifests() between Cargo and JavaScript
  • Add **/target/** to default workspace discovery ignore patterns
  • 8 unit tests covering: multi-module, nested aggregator, no modules, missing module dir, mvn failure, ignore filtering, parser edge cases

Test plan

  • parseMavenModuleList() unit tests (standard, single, null, empty, malformed, whitespace)
  • Multi-module workspace discovery test with mocked Operations
  • Nested aggregator recursive discovery test
  • No modules returns root pom only
  • Missing module directory skipped gracefully
  • mvn command failure returns root pom
  • Ignore pattern filtering test
  • Default ignore patterns include **/target/**

Implements TC-4260

🤖 Generated with Claude Code

Summary by Sourcery

Add Maven multi-module workspace discovery to Exhort workspace scanning and extend default ignore patterns.

New Features:

  • Discover Maven multi-module workspaces by invoking mvn help:evaluate starting from a root pom.xml and recursively following declared modules.
  • Automatically resolve the Maven binary or wrapper (mvnw) for workspace discovery using existing Maven provider logic.
  • Detect Maven workspaces in discoverWorkspaceManifests alongside existing Cargo and JavaScript workspace detection.

Enhancements:

  • Filter discovered Maven pom.xml manifests using the existing workspace ignore-pattern mechanism and include /target/ in the default ignores.

Tests:

  • Add comprehensive unit tests for Maven module list parsing, multi-module and nested aggregator discovery, behavior on no modules or missing module directories, mvn invocation failures, ignore pattern filtering, and default ignore patterns.

Add Maven multi-module workspace discovery to the Java client.
Uses `mvn help:evaluate -Dexpression=project.modules` to list declared
modules, with recursive traversal for nested aggregators and wrapper
support via `selectMvnRuntime()`.

Adds `**/target/**` to default workspace discovery ignore patterns.
Maven detection added between Cargo and JavaScript in ecosystem order.

Implements TC-4260

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

sourcery-ai Bot commented Apr 28, 2026

Reviewer's Guide

Implements Maven multi-module workspace discovery in ExhortApi by resolving a Maven (or mvnw) binary, invoking mvn help:evaluate to obtain module lists, recursively traversing nested aggregators with cycle protection, integrating results into the existing workspace detection flow, extending default ignore patterns to include Maven target directories, and adding comprehensive unit tests with Maven fixtures to validate behavior and error handling.

Sequence diagram for Maven multi-module workspace discovery

sequenceDiagram
  participant Caller
  participant ExhortApi
  participant Operations
  participant JavaMavenProvider

  Caller->>ExhortApi: discoverWorkspaceManifests(workspaceDir, ignorePatterns)
  ExhortApi->>ExhortApi: Files.exists(workspaceDir/Cargo.toml)
  ExhortApi-->>Caller: discoverCargoManifests(...) (if Cargo workspace)
  ExhortApi->>ExhortApi: Files.isRegularFile(workspaceDir/pom.xml)
  ExhortApi->>ExhortApi: discoverMavenModules(workspaceDir, ignorePatterns)

  rect rgb(235, 245, 255)
    ExhortApi->>ExhortApi: resolveMavenBinary(workspaceDir)
    ExhortApi->>Operations: getWrapperPreference(mvn)
    Operations-->>ExhortApi: preferWrapper
    ExhortApi->>Operations: isWindows()
    Operations-->>ExhortApi: isWindowsFlag
    ExhortApi->>JavaMavenProvider: traverseForMvnw(wrapperName, pomPath)
    JavaMavenProvider-->>ExhortApi: mvnwPath or null
    ExhortApi->>Operations: getCustomPathOrElse(mvn)
    Operations-->>ExhortApi: mvnBin or null
    ExhortApi-->>ExhortApi: mvnBin == null ?
  end

  ExhortApi-->>Caller: [root pom.xml only] (if mvnBin == null)

  Note over ExhortApi: Maven binary available

  ExhortApi->>ExhortApi: collectMavenModules(workspaceDir, mvnBin, visited, manifestPaths)

  loop for each directory dir
    ExhortApi->>ExhortApi: listMavenModules(dir, mvnBin)
    ExhortApi->>Operations: runProcessGetFullOutput(dir, [mvn, help:evaluate, ...])
    Operations-->>ExhortApi: ProcessExecOutput
    ExhortApi-->>ExhortApi: check exitCode, output
    ExhortApi->>ExhortApi: parseMavenModuleList(rawOutput)
    ExhortApi-->>ExhortApi: List moduleNames

    loop for each module
      ExhortApi->>ExhortApi: resolve moduleDir and modulePom
      ExhortApi-->>ExhortApi: Files.isRegularFile(modulePom)
      ExhortApi->>ExhortApi: add modulePom to manifestPaths
      ExhortApi->>ExhortApi: collectMavenModules(moduleDir, mvnBin, visited, manifestPaths)
    end
  end

  ExhortApi->>Operations: WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns)
  Operations-->>ExhortApi: filteredManifestPaths
  ExhortApi-->>Caller: filteredManifestPaths
Loading

Updated class diagram for ExhortApi Maven workspace discovery

classDiagram
  class ExhortApi {
    - static Set DEFAULT_WORKSPACE_DISCOVERY_IGNORE
    + List discoverWorkspaceManifests(Path workspaceDir, Set ignorePatterns) throws IOException
    - List discoverCargoManifests(Path workspaceDir, Set ignorePatterns) throws IOException
    - List discoverMavenModules(Path workspaceDir, Set ignorePatterns)
    - void collectMavenModules(Path dir, String mvnBin, java.util.HashSet visited, List manifestPaths)
    - List listMavenModules(Path dir, String mvnBin)
    - static List parseMavenModuleList(String raw)
    - String resolveMavenBinary(Path startDir)
  }

  class Operations {
    + static boolean isWindows()
    + static boolean getWrapperPreference(String tool)
    + static String getCustomPathOrElse(String binary)
    + static Operations.ProcessExecOutput runProcessGetFullOutput(Path dir, String[] command, Object env)
  }

  class JavaMavenProvider {
    + static String traverseForMvnw(String wrapperName, String pomPath)
  }

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

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

  ExhortApi ..> Operations : uses
  ExhortApi ..> JavaMavenProvider : uses
  ExhortApi ..> WorkspaceUtils : uses
  Operations ..> ProcessExecOutput : returns
Loading

File-Level Changes

Change Details Files
Add Maven multi-module workspace discovery integrated into workspace manifest detection.
  • Extend discoverWorkspaceManifests to detect a root pom.xml and delegate to Maven-specific discovery before JS workspaces.
  • Implement discoverMavenModules to always include the root pom.xml, resolve the Maven binary, collect module poms recursively, and apply ignore-pattern filtering.
  • Add collectMavenModules helper to recursively traverse modules, track visited directories to avoid cycles, and skip modules without pom.xml files.
  • Implement listMavenModules to run mvn help:evaluate -Dexpression=project.modules via Operations, handle failures and non-zero exit codes, and parse raw output into module names.
  • Add parseMavenModuleList pure function to parse [module-a, module-b]-formatted output into a trimmed list, with edge-case handling for null/empty/malformed strings.
  • Add resolveMavenBinary helper that reuses JavaMavenProvider.traverseForMvnw, honors wrapper preferences and OS detection, and falls back to a standard mvn binary or returns null on failure.
src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java
Update default workspace discovery ignore patterns to cover Maven build output.
  • Extend DEFAULT_WORKSPACE_DISCOVERY_IGNORE to include the **/target/** glob alongside node_modules and .git.
  • Validate that resolveIgnorePatterns preserves the new default ignore pattern via tests.
src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java
Add unit tests and test fixtures for Maven workspace discovery and module parsing.
  • Create MavenWorkspaceDiscoveryTest to cover parseMavenModuleList variants, recursive multi-module discovery (including nested aggregators), handling of no modules and missing module directories, mvn invocation failures, ignore pattern filtering, and presence of the target ignore pattern.
  • Use Mockito’s static mocking of Operations to simulate Maven binary resolution, mvn command execution outputs, and OS-specific behavior.
  • Introduce Maven test fixture directories and pom.xml files for multi-module, nested aggregator, no-modules, and missing-module scenarios under tst_manifests/workspace/maven.
src/test/java/io/github/guacsec/trustifyda/impl/MavenWorkspaceDiscoveryTest.java
src/test/resources/tst_manifests/workspace/maven/maven_missing_module/pom.xml
src/test/resources/tst_manifests/workspace/maven/maven_multi_module/pom.xml
src/test/resources/tst_manifests/workspace/maven/maven_nested_aggregator/parent/pom.xml
src/test/resources/tst_manifests/workspace/maven/maven_nested_aggregator/pom.xml
src/test/resources/tst_manifests/workspace/maven/maven_missing_module/module-a/pom.xml
src/test/resources/tst_manifests/workspace/maven/maven_multi_module/module-a/pom.xml
src/test/resources/tst_manifests/workspace/maven/maven_multi_module/module-b/pom.xml
src/test/resources/tst_manifests/workspace/maven/maven_nested_aggregator/parent/child/pom.xml
src/test/resources/tst_manifests/workspace/maven/maven_no_modules/pom.xml

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

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:

  • The Javadoc for discoverMavenModules() says it returns an empty list when Maven is unavailable, but the implementation returns a singleton list with the root pom.xml; update either the Javadoc or the behavior so they are consistent.
  • parseMavenModuleList() and listMavenModules() currently split the responsibility for handling the "null" sentinel value from mvn; consider centralizing that handling inside parseMavenModuleList() so it fully reflects the real mvn output format and callers don’t need to special-case it.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The Javadoc for discoverMavenModules() says it returns an empty list when Maven is unavailable, but the implementation returns a singleton list with the root pom.xml; update either the Javadoc or the behavior so they are consistent.
- parseMavenModuleList() and listMavenModules() currently split the responsibility for handling the "null" sentinel value from mvn; consider centralizing that handling inside parseMavenModuleList() so it fully reflects the real mvn output format and callers don’t need to special-case it.

## Individual Comments

### Comment 1
<location path="src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java" line_range="954-957" />
<code_context>
+   * @param ignorePatterns glob patterns for paths to exclude from results
+   * @return list of discovered pom.xml paths, or empty list if Maven is unavailable
+   */
+  private List<Path> discoverMavenModules(Path workspaceDir, Set<String> ignorePatterns) {
+    Path rootPom = workspaceDir.resolve("pom.xml");
+    String mvnBin = resolveMavenBinary(workspaceDir);
+    if (mvnBin == null) {
+      LOG.warning("Maven binary not available; returning root pom.xml only");
+      return List.of(rootPom);
</code_context>
<issue_to_address>
**issue (bug_risk):** Method behavior disagrees with its JavaDoc when Maven is unavailable.

JavaDoc promises an empty list when Maven is unavailable, but this branch returns `List.of(rootPom)` when `resolveMavenBinary` is `null`. Either update the JavaDoc to document that the root POM is always returned, or change this branch to return an empty list to match the documented contract.
</issue_to_address>

### Comment 2
<location path="src/test/java/io/github/guacsec/trustifyda/impl/MavenWorkspaceDiscoveryTest.java" line_range="292" />
<code_context>
+    }
+  }
+
+  /** Verifies that when mvn command fails, the root pom.xml is still returned. */
+  @Test
+  void discoverWorkspaceManifests_mvnCommandFails() throws IOException {
</code_context>
<issue_to_address>
**suggestion (testing):** Missing test for the case where Maven cannot be resolved at all (resolveMavenBinary returns null).

There’s good coverage for mvn command failures, but nothing that hits the `resolveMavenBinary()` path where it returns `null`. Since `discoverMavenModules()` then logs a warning and falls back to only the root `pom.xml`, please add a test that stubs `resolveMavenBinary` to return `null`, calls `discoverWorkspaceManifests` on a directory with a `pom.xml`, and verifies that only the root `pom.xml` is returned. This ensures correct behavior when Maven isn’t installed or resolvable.

Suggested implementation:

```java
      assertThat(manifests).noneMatch(p -> p.toString().contains("module-missing"));
    }
  }

  /** Verifies behavior when Maven cannot be resolved at all. */
  @Test
  void discoverWorkspaceManifests_mavenBinaryNotResolved() throws IOException {
    // Given a Maven workspace where Maven cannot be resolved (resolveMavenBinary returns null)
    Path workspaceDir = MAVEN_FIXTURES.resolve("maven_multi_module").toAbsolutePath().normalize();

    WorkspaceDiscoveryApi api = new MavenWorkspaceDiscovery();

    try (MockedStatic<Operations> mockOps = Mockito.mockStatic(Operations.class)) {
      // Simulate that no wrapper is preferred and Maven cannot be resolved from PATH/custom location
      mockOps.when(() -> Operations.getWrapperPreference("mvn")).thenReturn(false);
      mockOps.when(Operations::isWindows).thenReturn(false);
      mockOps.when(() -> Operations.getCustomPathOrElse("mvn")).thenReturn(null);

      // When discovering workspace manifests
      List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());

      // Then only the root pom.xml is returned
      assertThat(manifests).containsExactly(workspaceDir.resolve("pom.xml"));
    }
  }

  /** Verifies that when mvn command fails, the root pom.xml is still returned. */

```

If the actual resolution logic uses a different method or class than `Operations.getCustomPathOrElse("mvn")` to implement `resolveMavenBinary()`, you should adjust the stubbing in this test to directly mock the method that returns `null` (for example, by mocking a `resolveMavenBinary()` method on `MavenWorkspaceDiscovery` instead of or in addition to `Operations`). Also ensure that `WorkspaceDiscoveryApi`, `MavenWorkspaceDiscovery`, `Operations`, and `MockedStatic` are already imported; if not, add the appropriate imports at the top of the test file.
</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 +954 to +957
private List<Path> discoverMavenModules(Path workspaceDir, Set<String> ignorePatterns) {
Path rootPom = workspaceDir.resolve("pom.xml");
String mvnBin = resolveMavenBinary(workspaceDir);
if (mvnBin == null) {
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): Method behavior disagrees with its JavaDoc when Maven is unavailable.

JavaDoc promises an empty list when Maven is unavailable, but this branch returns List.of(rootPom) when resolveMavenBinary is null. Either update the JavaDoc to document that the root POM is always returned, or change this branch to return an empty list to match the documented contract.

}
}

/** Verifies that when mvn command fails, the root pom.xml is still returned. */
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 (testing): Missing test for the case where Maven cannot be resolved at all (resolveMavenBinary returns null).

There’s good coverage for mvn command failures, but nothing that hits the resolveMavenBinary() path where it returns null. Since discoverMavenModules() then logs a warning and falls back to only the root pom.xml, please add a test that stubs resolveMavenBinary to return null, calls discoverWorkspaceManifests on a directory with a pom.xml, and verifies that only the root pom.xml is returned. This ensures correct behavior when Maven isn’t installed or resolvable.

Suggested implementation:

      assertThat(manifests).noneMatch(p -> p.toString().contains("module-missing"));
    }
  }

  /** Verifies behavior when Maven cannot be resolved at all. */
  @Test
  void discoverWorkspaceManifests_mavenBinaryNotResolved() throws IOException {
    // Given a Maven workspace where Maven cannot be resolved (resolveMavenBinary returns null)
    Path workspaceDir = MAVEN_FIXTURES.resolve("maven_multi_module").toAbsolutePath().normalize();

    WorkspaceDiscoveryApi api = new MavenWorkspaceDiscovery();

    try (MockedStatic<Operations> mockOps = Mockito.mockStatic(Operations.class)) {
      // Simulate that no wrapper is preferred and Maven cannot be resolved from PATH/custom location
      mockOps.when(() -> Operations.getWrapperPreference("mvn")).thenReturn(false);
      mockOps.when(Operations::isWindows).thenReturn(false);
      mockOps.when(() -> Operations.getCustomPathOrElse("mvn")).thenReturn(null);

      // When discovering workspace manifests
      List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());

      // Then only the root pom.xml is returned
      assertThat(manifests).containsExactly(workspaceDir.resolve("pom.xml"));
    }
  }

  /** Verifies that when mvn command fails, the root pom.xml is still returned. */

If the actual resolution logic uses a different method or class than Operations.getCustomPathOrElse("mvn") to implement resolveMavenBinary(), you should adjust the stubbing in this test to directly mock the method that returns null (for example, by mocking a resolveMavenBinary() method on MavenWorkspaceDiscovery instead of or in addition to Operations). Also ensure that WorkspaceDiscoveryApi, MavenWorkspaceDiscovery, Operations, and MockedStatic are already imported; if not, add the appropriate imports at the top of the test file.

if (preferWrapper) {
String wrapperName = Operations.isWindows() ? "mvnw.cmd" : "mvnw";
String mvnw =
JavaMavenProvider.traverseForMvnw(wrapperName, startDir.resolve("pom.xml").toString());
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.

traverseForMvnw with 2 args is a private instance method (line 462 of JavaMavenProvider.java). The public static overload requires 3 args (line 466). This call from ExhortApi should not compile, it's calling a private method from outside the class, and Java won't auto-fill a null third argument.

* @param startDir the directory from which to start searching for mvnw
* @return the resolved Maven binary path, or null if Maven is not available
*/
private String resolveMavenBinary(Path startDir) {
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.

resolveMavenBinary duplicates the logic from selectMvnRuntime but skips the binary verification step (mvnw -v). Consider reusing selectMvnRuntime (or extracting a shared helper) so both paths stay consistent. Not a bug, failures are caught downstream, but a broken binary would produce a misleading "mvn help:evaluate failed" warning instead of a clear "maven not available" message.

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