Skip to content

Commit 74fa2d9

Browse files
Strum355claude
andauthored
feat(workspace): add Maven multi-module workspace discovery (#443)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c1ef6fd commit 74fa2d9

12 files changed

Lines changed: 646 additions & 22 deletions

File tree

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

Lines changed: 135 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.golang.model.GoWorkspace;
3435
import io.github.guacsec.trustifyda.providers.javascript.workspace.JsWorkspaceDiscovery;
3536
import io.github.guacsec.trustifyda.providers.rust.model.CargoMetadata;
@@ -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,15 @@ 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+
List<Path> mavenManifests = discoverMavenModules(workspaceDir, ignorePatterns);
893+
if (!mavenManifests.isEmpty()) {
894+
return mavenManifests;
895+
}
896+
}
897+
887898
// JS workspace: require package.json + a lock file
888899
Path packageJson = workspaceDir.resolve("package.json");
889900
boolean hasJsLock =
@@ -976,6 +987,128 @@ private List<Path> discoverGoWorkspaceModules(Path workspaceDir, Set<String> ign
976987
}
977988
}
978989

990+
/**
991+
* Discovers Maven multi-module workspace manifests by invoking {@code mvn help:evaluate} to list
992+
* declared modules, then recursively checking each module for nested aggregators.
993+
*
994+
* <p>Uses {@link JavaMavenProvider#resolveVerifiedMavenBinary} for Maven binary resolution,
995+
* including wrapper support. Always includes the root {@code pom.xml}. Uses a visited set to
996+
* prevent cycles in the module graph.
997+
*
998+
* @param workspaceDir the root directory containing the aggregator pom.xml
999+
* @param ignorePatterns glob patterns for paths to exclude from results
1000+
* @return list of discovered pom.xml paths; always includes the root pom.xml even if Maven is
1001+
* unavailable
1002+
*/
1003+
private List<Path> discoverMavenModules(Path workspaceDir, Set<String> ignorePatterns) {
1004+
Path rootPom = workspaceDir.resolve("pom.xml");
1005+
String mvnBin = JavaMavenProvider.resolveVerifiedMavenBinary(workspaceDir).orElse(null);
1006+
if (mvnBin == null) {
1007+
LOG.warning("Maven binary not available; returning root pom.xml only");
1008+
return List.of(rootPom);
1009+
}
1010+
1011+
var visited = new java.util.HashSet<Path>();
1012+
var manifestPaths = new ArrayList<Path>();
1013+
manifestPaths.add(rootPom);
1014+
1015+
collectMavenModules(workspaceDir, mvnBin, visited, manifestPaths);
1016+
1017+
return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns);
1018+
}
1019+
1020+
/**
1021+
* Recursively collects Maven module pom.xml paths by invoking {@code mvn help:evaluate} on each
1022+
* directory and descending into sub-modules.
1023+
*
1024+
* @param dir the directory to evaluate for modules
1025+
* @param mvnBin the resolved Maven binary path
1026+
* @param visited set of already-visited directories to prevent cycles
1027+
* @param manifestPaths accumulator list for discovered pom.xml paths
1028+
*/
1029+
private void collectMavenModules(
1030+
Path dir, String mvnBin, java.util.HashSet<Path> visited, List<Path> manifestPaths) {
1031+
Path resolvedDir = dir.toAbsolutePath().normalize();
1032+
if (!visited.add(resolvedDir)) {
1033+
return;
1034+
}
1035+
1036+
List<String> modules = listMavenModules(resolvedDir, mvnBin);
1037+
for (String mod : modules) {
1038+
Path moduleDir = resolvedDir.resolve(mod);
1039+
Path modulePom = moduleDir.resolve("pom.xml");
1040+
if (Files.isRegularFile(modulePom)) {
1041+
manifestPaths.add(modulePom);
1042+
collectMavenModules(moduleDir, mvnBin, visited, manifestPaths);
1043+
}
1044+
}
1045+
}
1046+
1047+
/**
1048+
* Invokes {@code mvn help:evaluate -Dexpression=project.modules} on the given directory and
1049+
* parses the output to extract module names.
1050+
*
1051+
* @param dir the directory containing a pom.xml to evaluate
1052+
* @param mvnBin the resolved Maven binary path
1053+
* @return list of module names, or empty list on failure
1054+
*/
1055+
private List<String> listMavenModules(Path dir, String mvnBin) {
1056+
try {
1057+
Path pomFile = dir.resolve("pom.xml");
1058+
Operations.ProcessExecOutput output =
1059+
Operations.runProcessGetFullOutput(
1060+
dir,
1061+
new String[] {
1062+
mvnBin,
1063+
"help:evaluate",
1064+
"-Dexpression=project.modules",
1065+
"-q",
1066+
"-DforceStdout",
1067+
"-f",
1068+
pomFile.toString(),
1069+
"--batch-mode"
1070+
},
1071+
null);
1072+
if (output.getExitCode() != 0) {
1073+
LOG.warning(
1074+
"mvn help:evaluate failed with exit code " + output.getExitCode() + " in " + dir);
1075+
return Collections.emptyList();
1076+
}
1077+
String raw = output.getOutput().trim();
1078+
if (raw.isEmpty() || "null".equals(raw)) {
1079+
return Collections.emptyList();
1080+
}
1081+
return parseMavenModuleList(raw);
1082+
} catch (Exception e) {
1083+
LOG.warning("Failed to list Maven modules in " + dir + ": " + e.getMessage());
1084+
return Collections.emptyList();
1085+
}
1086+
}
1087+
1088+
/**
1089+
* Parses the output of {@code mvn help:evaluate -Dexpression=project.modules} which returns a
1090+
* string like {@code [module-a, module-b]}.
1091+
*
1092+
* @param raw the raw output string
1093+
* @return list of module name strings
1094+
*/
1095+
static List<String> parseMavenModuleList(String raw) {
1096+
if (raw == null || raw.isEmpty()) {
1097+
return Collections.emptyList();
1098+
}
1099+
// Expected format: [module-a, module-b, ...]
1100+
java.util.regex.Matcher matcher =
1101+
java.util.regex.Pattern.compile("^\\[(.+)]$").matcher(raw.trim());
1102+
if (!matcher.matches()) {
1103+
return Collections.emptyList();
1104+
}
1105+
String inner = matcher.group(1);
1106+
return java.util.Arrays.stream(inner.split(","))
1107+
.map(String::trim)
1108+
.filter(s -> !s.isEmpty())
1109+
.toList();
1110+
}
1111+
9791112
/**
9801113
* Checks whether a package.json has "private": true, meaning it should not be analyzed as a
9811114
* publishable package.

src/main/java/io/github/guacsec/trustifyda/providers/JavaMavenProvider.java

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
import java.util.List;
4040
import java.util.Map;
4141
import java.util.Objects;
42-
import java.util.logging.Level;
42+
import java.util.Optional;
4343
import java.util.logging.Logger;
4444
import java.util.stream.Collectors;
4545
import javax.xml.stream.XMLInputFactory;
@@ -431,35 +431,45 @@ private List<String> buildMvnCommandArgs(String... baseArgs) {
431431
}
432432

433433
private String selectMvnRuntime(final Path manifestPath) {
434-
boolean preferWrapper = Operations.getWrapperPreference(MVN);
435-
if (preferWrapper && manifestPath != null) {
436-
String wrapperName = Operations.isWindows() ? "mvnw.cmd" : "mvnw";
437-
String mvnw = traverseForMvnw(wrapperName, manifestPath.toString());
438-
if (mvnw != null) {
439-
try {
440-
// verify maven wrapper is accessible
441-
Operations.runProcess(manifestPath.getParent(), mvnw, ARG_VERSION);
442-
if (debugLoggingIsNeeded()) {
443-
log.info(String.format("using maven wrapper from : %s", mvnw));
444-
}
445-
return mvnw;
446-
} catch (Exception e) {
447-
log.log(
448-
Level.WARNING,
449-
"Failed to check for mvnw due to: {0} Fall back to use mvn",
450-
e.getMessage());
434+
Path dir = (manifestPath != null) ? manifestPath.getParent() : null;
435+
if (dir != null) {
436+
Optional<String> resolved = resolveVerifiedMavenBinary(dir);
437+
if (resolved.isPresent()) {
438+
if (debugLoggingIsNeeded()) {
439+
log.info(String.format("using maven binary: %s", resolved.get()));
451440
}
441+
return resolved.get();
452442
}
453443
}
454-
// If maven wrapper is not requested or not accessible, fall back to use mvn
455444
String mvn = Operations.getExecutable(MVN, ARG_VERSION);
456445
if (debugLoggingIsNeeded()) {
457446
log.info(String.format("using mvn executable from : %s", mvn));
458447
}
459448
return mvn;
460449
}
461450

462-
private String traverseForMvnw(String wrapperName, String startingManifest) {
451+
public static Optional<String> resolveVerifiedMavenBinary(Path workspaceDir) {
452+
if (Operations.getWrapperPreference(MVN)) {
453+
String wrapperName = Operations.isWindows() ? "mvnw.cmd" : "mvnw";
454+
String mvnw = traverseForMvnw(wrapperName, workspaceDir.resolve("pom.xml").toString());
455+
if (mvnw != null) {
456+
try {
457+
Operations.runProcess(workspaceDir, mvnw, ARG_VERSION);
458+
return Optional.of(mvnw);
459+
} catch (Exception e) {
460+
// wrapper found but not functional — fall through to system mvn
461+
}
462+
}
463+
}
464+
try {
465+
String mvn = Operations.getExecutable(MVN, ARG_VERSION);
466+
return Optional.of(mvn);
467+
} catch (RuntimeException e) {
468+
return Optional.empty();
469+
}
470+
}
471+
472+
private static String traverseForMvnw(String wrapperName, String startingManifest) {
463473
return traverseForMvnw(wrapperName, startingManifest, null);
464474
}
465475

0 commit comments

Comments
 (0)