Skip to content

Commit 8dfee73

Browse files
committed
Merge remote-tracking branch 'upstream/main' into TC-4359
2 parents 3ab7599 + bbeb043 commit 8dfee73

41 files changed

Lines changed: 933 additions & 1 deletion

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,13 @@
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;
3839
import io.github.guacsec.trustifyda.tools.Operations;
3940
import io.github.guacsec.trustifyda.utils.Environment;
41+
import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils;
4042
import io.github.guacsec.trustifyda.utils.WorkspaceUtils;
4143
import jakarta.mail.MessagingException;
4244
import jakarta.mail.internet.MimeMultipart;
@@ -51,8 +53,10 @@
5153
import java.net.http.HttpRequest;
5254
import java.net.http.HttpResponse;
5355
import java.nio.charset.StandardCharsets;
56+
import java.nio.file.FileSystems;
5457
import java.nio.file.Files;
5558
import java.nio.file.Path;
59+
import java.nio.file.PathMatcher;
5660
import java.time.LocalDateTime;
5761
import java.time.temporal.ChronoUnit;
5862
import java.util.AbstractMap;
@@ -68,8 +72,12 @@
6872
import java.util.concurrent.CompletionException;
6973
import java.util.function.Function;
7074
import java.util.function.Supplier;
75+
import java.util.logging.Level;
7176
import java.util.logging.Logger;
7277
import java.util.stream.Collectors;
78+
import org.tomlj.TomlArray;
79+
import org.tomlj.TomlParseResult;
80+
import org.tomlj.TomlTable;
7381

7482
/** Concrete implementation of the Exhort {@link Api} Service. */
7583
public final class ExhortApi implements Api {
@@ -844,7 +852,14 @@ int resolveBatchConcurrency() {
844852
}
845853

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

849864
/** Merges default ignore patterns, env var overrides, and caller-provided patterns. */
850865
Set<String> resolveIgnorePatterns(Set<String> callerPatterns) {
@@ -895,6 +910,23 @@ List<Path> discoverWorkspaceManifests(Path workspaceDir, Set<String> ignorePatte
895910
}
896911
}
897912

913+
// uv workspace: pyproject.toml with [tool.uv.workspace] + uv.lock
914+
if (Files.isRegularFile(workspaceDir.resolve("pyproject.toml"))
915+
&& Files.isRegularFile(workspaceDir.resolve("uv.lock"))) {
916+
List<Path> uvManifests = discoverUvWorkspaceMembers(workspaceDir, ignorePatterns);
917+
if (!uvManifests.isEmpty()) {
918+
return uvManifests;
919+
}
920+
}
921+
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+
898930
// JS workspace: require package.json + a lock file
899931
Path packageJson = workspaceDir.resolve("package.json");
900932
boolean hasJsLock =
@@ -987,6 +1019,86 @@ private List<Path> discoverGoWorkspaceModules(Path workspaceDir, Set<String> ign
9871019
}
9881020
}
9891021

1022+
/**
1023+
* Discover all pyproject.toml manifest paths in a uv workspace. Parses the root pyproject.toml
1024+
* for {@code [tool.uv.workspace]} member and exclude globs, then walks the filesystem to find
1025+
* matching member directories.
1026+
*/
1027+
private List<Path> discoverUvWorkspaceMembers(Path workspaceDir, Set<String> ignorePatterns) {
1028+
try {
1029+
Path rootPyproject = workspaceDir.resolve("pyproject.toml");
1030+
TomlParseResult toml = PyprojectTomlUtils.parseToml(rootPyproject);
1031+
1032+
TomlTable workspaceConfig = toml.getTable("tool.uv.workspace");
1033+
if (workspaceConfig == null) {
1034+
return Collections.emptyList();
1035+
}
1036+
1037+
TomlArray membersArray = workspaceConfig.getArray("members");
1038+
if (membersArray == null || membersArray.isEmpty()) {
1039+
return Collections.emptyList();
1040+
}
1041+
1042+
List<String> memberPatterns = toStringList(membersArray);
1043+
if (memberPatterns.isEmpty()) {
1044+
return Collections.emptyList();
1045+
}
1046+
1047+
List<String> excludePatterns = toStringList(workspaceConfig.getArray("exclude"));
1048+
1049+
List<PathMatcher> memberMatchers =
1050+
memberPatterns.stream()
1051+
.map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p))
1052+
.toList();
1053+
1054+
List<PathMatcher> excludeMatchers =
1055+
excludePatterns.stream()
1056+
.map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p))
1057+
.toList();
1058+
1059+
List<Path> manifests = new ArrayList<>();
1060+
try (var stream = Files.walk(workspaceDir)) {
1061+
stream
1062+
.filter(p -> p.getFileName().toString().equals("pyproject.toml"))
1063+
.filter(p -> !p.equals(rootPyproject))
1064+
.forEach(
1065+
p -> {
1066+
Path relative = workspaceDir.relativize(p.getParent());
1067+
boolean matchesMember =
1068+
memberMatchers.stream().anyMatch(m -> m.matches(relative));
1069+
boolean matchesExclude =
1070+
excludeMatchers.stream().anyMatch(m -> m.matches(relative));
1071+
if (matchesMember && !matchesExclude) {
1072+
manifests.add(p);
1073+
}
1074+
});
1075+
}
1076+
1077+
if (PyprojectTomlUtils.getProjectName(toml) != null) {
1078+
manifests.addFirst(rootPyproject);
1079+
}
1080+
1081+
return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifests, ignorePatterns);
1082+
} catch (Exception e) {
1083+
LOG.log(Level.WARNING, "Failed to discover uv workspace members", e);
1084+
return Collections.emptyList();
1085+
}
1086+
}
1087+
1088+
static List<String> toStringList(TomlArray array) {
1089+
if (array == null) {
1090+
return List.of();
1091+
}
1092+
List<String> result = new ArrayList<>();
1093+
for (int i = 0; i < array.size(); i++) {
1094+
String s = array.getString(i);
1095+
if (s != null && !s.isBlank()) {
1096+
result.add(s.trim());
1097+
}
1098+
}
1099+
return result;
1100+
}
1101+
9901102
/**
9911103
* Discovers Maven multi-module workspace manifests by invoking {@code mvn help:evaluate} to list
9921104
* declared modules, then recursively checking each module for nested aggregators.
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)