From 3f0af077652b6506332a6c8cf50355149979afc7 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Tue, 28 Apr 2026 12:10:25 +0100 Subject: [PATCH 1/6] feat(workspace): add Maven multi-module workspace discovery 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 Assisted-by: Claude Code --- .../guacsec/trustifyda/impl/ExhortApi.java | 159 ++++++- .../impl/MavenWorkspaceDiscoveryTest.java | 397 ++++++++++++++++++ .../maven_missing_module/module-a/pom.xml | 9 + .../maven/maven_missing_module/pom.xml | 11 + .../maven/maven_multi_module/module-a/pom.xml | 9 + .../maven/maven_multi_module/module-b/pom.xml | 9 + .../maven/maven_multi_module/pom.xml | 11 + .../parent/child/pom.xml | 9 + .../maven_nested_aggregator/parent/pom.xml | 10 + .../maven/maven_nested_aggregator/pom.xml | 10 + .../workspace/maven/maven_no_modules/pom.xml | 6 + 11 files changed, 638 insertions(+), 2 deletions(-) create mode 100644 src/test/java/io/github/guacsec/trustifyda/impl/MavenWorkspaceDiscoveryTest.java create mode 100644 src/test/resources/tst_manifests/workspace/maven/maven_missing_module/module-a/pom.xml create mode 100644 src/test/resources/tst_manifests/workspace/maven/maven_missing_module/pom.xml create mode 100644 src/test/resources/tst_manifests/workspace/maven/maven_multi_module/module-a/pom.xml create mode 100644 src/test/resources/tst_manifests/workspace/maven/maven_multi_module/module-b/pom.xml create mode 100644 src/test/resources/tst_manifests/workspace/maven/maven_multi_module/pom.xml create mode 100644 src/test/resources/tst_manifests/workspace/maven/maven_nested_aggregator/parent/child/pom.xml create mode 100644 src/test/resources/tst_manifests/workspace/maven/maven_nested_aggregator/parent/pom.xml create mode 100644 src/test/resources/tst_manifests/workspace/maven/maven_nested_aggregator/pom.xml create mode 100644 src/test/resources/tst_manifests/workspace/maven/maven_no_modules/pom.xml diff --git a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java index eed0e0ee..5a5ce862 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -31,6 +31,7 @@ import io.github.guacsec.trustifyda.license.LicenseCheck; import io.github.guacsec.trustifyda.logging.LoggersFactory; import io.github.guacsec.trustifyda.providers.golang.model.GoWorkspace; +import io.github.guacsec.trustifyda.providers.JavaMavenProvider; import io.github.guacsec.trustifyda.providers.javascript.workspace.JsWorkspaceDiscovery; import io.github.guacsec.trustifyda.providers.rust.model.CargoMetadata; import io.github.guacsec.trustifyda.tools.Ecosystem; @@ -843,7 +844,7 @@ int resolveBatchConcurrency() { } private static final Set DEFAULT_WORKSPACE_DISCOVERY_IGNORE = - Set.of("**/node_modules/**", "**/.git/**"); + Set.of("**/node_modules/**", "**/.git/**", "**/target/**"); /** Merges default ignore patterns, env var overrides, and caller-provided patterns. */ Set resolveIgnorePatterns(Set callerPatterns) { @@ -865,7 +866,8 @@ Set resolveIgnorePatterns(Set callerPatterns) { /** * Detects the workspace ecosystem and discovers manifest paths. Checks for Cargo workspace first - * (Cargo.toml + Cargo.lock), then falls back to JS workspace discovery. + * (Cargo.toml + Cargo.lock), then Maven multi-module (pom.xml), then falls back to JS workspace + * discovery. */ List discoverWorkspaceManifests(Path workspaceDir, Set ignorePatterns) throws IOException { @@ -884,6 +886,12 @@ List discoverWorkspaceManifests(Path workspaceDir, Set ignorePatte } } + // Maven multi-module: pom.xml + Path pomXml = workspaceDir.resolve("pom.xml"); + if (Files.isRegularFile(pomXml)) { + return discoverMavenModules(workspaceDir, ignorePatterns); + } + // JS workspace: require package.json + a lock file Path packageJson = workspaceDir.resolve("package.json"); boolean hasJsLock = @@ -975,6 +983,153 @@ private List discoverGoWorkspaceModules(Path workspaceDir, Set ign return Collections.emptyList(); } } + /** + * Discovers Maven multi-module workspace manifests by invoking {@code mvn help:evaluate} to list + * declared modules, then recursively checking each module for nested aggregators. + * + *

Uses the same Maven binary selection as {@link + * io.github.guacsec.trustifyda.providers.JavaMavenProvider}, including wrapper support via {@code + * selectMvnRuntime}. Always includes the root {@code pom.xml}. Uses a visited set to prevent + * cycles in the module graph. + * + * @param workspaceDir the root directory containing the aggregator pom.xml + * @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 discoverMavenModules(Path workspaceDir, Set 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); + } + + var visited = new java.util.HashSet(); + var manifestPaths = new ArrayList(); + manifestPaths.add(rootPom); + + collectMavenModules(workspaceDir, mvnBin, visited, manifestPaths); + + return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns); + } + + /** + * Recursively collects Maven module pom.xml paths by invoking {@code mvn help:evaluate} on each + * directory and descending into sub-modules. + * + * @param dir the directory to evaluate for modules + * @param mvnBin the resolved Maven binary path + * @param visited set of already-visited directories to prevent cycles + * @param manifestPaths accumulator list for discovered pom.xml paths + */ + private void collectMavenModules( + Path dir, String mvnBin, java.util.HashSet visited, List manifestPaths) { + Path resolvedDir = dir.toAbsolutePath().normalize(); + if (!visited.add(resolvedDir)) { + return; + } + + List modules = listMavenModules(resolvedDir, mvnBin); + for (String mod : modules) { + Path moduleDir = resolvedDir.resolve(mod); + Path modulePom = moduleDir.resolve("pom.xml"); + if (Files.isRegularFile(modulePom)) { + manifestPaths.add(modulePom); + collectMavenModules(moduleDir, mvnBin, visited, manifestPaths); + } + } + } + + /** + * Invokes {@code mvn help:evaluate -Dexpression=project.modules} on the given directory and + * parses the output to extract module names. + * + * @param dir the directory containing a pom.xml to evaluate + * @param mvnBin the resolved Maven binary path + * @return list of module names, or empty list on failure + */ + private List listMavenModules(Path dir, String mvnBin) { + try { + Path pomFile = dir.resolve("pom.xml"); + Operations.ProcessExecOutput output = + Operations.runProcessGetFullOutput( + dir, + new String[] { + mvnBin, + "help:evaluate", + "-Dexpression=project.modules", + "-q", + "-DforceStdout", + "-f", + pomFile.toString(), + "--batch-mode" + }, + null); + if (output.getExitCode() != 0) { + LOG.warning( + "mvn help:evaluate failed with exit code " + output.getExitCode() + " in " + dir); + return Collections.emptyList(); + } + String raw = output.getOutput().trim(); + if (raw.isEmpty() || "null".equals(raw)) { + return Collections.emptyList(); + } + return parseMavenModuleList(raw); + } catch (Exception e) { + LOG.warning("Failed to list Maven modules in " + dir + ": " + e.getMessage()); + return Collections.emptyList(); + } + } + + /** + * Parses the output of {@code mvn help:evaluate -Dexpression=project.modules} which returns a + * string like {@code [module-a, module-b]}. + * + * @param raw the raw output string + * @return list of module name strings + */ + static List parseMavenModuleList(String raw) { + if (raw == null || raw.isEmpty()) { + return Collections.emptyList(); + } + // Expected format: [module-a, module-b, ...] + java.util.regex.Matcher matcher = + java.util.regex.Pattern.compile("^\\[(.+)]$").matcher(raw.trim()); + if (!matcher.matches()) { + return Collections.emptyList(); + } + String inner = matcher.group(1); + return java.util.Arrays.stream(inner.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } + + /** + * Resolves the Maven binary to use, following the same wrapper preference logic as {@link + * io.github.guacsec.trustifyda.providers.JavaMavenProvider#selectMvnRuntime}. + * + * @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) { + try { + boolean preferWrapper = Operations.getWrapperPreference("mvn"); + String mvn = Operations.isWindows() ? "mvn.cmd" : "mvn"; + if (preferWrapper) { + String wrapperName = Operations.isWindows() ? "mvnw.cmd" : "mvnw"; + String mvnw = + JavaMavenProvider.traverseForMvnw(wrapperName, startDir.resolve("pom.xml").toString()); + if (mvnw != null) { + return mvnw; + } + } + return Operations.getCustomPathOrElse(mvn); + } catch (Exception e) { + LOG.warning("Failed to resolve Maven binary: " + e.getMessage()); + return null; + } + } /** * Checks whether a package.json has "private": true, meaning it should not be analyzed as a diff --git a/src/test/java/io/github/guacsec/trustifyda/impl/MavenWorkspaceDiscoveryTest.java b/src/test/java/io/github/guacsec/trustifyda/impl/MavenWorkspaceDiscoveryTest.java new file mode 100644 index 00000000..91280bd0 --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/impl/MavenWorkspaceDiscoveryTest.java @@ -0,0 +1,397 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.guacsec.trustifyda.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; + +import io.github.guacsec.trustifyda.tools.Operations; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +class MavenWorkspaceDiscoveryTest { + + private static final Path MAVEN_FIXTURES = + Path.of("src/test/resources/tst_manifests/workspace/maven"); + + // --- parseMavenModuleList tests (pure function, no mocking needed) --- + + /** Verifies that a standard module list output is parsed correctly. */ + @Test + void parseMavenModuleList_standardOutput() { + // Given a typical mvn help:evaluate output + String raw = "[module-a, module-b]"; + + // When parsing + List result = ExhortApi.parseMavenModuleList(raw); + + // Then both modules are returned + assertThat(result).containsExactly("module-a", "module-b"); + } + + /** Verifies that a single module is parsed correctly. */ + @Test + void parseMavenModuleList_singleModule() { + // Given output with one module + String raw = "[parent]"; + + // When parsing + List result = ExhortApi.parseMavenModuleList(raw); + + // Then the single module is returned + assertThat(result).containsExactly("parent"); + } + + /** Verifies that null input returns an empty list. */ + @Test + void parseMavenModuleList_nullInput() { + assertThat(ExhortApi.parseMavenModuleList(null)).isEmpty(); + } + + /** Verifies that empty string returns an empty list. */ + @Test + void parseMavenModuleList_emptyInput() { + assertThat(ExhortApi.parseMavenModuleList("")).isEmpty(); + } + + /** Verifies that 'null' string (no modules) returns an empty list. */ + @Test + void parseMavenModuleList_nullString() { + // "null" is returned by mvn when there are no modules + assertThat(ExhortApi.parseMavenModuleList("null")).isEmpty(); + } + + /** Verifies that malformed output (no brackets) returns an empty list. */ + @Test + void parseMavenModuleList_malformedOutput() { + assertThat(ExhortApi.parseMavenModuleList("module-a, module-b")).isEmpty(); + } + + /** Verifies that whitespace around module names is trimmed. */ + @Test + void parseMavenModuleList_withWhitespace() { + // Given output with extra whitespace + String raw = "[ module-a , module-b ]"; + + // When parsing + List result = ExhortApi.parseMavenModuleList(raw); + + // Then modules are trimmed + assertThat(result).containsExactly("module-a", "module-b"); + } + + // --- discoverWorkspaceManifests tests (require mocking Operations) --- + + /** Verifies that a multi-module Maven project discovers all module pom.xml files. */ + @Test + void discoverWorkspaceManifests_mavenMultiModule() throws IOException { + // Given a multi-module Maven workspace + Path workspaceDir = MAVEN_FIXTURES.resolve("maven_multi_module").toAbsolutePath().normalize(); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + // Mock Maven binary resolution + mockOps.when(() -> Operations.getWrapperPreference("mvn")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("mvn")).thenReturn("mvn"); + + // Mock mvn help:evaluate for root pom -> returns [module-a, module-b] + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput("[module-a, module-b]", "", 0)); + + // Mock mvn help:evaluate for module-a -> returns null (leaf module) + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir.resolve("module-a")), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput("null", "", 0)); + + // Mock mvn help:evaluate for module-b -> returns null (leaf module) + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir.resolve("module-b")), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput("null", "", 0)); + + // When discovering workspace manifests + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + // Then root + 2 modules = 3 pom.xml files + assertThat(manifests).hasSize(3); + assertThat(manifests).allMatch(p -> p.getFileName().toString().equals("pom.xml")); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("pom.xml")); + assertThat(manifests).anyMatch(p -> p.toString().contains("module-a")); + assertThat(manifests).anyMatch(p -> p.toString().contains("module-b")); + } + } + + /** Verifies that nested aggregator modules are discovered recursively. */ + @Test + void discoverWorkspaceManifests_nestedAggregator() throws IOException { + // Given a nested Maven aggregator workspace + Path workspaceDir = + MAVEN_FIXTURES.resolve("maven_nested_aggregator").toAbsolutePath().normalize(); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("mvn")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("mvn")).thenReturn("mvn"); + + // Root -> [parent] + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput("[parent]", "", 0)); + + // parent -> [child] + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir.resolve("parent").toAbsolutePath().normalize()), + any(String[].class), + isNull())) + .thenReturn(new Operations.ProcessExecOutput("[child]", "", 0)); + + // child -> null (leaf) + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq( + workspaceDir + .resolve("parent") + .resolve("child") + .toAbsolutePath() + .normalize()), + any(String[].class), + isNull())) + .thenReturn(new Operations.ProcessExecOutput("null", "", 0)); + + // When discovering workspace manifests + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + // Then root + parent + child = 3 pom.xml files + assertThat(manifests).hasSize(3); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("pom.xml")); + assertThat(manifests) + .anyMatch(p -> p.toString().contains("parent" + java.io.File.separator + "pom.xml")); + assertThat(manifests) + .anyMatch( + p -> + p.toString() + .contains( + "parent" + + java.io.File.separator + + "child" + + java.io.File.separator + + "pom.xml")); + } + } + + /** Verifies that a project with no modules returns only the root pom.xml. */ + @Test + void discoverWorkspaceManifests_noModules() throws IOException { + // Given a Maven project with no modules + Path workspaceDir = MAVEN_FIXTURES.resolve("maven_no_modules").toAbsolutePath().normalize(); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("mvn")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("mvn")).thenReturn("mvn"); + + // Root -> null (no modules) + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput("null", "", 0)); + + // When discovering workspace manifests + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + // Then only the root pom.xml is returned + assertThat(manifests).hasSize(1); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("pom.xml")); + } + } + + /** Verifies that missing module directories are skipped gracefully. */ + @Test + void discoverWorkspaceManifests_missingModuleDirectory() throws IOException { + // Given a Maven project where one module directory is missing + Path workspaceDir = MAVEN_FIXTURES.resolve("maven_missing_module").toAbsolutePath().normalize(); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("mvn")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("mvn")).thenReturn("mvn"); + + // Root -> [module-a, module-missing] + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput("[module-a, module-missing]", "", 0)); + + // module-a -> null (leaf) + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir.resolve("module-a").toAbsolutePath().normalize()), + any(String[].class), + isNull())) + .thenReturn(new Operations.ProcessExecOutput("null", "", 0)); + + // When discovering workspace manifests + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + // Then root + module-a = 2 (module-missing is skipped) + assertThat(manifests).hasSize(2); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("pom.xml")); + assertThat(manifests).anyMatch(p -> p.toString().contains("module-a")); + assertThat(manifests).noneMatch(p -> p.toString().contains("module-missing")); + } + } + + /** Verifies that when mvn command fails, the root pom.xml is still returned. */ + @Test + void discoverWorkspaceManifests_mvnCommandFails() throws IOException { + // Given a Maven workspace where mvn invocation fails + Path workspaceDir = MAVEN_FIXTURES.resolve("maven_multi_module").toAbsolutePath().normalize(); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("mvn")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("mvn")).thenReturn("mvn"); + + // mvn command fails with non-zero exit code + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput("", "error", 1)); + + // When discovering workspace manifests + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + // Then only the root pom.xml is returned (mvn failure falls through gracefully) + assertThat(manifests).hasSize(1); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("pom.xml")); + } + } + + /** Verifies that ignore patterns filter out matched module paths. */ + @Test + void discoverWorkspaceManifests_ignorePatternFiltering() throws IOException { + // Given a multi-module Maven workspace with an ignore pattern + Path workspaceDir = MAVEN_FIXTURES.resolve("maven_multi_module").toAbsolutePath().normalize(); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("mvn")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("mvn")).thenReturn("mvn"); + + // Root -> [module-a, module-b] + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput("[module-a, module-b]", "", 0)); + + // module-a -> null + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir.resolve("module-a").toAbsolutePath().normalize()), + any(String[].class), + isNull())) + .thenReturn(new Operations.ProcessExecOutput("null", "", 0)); + + // module-b -> null + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir.resolve("module-b").toAbsolutePath().normalize()), + any(String[].class), + isNull())) + .thenReturn(new Operations.ProcessExecOutput("null", "", 0)); + + // When discovering with ignore pattern for module-b + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of("**/module-b/**")); + + // Then module-b is filtered out + assertThat(manifests).anyMatch(p -> p.toString().contains("module-a")); + assertThat(manifests).noneMatch(p -> p.toString().contains("module-b")); + } + } + + /** Verifies that the default ignore patterns include target directories. */ + @Test + void defaultIgnorePatterns_includesTarget() throws IOException { + // Given a multi-module project with a pom in a target directory + // The default patterns should include **/target/** + Path workspaceDir = MAVEN_FIXTURES.resolve("maven_multi_module").toAbsolutePath().normalize(); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("mvn")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("mvn")).thenReturn("mvn"); + + // Root -> [module-a] + mockOps + .when(() -> Operations.runProcessGetFullOutput(any(), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput("null", "", 0)); + + // When resolving default ignore patterns + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + Set resolvedPatterns = api.resolveIgnorePatterns(null); + + // Then **/target/** is included + assertThat(resolvedPatterns).contains("**/target/**"); + assertThat(resolvedPatterns).contains("**/node_modules/**"); + assertThat(resolvedPatterns).contains("**/.git/**"); + } + } +} diff --git a/src/test/resources/tst_manifests/workspace/maven/maven_missing_module/module-a/pom.xml b/src/test/resources/tst_manifests/workspace/maven/maven_missing_module/module-a/pom.xml new file mode 100644 index 00000000..6740fbdd --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/maven/maven_missing_module/module-a/pom.xml @@ -0,0 +1,9 @@ + + 4.0.0 + + com.example + parent + 1.0.0 + + module-a + diff --git a/src/test/resources/tst_manifests/workspace/maven/maven_missing_module/pom.xml b/src/test/resources/tst_manifests/workspace/maven/maven_missing_module/pom.xml new file mode 100644 index 00000000..1476968b --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/maven/maven_missing_module/pom.xml @@ -0,0 +1,11 @@ + + 4.0.0 + com.example + parent + 1.0.0 + pom + + module-a + module-missing + + diff --git a/src/test/resources/tst_manifests/workspace/maven/maven_multi_module/module-a/pom.xml b/src/test/resources/tst_manifests/workspace/maven/maven_multi_module/module-a/pom.xml new file mode 100644 index 00000000..6740fbdd --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/maven/maven_multi_module/module-a/pom.xml @@ -0,0 +1,9 @@ + + 4.0.0 + + com.example + parent + 1.0.0 + + module-a + diff --git a/src/test/resources/tst_manifests/workspace/maven/maven_multi_module/module-b/pom.xml b/src/test/resources/tst_manifests/workspace/maven/maven_multi_module/module-b/pom.xml new file mode 100644 index 00000000..03684ec4 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/maven/maven_multi_module/module-b/pom.xml @@ -0,0 +1,9 @@ + + 4.0.0 + + com.example + parent + 1.0.0 + + module-b + diff --git a/src/test/resources/tst_manifests/workspace/maven/maven_multi_module/pom.xml b/src/test/resources/tst_manifests/workspace/maven/maven_multi_module/pom.xml new file mode 100644 index 00000000..0566726e --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/maven/maven_multi_module/pom.xml @@ -0,0 +1,11 @@ + + 4.0.0 + com.example + parent + 1.0.0 + pom + + module-a + module-b + + diff --git a/src/test/resources/tst_manifests/workspace/maven/maven_nested_aggregator/parent/child/pom.xml b/src/test/resources/tst_manifests/workspace/maven/maven_nested_aggregator/parent/child/pom.xml new file mode 100644 index 00000000..b29df9e8 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/maven/maven_nested_aggregator/parent/child/pom.xml @@ -0,0 +1,9 @@ + + 4.0.0 + + com.example + parent + 1.0.0 + + child + diff --git a/src/test/resources/tst_manifests/workspace/maven/maven_nested_aggregator/parent/pom.xml b/src/test/resources/tst_manifests/workspace/maven/maven_nested_aggregator/parent/pom.xml new file mode 100644 index 00000000..b95c8a95 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/maven/maven_nested_aggregator/parent/pom.xml @@ -0,0 +1,10 @@ + + 4.0.0 + com.example + parent + 1.0.0 + pom + + child + + diff --git a/src/test/resources/tst_manifests/workspace/maven/maven_nested_aggregator/pom.xml b/src/test/resources/tst_manifests/workspace/maven/maven_nested_aggregator/pom.xml new file mode 100644 index 00000000..144babcf --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/maven/maven_nested_aggregator/pom.xml @@ -0,0 +1,10 @@ + + 4.0.0 + com.example + root + 1.0.0 + pom + + parent + + diff --git a/src/test/resources/tst_manifests/workspace/maven/maven_no_modules/pom.xml b/src/test/resources/tst_manifests/workspace/maven/maven_no_modules/pom.xml new file mode 100644 index 00000000..770a783f --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/maven/maven_no_modules/pom.xml @@ -0,0 +1,6 @@ + + 4.0.0 + com.example + single + 1.0.0 + From 831da063612c640ded54793a03e69b2520a14f9c Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Thu, 30 Apr 2026 16:23:12 +0100 Subject: [PATCH 2/6] fix(workspace): prevent Maven discovery from blocking fallthrough to other ecosystems Two fixes: 1. discoverWorkspaceManifests now checks if Maven module discovery found actual submodules (size > 1) before returning. A single-module Maven project (just root pom.xml) falls through to Gradle/Go/uv/JS detection. 2. resolveMavenBinary now verifies the wrapper is accessible (runs it with -v) before returning it, matching selectMvnRuntime behavior. Also uses Operations.getExecutable instead of getCustomPathOrElse to verify the system Maven binary exists. Co-Authored-By: Claude Opus 4.6 --- .../github/guacsec/trustifyda/impl/ExhortApi.java | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java index 5a5ce862..459dc31c 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -889,7 +889,10 @@ List discoverWorkspaceManifests(Path workspaceDir, Set ignorePatte // Maven multi-module: pom.xml Path pomXml = workspaceDir.resolve("pom.xml"); if (Files.isRegularFile(pomXml)) { - return discoverMavenModules(workspaceDir, ignorePatterns); + List mavenManifests = discoverMavenModules(workspaceDir, ignorePatterns); + if (mavenManifests.size() > 1) { + return mavenManifests; + } } // JS workspace: require package.json + a lock file @@ -1121,10 +1124,15 @@ private String resolveMavenBinary(Path startDir) { String mvnw = JavaMavenProvider.traverseForMvnw(wrapperName, startDir.resolve("pom.xml").toString()); if (mvnw != null) { - return mvnw; + try { + Operations.runProcess(startDir, mvnw, "-v"); + return mvnw; + } catch (Exception e) { + LOG.warning("Maven wrapper found but not accessible: " + e.getMessage()); + } } } - return Operations.getCustomPathOrElse(mvn); + return Operations.getExecutable(mvn, "-v"); } catch (Exception e) { LOG.warning("Failed to resolve Maven binary: " + e.getMessage()); return null; From 9c82b16ce5e9428f02e42327f3013cce5b8c5148 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Fri, 1 May 2026 18:52:40 +0100 Subject: [PATCH 3/6] fix(workspace): use public static overload of traverseForMvnw The 2-arg traverseForMvnw is a private instance method on JavaMavenProvider. Call the 3-arg public static overload with null repoRoot instead, which has the same behavior. Co-Authored-By: Claude Opus 4.6 --- src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java index 459dc31c..747a6925 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -1122,7 +1122,8 @@ private String resolveMavenBinary(Path startDir) { if (preferWrapper) { String wrapperName = Operations.isWindows() ? "mvnw.cmd" : "mvnw"; String mvnw = - JavaMavenProvider.traverseForMvnw(wrapperName, startDir.resolve("pom.xml").toString()); + JavaMavenProvider.traverseForMvnw( + wrapperName, startDir.resolve("pom.xml").toString(), null); if (mvnw != null) { try { Operations.runProcess(startDir, mvnw, "-v"); From 580d9b37c791356bc8976ad1f15ccf9b6e8ef376 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Fri, 1 May 2026 19:39:23 +0100 Subject: [PATCH 4/6] fix(workspace): fix Maven binary resolution and glob pattern matching Use getCustomPathOrElse for Maven binary resolution (matching Gradle), and handle Java PathMatcher quirk where **/X/** doesn't match paths where X is the first component. Co-Authored-By: Claude Opus 4.6 --- .../guacsec/trustifyda/impl/ExhortApi.java | 32 ++++++------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java index 747a6925..965752ba 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -890,7 +890,7 @@ List discoverWorkspaceManifests(Path workspaceDir, Set ignorePatte Path pomXml = workspaceDir.resolve("pom.xml"); if (Files.isRegularFile(pomXml)) { List mavenManifests = discoverMavenModules(workspaceDir, ignorePatterns); - if (mavenManifests.size() > 1) { + if (!mavenManifests.isEmpty()) { return mavenManifests; } } @@ -1115,29 +1115,17 @@ static List parseMavenModuleList(String raw) { * @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) { - try { - boolean preferWrapper = Operations.getWrapperPreference("mvn"); - String mvn = Operations.isWindows() ? "mvn.cmd" : "mvn"; - if (preferWrapper) { - String wrapperName = Operations.isWindows() ? "mvnw.cmd" : "mvnw"; - String mvnw = - JavaMavenProvider.traverseForMvnw( - wrapperName, startDir.resolve("pom.xml").toString(), null); - if (mvnw != null) { - try { - Operations.runProcess(startDir, mvnw, "-v"); - return mvnw; - } catch (Exception e) { - LOG.warning("Maven wrapper found but not accessible: " + e.getMessage()); - } - } + private static String resolveMavenBinary(Path startDir) { + if (Operations.getWrapperPreference("mvn")) { + String wrapperName = Operations.isWindows() ? "mvnw.cmd" : "mvnw"; + String wrapper = + JavaMavenProvider.traverseForMvnw( + wrapperName, startDir.resolve("pom.xml").toString(), null); + if (wrapper != null) { + return wrapper; } - return Operations.getExecutable(mvn, "-v"); - } catch (Exception e) { - LOG.warning("Failed to resolve Maven binary: " + e.getMessage()); - return null; } + return Operations.getCustomPathOrElse("mvn"); } /** From ef2a103b5ec87bb4110f9957ccd92ae1387b4dcf Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Tue, 5 May 2026 13:10:02 +0100 Subject: [PATCH 5/6] refactor: extract shared resolveVerifiedMavenBinary on JavaMavenProvider Consolidate duplicate Maven binary resolution logic between ExhortApi and JavaMavenProvider into a single public static method that verifies the binary with --version before returning it. - Add JavaMavenProvider.resolveVerifiedMavenBinary(Path) returning Optional with wrapper-first resolution and verification - Refactor selectMvnRuntime to delegate to the new shared method - Delete duplicate resolveMavenBinary from ExhortApi, update discoverMavenModules to call the shared method via .orElse(null) - Fix @return JavaDoc on discoverMavenModules to match actual behavior - Make 2-arg traverseForMvnw static (required by static caller) Co-Authored-By: Claude Opus 4.6 --- .../guacsec/trustifyda/impl/ExhortApi.java | 32 +++--------- .../providers/JavaMavenProvider.java | 50 +++++++++++-------- 2 files changed, 36 insertions(+), 46 deletions(-) diff --git a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java index 965752ba..931f9123 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -990,18 +990,18 @@ private List discoverGoWorkspaceModules(Path workspaceDir, Set ign * Discovers Maven multi-module workspace manifests by invoking {@code mvn help:evaluate} to list * declared modules, then recursively checking each module for nested aggregators. * - *

Uses the same Maven binary selection as {@link - * io.github.guacsec.trustifyda.providers.JavaMavenProvider}, including wrapper support via {@code - * selectMvnRuntime}. Always includes the root {@code pom.xml}. Uses a visited set to prevent - * cycles in the module graph. + *

Uses {@link JavaMavenProvider#resolveVerifiedMavenBinary} for Maven binary resolution, + * including wrapper support. Always includes the root {@code pom.xml}. Uses a visited set to + * prevent cycles in the module graph. * * @param workspaceDir the root directory containing the aggregator pom.xml * @param ignorePatterns glob patterns for paths to exclude from results - * @return list of discovered pom.xml paths, or empty list if Maven is unavailable + * @return list of discovered pom.xml paths; always includes the root pom.xml even if Maven is + * unavailable */ private List discoverMavenModules(Path workspaceDir, Set ignorePatterns) { Path rootPom = workspaceDir.resolve("pom.xml"); - String mvnBin = resolveMavenBinary(workspaceDir); + String mvnBin = JavaMavenProvider.resolveVerifiedMavenBinary(workspaceDir).orElse(null); if (mvnBin == null) { LOG.warning("Maven binary not available; returning root pom.xml only"); return List.of(rootPom); @@ -1108,26 +1108,6 @@ static List parseMavenModuleList(String raw) { .toList(); } - /** - * Resolves the Maven binary to use, following the same wrapper preference logic as {@link - * io.github.guacsec.trustifyda.providers.JavaMavenProvider#selectMvnRuntime}. - * - * @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 static String resolveMavenBinary(Path startDir) { - if (Operations.getWrapperPreference("mvn")) { - String wrapperName = Operations.isWindows() ? "mvnw.cmd" : "mvnw"; - String wrapper = - JavaMavenProvider.traverseForMvnw( - wrapperName, startDir.resolve("pom.xml").toString(), null); - if (wrapper != null) { - return wrapper; - } - } - return Operations.getCustomPathOrElse("mvn"); - } - /** * Checks whether a package.json has "private": true, meaning it should not be analyzed as a * publishable package. diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/JavaMavenProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/JavaMavenProvider.java index c21acae3..da01e03b 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/JavaMavenProvider.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/JavaMavenProvider.java @@ -39,7 +39,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.logging.Level; +import java.util.Optional; import java.util.logging.Logger; import java.util.stream.Collectors; import javax.xml.stream.XMLInputFactory; @@ -431,27 +431,16 @@ private List buildMvnCommandArgs(String... baseArgs) { } private String selectMvnRuntime(final Path manifestPath) { - boolean preferWrapper = Operations.getWrapperPreference(MVN); - if (preferWrapper && manifestPath != null) { - String wrapperName = Operations.isWindows() ? "mvnw.cmd" : "mvnw"; - String mvnw = traverseForMvnw(wrapperName, manifestPath.toString()); - if (mvnw != null) { - try { - // verify maven wrapper is accessible - Operations.runProcess(manifestPath.getParent(), mvnw, ARG_VERSION); - if (debugLoggingIsNeeded()) { - log.info(String.format("using maven wrapper from : %s", mvnw)); - } - return mvnw; - } catch (Exception e) { - log.log( - Level.WARNING, - "Failed to check for mvnw due to: {0} Fall back to use mvn", - e.getMessage()); + Path dir = (manifestPath != null) ? manifestPath.getParent() : null; + if (dir != null) { + Optional resolved = resolveVerifiedMavenBinary(dir); + if (resolved.isPresent()) { + if (debugLoggingIsNeeded()) { + log.info(String.format("using maven binary: %s", resolved.get())); } + return resolved.get(); } } - // If maven wrapper is not requested or not accessible, fall back to use mvn String mvn = Operations.getExecutable(MVN, ARG_VERSION); if (debugLoggingIsNeeded()) { log.info(String.format("using mvn executable from : %s", mvn)); @@ -459,7 +448,28 @@ private String selectMvnRuntime(final Path manifestPath) { return mvn; } - private String traverseForMvnw(String wrapperName, String startingManifest) { + public static Optional resolveVerifiedMavenBinary(Path workspaceDir) { + if (Operations.getWrapperPreference(MVN)) { + String wrapperName = Operations.isWindows() ? "mvnw.cmd" : "mvnw"; + String mvnw = traverseForMvnw(wrapperName, workspaceDir.resolve("pom.xml").toString()); + if (mvnw != null) { + try { + Operations.runProcess(workspaceDir, mvnw, ARG_VERSION); + return Optional.of(mvnw); + } catch (Exception e) { + // wrapper found but not functional — fall through to system mvn + } + } + } + try { + String mvn = Operations.getExecutable(MVN, ARG_VERSION); + return Optional.of(mvn); + } catch (RuntimeException e) { + return Optional.empty(); + } + } + + private static String traverseForMvnw(String wrapperName, String startingManifest) { return traverseForMvnw(wrapperName, startingManifest, null); } From 3cc2c095dc65a3f8de560f71e4bc9dee191a0431 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Tue, 5 May 2026 13:17:47 +0100 Subject: [PATCH 6/6] fix(test): update Maven workspace tests to mock getExecutable The resolveVerifiedMavenBinary refactoring changed the binary resolution path from getCustomPathOrElse (no verification) to getExecutable (runs --version). Update test mocks accordingly so Maven discovery tests pass in environments without Maven on PATH. Co-Authored-By: Claude Opus 4.6 --- .../github/guacsec/trustifyda/impl/ExhortApi.java | 3 ++- .../impl/MavenWorkspaceDiscoveryTest.java | 14 +++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java index 931f9123..c3a379e0 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -30,8 +30,8 @@ import io.github.guacsec.trustifyda.image.ImageUtils; import io.github.guacsec.trustifyda.license.LicenseCheck; import io.github.guacsec.trustifyda.logging.LoggersFactory; -import io.github.guacsec.trustifyda.providers.golang.model.GoWorkspace; import io.github.guacsec.trustifyda.providers.JavaMavenProvider; +import io.github.guacsec.trustifyda.providers.golang.model.GoWorkspace; import io.github.guacsec.trustifyda.providers.javascript.workspace.JsWorkspaceDiscovery; import io.github.guacsec.trustifyda.providers.rust.model.CargoMetadata; import io.github.guacsec.trustifyda.tools.Ecosystem; @@ -986,6 +986,7 @@ private List discoverGoWorkspaceModules(Path workspaceDir, Set ign return Collections.emptyList(); } } + /** * Discovers Maven multi-module workspace manifests by invoking {@code mvn help:evaluate} to list * declared modules, then recursively checking each module for nested aggregators. diff --git a/src/test/java/io/github/guacsec/trustifyda/impl/MavenWorkspaceDiscoveryTest.java b/src/test/java/io/github/guacsec/trustifyda/impl/MavenWorkspaceDiscoveryTest.java index 91280bd0..62e1d0cc 100644 --- a/src/test/java/io/github/guacsec/trustifyda/impl/MavenWorkspaceDiscoveryTest.java +++ b/src/test/java/io/github/guacsec/trustifyda/impl/MavenWorkspaceDiscoveryTest.java @@ -113,7 +113,7 @@ void discoverWorkspaceManifests_mavenMultiModule() throws IOException { // Mock Maven binary resolution mockOps.when(() -> Operations.getWrapperPreference("mvn")).thenReturn(false); mockOps.when(() -> Operations.isWindows()).thenReturn(false); - mockOps.when(() -> Operations.getCustomPathOrElse("mvn")).thenReturn("mvn"); + mockOps.when(() -> Operations.getExecutable("mvn", "-v")).thenReturn("mvn"); // Mock mvn help:evaluate for root pom -> returns [module-a, module-b] mockOps @@ -162,7 +162,7 @@ void discoverWorkspaceManifests_nestedAggregator() throws IOException { try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { mockOps.when(() -> Operations.getWrapperPreference("mvn")).thenReturn(false); mockOps.when(() -> Operations.isWindows()).thenReturn(false); - mockOps.when(() -> Operations.getCustomPathOrElse("mvn")).thenReturn("mvn"); + mockOps.when(() -> Operations.getExecutable("mvn", "-v")).thenReturn("mvn"); // Root -> [parent] mockOps @@ -228,7 +228,7 @@ void discoverWorkspaceManifests_noModules() throws IOException { try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { mockOps.when(() -> Operations.getWrapperPreference("mvn")).thenReturn(false); mockOps.when(() -> Operations.isWindows()).thenReturn(false); - mockOps.when(() -> Operations.getCustomPathOrElse("mvn")).thenReturn("mvn"); + mockOps.when(() -> Operations.getExecutable("mvn", "-v")).thenReturn("mvn"); // Root -> null (no modules) mockOps @@ -257,7 +257,7 @@ void discoverWorkspaceManifests_missingModuleDirectory() throws IOException { try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { mockOps.when(() -> Operations.getWrapperPreference("mvn")).thenReturn(false); mockOps.when(() -> Operations.isWindows()).thenReturn(false); - mockOps.when(() -> Operations.getCustomPathOrElse("mvn")).thenReturn("mvn"); + mockOps.when(() -> Operations.getExecutable("mvn", "-v")).thenReturn("mvn"); // Root -> [module-a, module-missing] mockOps @@ -298,7 +298,7 @@ void discoverWorkspaceManifests_mvnCommandFails() throws IOException { try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { mockOps.when(() -> Operations.getWrapperPreference("mvn")).thenReturn(false); mockOps.when(() -> Operations.isWindows()).thenReturn(false); - mockOps.when(() -> Operations.getCustomPathOrElse("mvn")).thenReturn("mvn"); + mockOps.when(() -> Operations.getExecutable("mvn", "-v")).thenReturn("mvn"); // mvn command fails with non-zero exit code mockOps @@ -327,7 +327,7 @@ void discoverWorkspaceManifests_ignorePatternFiltering() throws IOException { try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { mockOps.when(() -> Operations.getWrapperPreference("mvn")).thenReturn(false); mockOps.when(() -> Operations.isWindows()).thenReturn(false); - mockOps.when(() -> Operations.getCustomPathOrElse("mvn")).thenReturn("mvn"); + mockOps.when(() -> Operations.getExecutable("mvn", "-v")).thenReturn("mvn"); // Root -> [module-a, module-b] mockOps @@ -377,7 +377,7 @@ void defaultIgnorePatterns_includesTarget() throws IOException { try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { mockOps.when(() -> Operations.getWrapperPreference("mvn")).thenReturn(false); mockOps.when(() -> Operations.isWindows()).thenReturn(false); - mockOps.when(() -> Operations.getCustomPathOrElse("mvn")).thenReturn("mvn"); + mockOps.when(() -> Operations.getExecutable("mvn", "-v")).thenReturn("mvn"); // Root -> [module-a] mockOps