Skip to content

Commit bbeb043

Browse files
Strum355claude
andauthored
feat(workspace): add Gradle multi-project workspace discovery (guacsec#444)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ae11832 commit bbeb043

21 files changed

Lines changed: 609 additions & 1 deletion

File tree

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import io.github.guacsec.trustifyda.logging.LoggersFactory;
3333
import io.github.guacsec.trustifyda.providers.JavaMavenProvider;
3434
import io.github.guacsec.trustifyda.providers.golang.model.GoWorkspace;
35+
import io.github.guacsec.trustifyda.providers.gradle.workspace.GradleWorkspaceDiscovery;
3536
import io.github.guacsec.trustifyda.providers.javascript.workspace.JsWorkspaceDiscovery;
3637
import io.github.guacsec.trustifyda.providers.rust.model.CargoMetadata;
3738
import io.github.guacsec.trustifyda.tools.Ecosystem;
@@ -852,7 +853,13 @@ int resolveBatchConcurrency() {
852853

853854
private static final Set<String> DEFAULT_WORKSPACE_DISCOVERY_IGNORE =
854855
Set.of(
855-
"**/node_modules/**", "**/.git/**", "**/target/**", "**/__pycache__/**", "**/.venv/**");
856+
"**/node_modules/**",
857+
"**/.git/**",
858+
"**/target/**",
859+
"**/__pycache__/**",
860+
"**/.venv/**",
861+
"**/build/**",
862+
"**/.gradle/**");
856863

857864
/** Merges default ignore patterns, env var overrides, and caller-provided patterns. */
858865
Set<String> resolveIgnorePatterns(Set<String> callerPatterns) {
@@ -912,6 +919,14 @@ List<Path> discoverWorkspaceManifests(Path workspaceDir, Set<String> ignorePatte
912919
}
913920
}
914921

922+
// Gradle multi-project: settings.gradle or settings.gradle.kts
923+
boolean hasGradleSettings =
924+
Files.isRegularFile(workspaceDir.resolve("settings.gradle"))
925+
|| Files.isRegularFile(workspaceDir.resolve("settings.gradle.kts"));
926+
if (hasGradleSettings) {
927+
return GradleWorkspaceDiscovery.discoverSubprojects(workspaceDir, ignorePatterns);
928+
}
929+
915930
// JS workspace: require package.json + a lock file
916931
Path packageJson = workspaceDir.resolve("package.json");
917932
boolean hasJsLock =
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/*
2+
* Copyright 2023-2025 Trustify Dependency Analytics Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
*
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package io.github.guacsec.trustifyda.providers.gradle.workspace;
18+
19+
import io.github.guacsec.trustifyda.logging.LoggersFactory;
20+
import io.github.guacsec.trustifyda.providers.JavaMavenProvider;
21+
import io.github.guacsec.trustifyda.tools.Operations;
22+
import io.github.guacsec.trustifyda.utils.WorkspaceUtils;
23+
import java.io.IOException;
24+
import java.nio.file.Files;
25+
import java.nio.file.Path;
26+
import java.util.ArrayList;
27+
import java.util.List;
28+
import java.util.Set;
29+
import java.util.logging.Level;
30+
import java.util.logging.Logger;
31+
32+
/** Discovers Gradle multi-project build manifest paths using a custom init script. */
33+
public final class GradleWorkspaceDiscovery {
34+
35+
private static final Logger LOG =
36+
LoggersFactory.getLogger(GradleWorkspaceDiscovery.class.getName());
37+
38+
private static final String GRADLE_INIT_SCRIPT =
39+
"allprojects {\n"
40+
+ " task daListProjects {\n"
41+
+ " doLast {\n"
42+
+ " println \"::DA_PROJECT::${project.path}::${project.projectDir}\"\n"
43+
+ " }\n"
44+
+ " }\n"
45+
+ "}\n";
46+
47+
private GradleWorkspaceDiscovery() {}
48+
49+
public static List<Path> discoverSubprojects(Path workspaceDir, Set<String> ignorePatterns) {
50+
Path rootBuildKts = workspaceDir.resolve("build.gradle.kts");
51+
Path rootBuild = workspaceDir.resolve("build.gradle");
52+
53+
List<Path> manifestPaths = new ArrayList<>();
54+
if (Files.isRegularFile(rootBuildKts)) {
55+
manifestPaths.add(rootBuildKts);
56+
} else if (Files.isRegularFile(rootBuild)) {
57+
manifestPaths.add(rootBuild);
58+
}
59+
60+
String gradleBin = resolveGradleBinary(workspaceDir);
61+
Path initScriptPath = null;
62+
try {
63+
initScriptPath = Files.createTempFile("da-list-projects-", ".gradle");
64+
Files.writeString(initScriptPath, GRADLE_INIT_SCRIPT);
65+
66+
Operations.ProcessExecOutput output =
67+
Operations.runProcessGetFullOutput(
68+
workspaceDir,
69+
new String[] {
70+
gradleBin,
71+
"-q",
72+
"--no-daemon",
73+
"--init-script",
74+
initScriptPath.toString(),
75+
"daListProjects"
76+
},
77+
null);
78+
79+
if (output.getExitCode() != 0) {
80+
LOG.warning(
81+
"gradle daListProjects failed with exit code "
82+
+ output.getExitCode()
83+
+ ": "
84+
+ output.getError());
85+
return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns);
86+
}
87+
88+
for (var proj : parseGradleInitScriptOutput(output.getOutput())) {
89+
if (":".equals(proj.path())) {
90+
continue;
91+
}
92+
Path projDir = Path.of(proj.dir()).toAbsolutePath().normalize();
93+
Path buildKts = projDir.resolve("build.gradle.kts");
94+
Path buildGroovy = projDir.resolve("build.gradle");
95+
if (Files.isRegularFile(buildKts)) {
96+
manifestPaths.add(buildKts);
97+
} else if (Files.isRegularFile(buildGroovy)) {
98+
manifestPaths.add(buildGroovy);
99+
}
100+
}
101+
} catch (Exception e) {
102+
LOG.log(Level.WARNING, "Failed to discover Gradle subprojects", e);
103+
return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns);
104+
} finally {
105+
if (initScriptPath != null) {
106+
try {
107+
Files.deleteIfExists(initScriptPath);
108+
} catch (IOException ignored) {
109+
}
110+
}
111+
}
112+
113+
return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns);
114+
}
115+
116+
static String resolveGradleBinary(Path startDir) {
117+
if (Operations.getWrapperPreference("gradle")) {
118+
String wrapperName = Operations.isWindows() ? "gradlew.bat" : "gradlew";
119+
String wrapper =
120+
JavaMavenProvider.traverseForMvnw(
121+
wrapperName, startDir.resolve("build.gradle").toString(), null);
122+
if (wrapper != null) {
123+
return wrapper;
124+
}
125+
}
126+
return Operations.getCustomPathOrElse("gradle");
127+
}
128+
129+
record GradleProject(String path, String dir) {}
130+
131+
static List<GradleProject> parseGradleInitScriptOutput(String raw) {
132+
if (raw == null || raw.isBlank()) {
133+
return List.of();
134+
}
135+
String prefix = "::DA_PROJECT::";
136+
List<GradleProject> projects = new ArrayList<>();
137+
for (String line : raw.lines().toList()) {
138+
if (!line.startsWith(prefix)) {
139+
continue;
140+
}
141+
String remainder = line.substring(prefix.length());
142+
int lastSep = remainder.lastIndexOf("::");
143+
if (lastSep < 0) {
144+
continue;
145+
}
146+
String path = remainder.substring(0, lastSep);
147+
String dir = remainder.substring(lastSep + 2);
148+
if (!path.isEmpty() && !dir.isEmpty()) {
149+
projects.add(new GradleProject(path, dir));
150+
}
151+
}
152+
return projects;
153+
}
154+
}

src/main/java/module-info.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
exports io.github.guacsec.trustifyda.providers;
3737
exports io.github.guacsec.trustifyda.providers.javascript.model;
38+
exports io.github.guacsec.trustifyda.providers.gradle.workspace;
3839
exports io.github.guacsec.trustifyda.providers.javascript.workspace;
3940
exports io.github.guacsec.trustifyda.providers.rust.model;
4041
exports io.github.guacsec.trustifyda.providers.golang.model;

0 commit comments

Comments
 (0)