Skip to content

Commit eaae875

Browse files
Strum355claude
andcommitted
feat(workspace): add Gradle multi-project workspace discovery
Discover subprojects in Gradle multi-project builds using a custom init script that emits structured project listings. Supports Groovy and Kotlin DSL variants, gradlew wrapper detection, and workspaceDiscoveryIgnore filtering. Adds **/build/** and **/.gradle/** to default ignore patterns. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2786440 commit eaae875

19 files changed

Lines changed: 485 additions & 1 deletion

File tree

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

Lines changed: 130 additions & 1 deletion
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/**", "**/build/**", "**/.gradle/**");
846847

847848
/** Merges default ignore patterns, env var overrides, and caller-provided patterns. */
848849
Set<String> resolveIgnorePatterns(Set<String> callerPatterns) {
@@ -875,6 +876,14 @@ List<Path> discoverWorkspaceManifests(Path workspaceDir, Set<String> ignorePatte
875876
return discoverCargoManifests(workspaceDir, ignorePatterns);
876877
}
877878

879+
// Gradle multi-project: settings.gradle or settings.gradle.kts
880+
boolean hasGradleSettings =
881+
Files.isRegularFile(workspaceDir.resolve("settings.gradle"))
882+
|| Files.isRegularFile(workspaceDir.resolve("settings.gradle.kts"));
883+
if (hasGradleSettings) {
884+
return discoverGradleSubprojects(workspaceDir, ignorePatterns);
885+
}
886+
878887
// JS workspace: require package.json + a lock file
879888
Path packageJson = workspaceDir.resolve("package.json");
880889
boolean hasJsLock =
@@ -930,6 +939,126 @@ private List<Path> discoverCargoManifests(Path workspaceDir, Set<String> ignoreP
930939
}
931940
}
932941

942+
private static final String GRADLE_INIT_SCRIPT =
943+
"allprojects {\n"
944+
+ " task daListProjects {\n"
945+
+ " doLast {\n"
946+
+ " println \"::DA_PROJECT::${project.path}::${project.projectDir}\"\n"
947+
+ " }\n"
948+
+ " }\n"
949+
+ "}\n";
950+
951+
/**
952+
* Resolve the Gradle binary, preferring gradlew wrapper when available and configured.
953+
*
954+
* @param startDir directory from which to start the wrapper search
955+
* @return path to the Gradle binary
956+
*/
957+
private static String resolveGradleBinary(Path startDir) {
958+
if (Operations.getWrapperPreference("gradle")) {
959+
String wrapperName = Operations.isWindows() ? "gradlew.bat" : "gradlew";
960+
String wrapper =
961+
JavaMavenProvider.traverseForMvnw(
962+
wrapperName, startDir.resolve("build.gradle").toString());
963+
if (wrapper != null) {
964+
return wrapper;
965+
}
966+
}
967+
return Operations.getCustomPathOrElse("gradle");
968+
}
969+
970+
/**
971+
* Discover all build.gradle[.kts] manifest paths in a Gradle multi-project build. Uses a custom
972+
* init script to get a structured project listing.
973+
*/
974+
private List<Path> discoverGradleSubprojects(Path workspaceDir, Set<String> ignorePatterns) {
975+
Path rootBuildKts = workspaceDir.resolve("build.gradle.kts");
976+
Path rootBuild = workspaceDir.resolve("build.gradle");
977+
978+
List<Path> manifestPaths = new ArrayList<>();
979+
if (Files.isRegularFile(rootBuildKts)) {
980+
manifestPaths.add(rootBuildKts);
981+
} else if (Files.isRegularFile(rootBuild)) {
982+
manifestPaths.add(rootBuild);
983+
}
984+
985+
String gradleBin = resolveGradleBinary(workspaceDir);
986+
Path initScriptPath = null;
987+
try {
988+
initScriptPath = Files.createTempFile("da-list-projects-", ".gradle");
989+
Files.writeString(initScriptPath, GRADLE_INIT_SCRIPT);
990+
991+
Operations.ProcessExecOutput output =
992+
Operations.runProcessGetFullOutput(
993+
workspaceDir,
994+
new String[] {
995+
gradleBin,
996+
"-q",
997+
"--no-daemon",
998+
"--init-script",
999+
initScriptPath.toString(),
1000+
"daListProjects"
1001+
},
1002+
null);
1003+
1004+
if (output.getExitCode() != 0) {
1005+
LOG.warning("gradle daListProjects failed with exit code " + output.getExitCode());
1006+
return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns);
1007+
}
1008+
1009+
for (var proj : parseGradleInitScriptOutput(output.getOutput())) {
1010+
if (":".equals(proj.path())) {
1011+
continue;
1012+
}
1013+
Path projDir = Path.of(proj.dir()).toAbsolutePath().normalize();
1014+
Path buildKts = projDir.resolve("build.gradle.kts");
1015+
Path buildGroovy = projDir.resolve("build.gradle");
1016+
if (Files.isRegularFile(buildKts)) {
1017+
manifestPaths.add(buildKts);
1018+
} else if (Files.isRegularFile(buildGroovy)) {
1019+
manifestPaths.add(buildGroovy);
1020+
}
1021+
}
1022+
} catch (Exception e) {
1023+
LOG.warning("Failed to discover Gradle subprojects: " + e.getMessage());
1024+
return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns);
1025+
} finally {
1026+
if (initScriptPath != null) {
1027+
try {
1028+
Files.deleteIfExists(initScriptPath);
1029+
} catch (IOException ignored) {
1030+
}
1031+
}
1032+
}
1033+
1034+
return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns);
1035+
}
1036+
1037+
record GradleProject(String path, String dir) {}
1038+
1039+
static List<GradleProject> parseGradleInitScriptOutput(String raw) {
1040+
if (raw == null || raw.isBlank()) {
1041+
return List.of();
1042+
}
1043+
List<GradleProject> projects = new ArrayList<>();
1044+
for (String line : raw.split("\n")) {
1045+
if (!line.startsWith("::DA_PROJECT::")) {
1046+
continue;
1047+
}
1048+
String[] parts = line.split("::");
1049+
List<String> nonEmpty = new ArrayList<>();
1050+
for (String part : parts) {
1051+
if (!part.isEmpty()) {
1052+
nonEmpty.add(part);
1053+
}
1054+
}
1055+
if (nonEmpty.size() >= 3) {
1056+
projects.add(new GradleProject(nonEmpty.get(1), nonEmpty.get(2)));
1057+
}
1058+
}
1059+
return projects;
1060+
}
1061+
9331062
/**
9341063
* Checks whether a package.json has "private": true, meaning it should not be analyzed as a
9351064
* publishable package.

0 commit comments

Comments
 (0)