Skip to content

Commit 3f0af07

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 c1ef6fd commit 3f0af07

11 files changed

Lines changed: 638 additions & 2 deletions

File tree

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

Lines changed: 157 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import io.github.guacsec.trustifyda.license.LicenseCheck;
3232
import io.github.guacsec.trustifyda.logging.LoggersFactory;
3333
import io.github.guacsec.trustifyda.providers.golang.model.GoWorkspace;
34+
import io.github.guacsec.trustifyda.providers.JavaMavenProvider;
3435
import io.github.guacsec.trustifyda.providers.javascript.workspace.JsWorkspaceDiscovery;
3536
import io.github.guacsec.trustifyda.providers.rust.model.CargoMetadata;
3637
import io.github.guacsec.trustifyda.tools.Ecosystem;
@@ -843,7 +844,7 @@ int resolveBatchConcurrency() {
843844
}
844845

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

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

866867
/**
867868
* Detects the workspace ecosystem and discovers manifest paths. Checks for Cargo workspace first
868-
* (Cargo.toml + Cargo.lock), then falls back to JS workspace discovery.
869+
* (Cargo.toml + Cargo.lock), then Maven multi-module (pom.xml), then falls back to JS workspace
870+
* discovery.
869871
*/
870872
List<Path> discoverWorkspaceManifests(Path workspaceDir, Set<String> ignorePatterns)
871873
throws IOException {
@@ -884,6 +886,12 @@ List<Path> discoverWorkspaceManifests(Path workspaceDir, Set<String> ignorePatte
884886
}
885887
}
886888

889+
// Maven multi-module: pom.xml
890+
Path pomXml = workspaceDir.resolve("pom.xml");
891+
if (Files.isRegularFile(pomXml)) {
892+
return discoverMavenModules(workspaceDir, ignorePatterns);
893+
}
894+
887895
// JS workspace: require package.json + a lock file
888896
Path packageJson = workspaceDir.resolve("package.json");
889897
boolean hasJsLock =
@@ -975,6 +983,153 @@ private List<Path> discoverGoWorkspaceModules(Path workspaceDir, Set<String> ign
975983
return Collections.emptyList();
976984
}
977985
}
986+
/**
987+
* Discovers Maven multi-module workspace manifests by invoking {@code mvn help:evaluate} to list
988+
* declared modules, then recursively checking each module for nested aggregators.
989+
*
990+
* <p>Uses the same Maven binary selection as {@link
991+
* io.github.guacsec.trustifyda.providers.JavaMavenProvider}, including wrapper support via {@code
992+
* selectMvnRuntime}. Always includes the root {@code pom.xml}. Uses a visited set to prevent
993+
* cycles in the module graph.
994+
*
995+
* @param workspaceDir the root directory containing the aggregator pom.xml
996+
* @param ignorePatterns glob patterns for paths to exclude from results
997+
* @return list of discovered pom.xml paths, or empty list if Maven is unavailable
998+
*/
999+
private List<Path> discoverMavenModules(Path workspaceDir, Set<String> ignorePatterns) {
1000+
Path rootPom = workspaceDir.resolve("pom.xml");
1001+
String mvnBin = resolveMavenBinary(workspaceDir);
1002+
if (mvnBin == null) {
1003+
LOG.warning("Maven binary not available; returning root pom.xml only");
1004+
return List.of(rootPom);
1005+
}
1006+
1007+
var visited = new java.util.HashSet<Path>();
1008+
var manifestPaths = new ArrayList<Path>();
1009+
manifestPaths.add(rootPom);
1010+
1011+
collectMavenModules(workspaceDir, mvnBin, visited, manifestPaths);
1012+
1013+
return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns);
1014+
}
1015+
1016+
/**
1017+
* Recursively collects Maven module pom.xml paths by invoking {@code mvn help:evaluate} on each
1018+
* directory and descending into sub-modules.
1019+
*
1020+
* @param dir the directory to evaluate for modules
1021+
* @param mvnBin the resolved Maven binary path
1022+
* @param visited set of already-visited directories to prevent cycles
1023+
* @param manifestPaths accumulator list for discovered pom.xml paths
1024+
*/
1025+
private void collectMavenModules(
1026+
Path dir, String mvnBin, java.util.HashSet<Path> visited, List<Path> manifestPaths) {
1027+
Path resolvedDir = dir.toAbsolutePath().normalize();
1028+
if (!visited.add(resolvedDir)) {
1029+
return;
1030+
}
1031+
1032+
List<String> modules = listMavenModules(resolvedDir, mvnBin);
1033+
for (String mod : modules) {
1034+
Path moduleDir = resolvedDir.resolve(mod);
1035+
Path modulePom = moduleDir.resolve("pom.xml");
1036+
if (Files.isRegularFile(modulePom)) {
1037+
manifestPaths.add(modulePom);
1038+
collectMavenModules(moduleDir, mvnBin, visited, manifestPaths);
1039+
}
1040+
}
1041+
}
1042+
1043+
/**
1044+
* Invokes {@code mvn help:evaluate -Dexpression=project.modules} on the given directory and
1045+
* parses the output to extract module names.
1046+
*
1047+
* @param dir the directory containing a pom.xml to evaluate
1048+
* @param mvnBin the resolved Maven binary path
1049+
* @return list of module names, or empty list on failure
1050+
*/
1051+
private List<String> listMavenModules(Path dir, String mvnBin) {
1052+
try {
1053+
Path pomFile = dir.resolve("pom.xml");
1054+
Operations.ProcessExecOutput output =
1055+
Operations.runProcessGetFullOutput(
1056+
dir,
1057+
new String[] {
1058+
mvnBin,
1059+
"help:evaluate",
1060+
"-Dexpression=project.modules",
1061+
"-q",
1062+
"-DforceStdout",
1063+
"-f",
1064+
pomFile.toString(),
1065+
"--batch-mode"
1066+
},
1067+
null);
1068+
if (output.getExitCode() != 0) {
1069+
LOG.warning(
1070+
"mvn help:evaluate failed with exit code " + output.getExitCode() + " in " + dir);
1071+
return Collections.emptyList();
1072+
}
1073+
String raw = output.getOutput().trim();
1074+
if (raw.isEmpty() || "null".equals(raw)) {
1075+
return Collections.emptyList();
1076+
}
1077+
return parseMavenModuleList(raw);
1078+
} catch (Exception e) {
1079+
LOG.warning("Failed to list Maven modules in " + dir + ": " + e.getMessage());
1080+
return Collections.emptyList();
1081+
}
1082+
}
1083+
1084+
/**
1085+
* Parses the output of {@code mvn help:evaluate -Dexpression=project.modules} which returns a
1086+
* string like {@code [module-a, module-b]}.
1087+
*
1088+
* @param raw the raw output string
1089+
* @return list of module name strings
1090+
*/
1091+
static List<String> parseMavenModuleList(String raw) {
1092+
if (raw == null || raw.isEmpty()) {
1093+
return Collections.emptyList();
1094+
}
1095+
// Expected format: [module-a, module-b, ...]
1096+
java.util.regex.Matcher matcher =
1097+
java.util.regex.Pattern.compile("^\\[(.+)]$").matcher(raw.trim());
1098+
if (!matcher.matches()) {
1099+
return Collections.emptyList();
1100+
}
1101+
String inner = matcher.group(1);
1102+
return java.util.Arrays.stream(inner.split(","))
1103+
.map(String::trim)
1104+
.filter(s -> !s.isEmpty())
1105+
.toList();
1106+
}
1107+
1108+
/**
1109+
* Resolves the Maven binary to use, following the same wrapper preference logic as {@link
1110+
* io.github.guacsec.trustifyda.providers.JavaMavenProvider#selectMvnRuntime}.
1111+
*
1112+
* @param startDir the directory from which to start searching for mvnw
1113+
* @return the resolved Maven binary path, or null if Maven is not available
1114+
*/
1115+
private String resolveMavenBinary(Path startDir) {
1116+
try {
1117+
boolean preferWrapper = Operations.getWrapperPreference("mvn");
1118+
String mvn = Operations.isWindows() ? "mvn.cmd" : "mvn";
1119+
if (preferWrapper) {
1120+
String wrapperName = Operations.isWindows() ? "mvnw.cmd" : "mvnw";
1121+
String mvnw =
1122+
JavaMavenProvider.traverseForMvnw(wrapperName, startDir.resolve("pom.xml").toString());
1123+
if (mvnw != null) {
1124+
return mvnw;
1125+
}
1126+
}
1127+
return Operations.getCustomPathOrElse(mvn);
1128+
} catch (Exception e) {
1129+
LOG.warning("Failed to resolve Maven binary: " + e.getMessage());
1130+
return null;
1131+
}
1132+
}
9781133

9791134
/**
9801135
* Checks whether a package.json has "private": true, meaning it should not be analyzed as a

0 commit comments

Comments
 (0)