Skip to content

Commit b32dc96

Browse files
Strum355claude
andcommitted
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 <noreply@anthropic.com> Assisted-by: Claude Code
1 parent 55e88d9 commit b32dc96

11 files changed

Lines changed: 639 additions & 2 deletions

File tree

src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java

Lines changed: 158 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import io.github.guacsec.trustifyda.image.ImageUtils;
3131
import io.github.guacsec.trustifyda.license.LicenseCheck;
3232
import io.github.guacsec.trustifyda.logging.LoggersFactory;
33+
import io.github.guacsec.trustifyda.providers.JavaMavenProvider;
3334
import io.github.guacsec.trustifyda.providers.javascript.workspace.JsWorkspaceDiscovery;
3435
import io.github.guacsec.trustifyda.providers.rust.model.CargoMetadata;
3536
import io.github.guacsec.trustifyda.tools.Ecosystem;
@@ -842,7 +843,7 @@ int resolveBatchConcurrency() {
842843
}
843844

844845
private static final Set<String> DEFAULT_WORKSPACE_DISCOVERY_IGNORE =
845-
Set.of("**/node_modules/**", "**/.git/**");
846+
Set.of("**/node_modules/**", "**/.git/**", "**/target/**");
846847

847848
/** Merges default ignore patterns, env var overrides, and caller-provided patterns. */
848849
Set<String> resolveIgnorePatterns(Set<String> callerPatterns) {
@@ -864,7 +865,8 @@ Set<String> resolveIgnorePatterns(Set<String> callerPatterns) {
864865

865866
/**
866867
* Detects the workspace ecosystem and discovers manifest paths. Checks for Cargo workspace first
867-
* (Cargo.toml + Cargo.lock), then falls back to JS workspace discovery.
868+
* (Cargo.toml + Cargo.lock), then Maven multi-module (pom.xml), then falls back to JS workspace
869+
* discovery.
868870
*/
869871
List<Path> discoverWorkspaceManifests(Path workspaceDir, Set<String> ignorePatterns)
870872
throws IOException {
@@ -875,6 +877,12 @@ List<Path> discoverWorkspaceManifests(Path workspaceDir, Set<String> ignorePatte
875877
return discoverCargoManifests(workspaceDir, ignorePatterns);
876878
}
877879

880+
// Maven multi-module: pom.xml
881+
Path pomXml = workspaceDir.resolve("pom.xml");
882+
if (Files.isRegularFile(pomXml)) {
883+
return discoverMavenModules(workspaceDir, ignorePatterns);
884+
}
885+
878886
// JS workspace: require package.json + a lock file
879887
Path packageJson = workspaceDir.resolve("package.json");
880888
boolean hasJsLock =
@@ -930,6 +938,154 @@ private List<Path> discoverCargoManifests(Path workspaceDir, Set<String> ignoreP
930938
}
931939
}
932940

941+
/**
942+
* Discovers Maven multi-module workspace manifests by invoking {@code mvn help:evaluate} to list
943+
* declared modules, then recursively checking each module for nested aggregators.
944+
*
945+
* <p>Uses the same Maven binary selection as {@link
946+
* io.github.guacsec.trustifyda.providers.JavaMavenProvider}, including wrapper support via {@code
947+
* selectMvnRuntime}. Always includes the root {@code pom.xml}. Uses a visited set to prevent
948+
* cycles in the module graph.
949+
*
950+
* @param workspaceDir the root directory containing the aggregator pom.xml
951+
* @param ignorePatterns glob patterns for paths to exclude from results
952+
* @return list of discovered pom.xml paths, or empty list if Maven is unavailable
953+
*/
954+
private List<Path> discoverMavenModules(Path workspaceDir, Set<String> ignorePatterns) {
955+
Path rootPom = workspaceDir.resolve("pom.xml");
956+
String mvnBin = resolveMavenBinary(workspaceDir);
957+
if (mvnBin == null) {
958+
LOG.warning("Maven binary not available; returning root pom.xml only");
959+
return List.of(rootPom);
960+
}
961+
962+
var visited = new java.util.HashSet<Path>();
963+
var manifestPaths = new ArrayList<Path>();
964+
manifestPaths.add(rootPom);
965+
966+
collectMavenModules(workspaceDir, mvnBin, visited, manifestPaths);
967+
968+
return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns);
969+
}
970+
971+
/**
972+
* Recursively collects Maven module pom.xml paths by invoking {@code mvn help:evaluate} on each
973+
* directory and descending into sub-modules.
974+
*
975+
* @param dir the directory to evaluate for modules
976+
* @param mvnBin the resolved Maven binary path
977+
* @param visited set of already-visited directories to prevent cycles
978+
* @param manifestPaths accumulator list for discovered pom.xml paths
979+
*/
980+
private void collectMavenModules(
981+
Path dir, String mvnBin, java.util.HashSet<Path> visited, List<Path> manifestPaths) {
982+
Path resolvedDir = dir.toAbsolutePath().normalize();
983+
if (!visited.add(resolvedDir)) {
984+
return;
985+
}
986+
987+
List<String> modules = listMavenModules(resolvedDir, mvnBin);
988+
for (String mod : modules) {
989+
Path moduleDir = resolvedDir.resolve(mod);
990+
Path modulePom = moduleDir.resolve("pom.xml");
991+
if (Files.isRegularFile(modulePom)) {
992+
manifestPaths.add(modulePom);
993+
collectMavenModules(moduleDir, mvnBin, visited, manifestPaths);
994+
}
995+
}
996+
}
997+
998+
/**
999+
* Invokes {@code mvn help:evaluate -Dexpression=project.modules} on the given directory and
1000+
* parses the output to extract module names.
1001+
*
1002+
* @param dir the directory containing a pom.xml to evaluate
1003+
* @param mvnBin the resolved Maven binary path
1004+
* @return list of module names, or empty list on failure
1005+
*/
1006+
private List<String> listMavenModules(Path dir, String mvnBin) {
1007+
try {
1008+
Path pomFile = dir.resolve("pom.xml");
1009+
Operations.ProcessExecOutput output =
1010+
Operations.runProcessGetFullOutput(
1011+
dir,
1012+
new String[] {
1013+
mvnBin,
1014+
"help:evaluate",
1015+
"-Dexpression=project.modules",
1016+
"-q",
1017+
"-DforceStdout",
1018+
"-f",
1019+
pomFile.toString(),
1020+
"--batch-mode"
1021+
},
1022+
null);
1023+
if (output.getExitCode() != 0) {
1024+
LOG.warning(
1025+
"mvn help:evaluate failed with exit code " + output.getExitCode() + " in " + dir);
1026+
return Collections.emptyList();
1027+
}
1028+
String raw = output.getOutput().trim();
1029+
if (raw.isEmpty() || "null".equals(raw)) {
1030+
return Collections.emptyList();
1031+
}
1032+
return parseMavenModuleList(raw);
1033+
} catch (Exception e) {
1034+
LOG.warning("Failed to list Maven modules in " + dir + ": " + e.getMessage());
1035+
return Collections.emptyList();
1036+
}
1037+
}
1038+
1039+
/**
1040+
* Parses the output of {@code mvn help:evaluate -Dexpression=project.modules} which returns a
1041+
* string like {@code [module-a, module-b]}.
1042+
*
1043+
* @param raw the raw output string
1044+
* @return list of module name strings
1045+
*/
1046+
static List<String> parseMavenModuleList(String raw) {
1047+
if (raw == null || raw.isEmpty()) {
1048+
return Collections.emptyList();
1049+
}
1050+
// Expected format: [module-a, module-b, ...]
1051+
java.util.regex.Matcher matcher =
1052+
java.util.regex.Pattern.compile("^\\[(.+)]$").matcher(raw.trim());
1053+
if (!matcher.matches()) {
1054+
return Collections.emptyList();
1055+
}
1056+
String inner = matcher.group(1);
1057+
return java.util.Arrays.stream(inner.split(","))
1058+
.map(String::trim)
1059+
.filter(s -> !s.isEmpty())
1060+
.toList();
1061+
}
1062+
1063+
/**
1064+
* Resolves the Maven binary to use, following the same wrapper preference logic as {@link
1065+
* io.github.guacsec.trustifyda.providers.JavaMavenProvider#selectMvnRuntime}.
1066+
*
1067+
* @param startDir the directory from which to start searching for mvnw
1068+
* @return the resolved Maven binary path, or null if Maven is not available
1069+
*/
1070+
private String resolveMavenBinary(Path startDir) {
1071+
try {
1072+
boolean preferWrapper = Operations.getWrapperPreference("mvn");
1073+
String mvn = Operations.isWindows() ? "mvn.cmd" : "mvn";
1074+
if (preferWrapper) {
1075+
String wrapperName = Operations.isWindows() ? "mvnw.cmd" : "mvnw";
1076+
String mvnw =
1077+
JavaMavenProvider.traverseForMvnw(wrapperName, startDir.resolve("pom.xml").toString());
1078+
if (mvnw != null) {
1079+
return mvnw;
1080+
}
1081+
}
1082+
return Operations.getCustomPathOrElse(mvn);
1083+
} catch (Exception e) {
1084+
LOG.warning("Failed to resolve Maven binary: " + e.getMessage());
1085+
return null;
1086+
}
1087+
}
1088+
9331089
/**
9341090
* Checks whether a package.json has "private": true, meaning it should not be analyzed as a
9351091
* publishable package.

0 commit comments

Comments
 (0)