Skip to content

Commit ae11832

Browse files
Strum355claude
andauthored
feat(workspace): add uv workspace discovery (TC-4266) (#446)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 74fa2d9 commit ae11832

21 files changed

Lines changed: 325 additions & 1 deletion

File tree

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

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import io.github.guacsec.trustifyda.tools.Ecosystem;
3838
import io.github.guacsec.trustifyda.tools.Operations;
3939
import io.github.guacsec.trustifyda.utils.Environment;
40+
import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils;
4041
import io.github.guacsec.trustifyda.utils.WorkspaceUtils;
4142
import jakarta.mail.MessagingException;
4243
import jakarta.mail.internet.MimeMultipart;
@@ -51,8 +52,10 @@
5152
import java.net.http.HttpRequest;
5253
import java.net.http.HttpResponse;
5354
import java.nio.charset.StandardCharsets;
55+
import java.nio.file.FileSystems;
5456
import java.nio.file.Files;
5557
import java.nio.file.Path;
58+
import java.nio.file.PathMatcher;
5659
import java.time.LocalDateTime;
5760
import java.time.temporal.ChronoUnit;
5861
import java.util.AbstractMap;
@@ -68,8 +71,12 @@
6871
import java.util.concurrent.CompletionException;
6972
import java.util.function.Function;
7073
import java.util.function.Supplier;
74+
import java.util.logging.Level;
7175
import java.util.logging.Logger;
7276
import java.util.stream.Collectors;
77+
import org.tomlj.TomlArray;
78+
import org.tomlj.TomlParseResult;
79+
import org.tomlj.TomlTable;
7380

7481
/** Concrete implementation of the Exhort {@link Api} Service. */
7582
public final class ExhortApi implements Api {
@@ -844,7 +851,8 @@ int resolveBatchConcurrency() {
844851
}
845852

846853
private static final Set<String> DEFAULT_WORKSPACE_DISCOVERY_IGNORE =
847-
Set.of("**/node_modules/**", "**/.git/**", "**/target/**");
854+
Set.of(
855+
"**/node_modules/**", "**/.git/**", "**/target/**", "**/__pycache__/**", "**/.venv/**");
848856

849857
/** Merges default ignore patterns, env var overrides, and caller-provided patterns. */
850858
Set<String> resolveIgnorePatterns(Set<String> callerPatterns) {
@@ -895,6 +903,15 @@ List<Path> discoverWorkspaceManifests(Path workspaceDir, Set<String> ignorePatte
895903
}
896904
}
897905

906+
// uv workspace: pyproject.toml with [tool.uv.workspace] + uv.lock
907+
if (Files.isRegularFile(workspaceDir.resolve("pyproject.toml"))
908+
&& Files.isRegularFile(workspaceDir.resolve("uv.lock"))) {
909+
List<Path> uvManifests = discoverUvWorkspaceMembers(workspaceDir, ignorePatterns);
910+
if (!uvManifests.isEmpty()) {
911+
return uvManifests;
912+
}
913+
}
914+
898915
// JS workspace: require package.json + a lock file
899916
Path packageJson = workspaceDir.resolve("package.json");
900917
boolean hasJsLock =
@@ -987,6 +1004,86 @@ private List<Path> discoverGoWorkspaceModules(Path workspaceDir, Set<String> ign
9871004
}
9881005
}
9891006

1007+
/**
1008+
* Discover all pyproject.toml manifest paths in a uv workspace. Parses the root pyproject.toml
1009+
* for {@code [tool.uv.workspace]} member and exclude globs, then walks the filesystem to find
1010+
* matching member directories.
1011+
*/
1012+
private List<Path> discoverUvWorkspaceMembers(Path workspaceDir, Set<String> ignorePatterns) {
1013+
try {
1014+
Path rootPyproject = workspaceDir.resolve("pyproject.toml");
1015+
TomlParseResult toml = PyprojectTomlUtils.parseToml(rootPyproject);
1016+
1017+
TomlTable workspaceConfig = toml.getTable("tool.uv.workspace");
1018+
if (workspaceConfig == null) {
1019+
return Collections.emptyList();
1020+
}
1021+
1022+
TomlArray membersArray = workspaceConfig.getArray("members");
1023+
if (membersArray == null || membersArray.isEmpty()) {
1024+
return Collections.emptyList();
1025+
}
1026+
1027+
List<String> memberPatterns = toStringList(membersArray);
1028+
if (memberPatterns.isEmpty()) {
1029+
return Collections.emptyList();
1030+
}
1031+
1032+
List<String> excludePatterns = toStringList(workspaceConfig.getArray("exclude"));
1033+
1034+
List<PathMatcher> memberMatchers =
1035+
memberPatterns.stream()
1036+
.map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p))
1037+
.toList();
1038+
1039+
List<PathMatcher> excludeMatchers =
1040+
excludePatterns.stream()
1041+
.map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p))
1042+
.toList();
1043+
1044+
List<Path> manifests = new ArrayList<>();
1045+
try (var stream = Files.walk(workspaceDir)) {
1046+
stream
1047+
.filter(p -> p.getFileName().toString().equals("pyproject.toml"))
1048+
.filter(p -> !p.equals(rootPyproject))
1049+
.forEach(
1050+
p -> {
1051+
Path relative = workspaceDir.relativize(p.getParent());
1052+
boolean matchesMember =
1053+
memberMatchers.stream().anyMatch(m -> m.matches(relative));
1054+
boolean matchesExclude =
1055+
excludeMatchers.stream().anyMatch(m -> m.matches(relative));
1056+
if (matchesMember && !matchesExclude) {
1057+
manifests.add(p);
1058+
}
1059+
});
1060+
}
1061+
1062+
if (PyprojectTomlUtils.getProjectName(toml) != null) {
1063+
manifests.addFirst(rootPyproject);
1064+
}
1065+
1066+
return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifests, ignorePatterns);
1067+
} catch (Exception e) {
1068+
LOG.log(Level.WARNING, "Failed to discover uv workspace members", e);
1069+
return Collections.emptyList();
1070+
}
1071+
}
1072+
1073+
static List<String> toStringList(TomlArray array) {
1074+
if (array == null) {
1075+
return List.of();
1076+
}
1077+
List<String> result = new ArrayList<>();
1078+
for (int i = 0; i < array.size(); i++) {
1079+
String s = array.getString(i);
1080+
if (s != null && !s.isBlank()) {
1081+
result.add(s.trim());
1082+
}
1083+
}
1084+
return result;
1085+
}
1086+
9901087
/**
9911088
* Discovers Maven multi-module workspace manifests by invoking {@code mvn help:evaluate} to list
9921089
* declared modules, then recursively checking each module for nested aggregators.
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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.impl;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
21+
import java.io.File;
22+
import java.io.IOException;
23+
import java.nio.file.Path;
24+
import java.util.List;
25+
import java.util.Set;
26+
import org.junit.jupiter.api.Test;
27+
import org.mockito.Mockito;
28+
29+
class UvWorkspaceDiscoveryTest {
30+
31+
private static final Path UV_FIXTURES = Path.of("src/test/resources/tst_manifests/workspace/uv");
32+
33+
@Test
34+
void discoverWorkspaceManifests_uvRootPackageWorkspace() throws IOException {
35+
Path workspaceDir = UV_FIXTURES.resolve("uv_workspace").toAbsolutePath().normalize();
36+
37+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
38+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
39+
40+
assertThat(manifests).hasSize(3);
41+
assertThat(manifests).allMatch(p -> p.toString().endsWith("pyproject.toml"));
42+
assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("pyproject.toml"));
43+
assertThat(manifests)
44+
.anyMatch(
45+
p ->
46+
p.toString()
47+
.contains(
48+
"packages"
49+
+ File.separator
50+
+ "mid-pkg"
51+
+ File.separator
52+
+ "pyproject.toml"));
53+
assertThat(manifests)
54+
.anyMatch(
55+
p ->
56+
p.toString()
57+
.contains(
58+
"packages"
59+
+ File.separator
60+
+ "sub-pkg"
61+
+ File.separator
62+
+ "pyproject.toml"));
63+
}
64+
65+
@Test
66+
void discoverWorkspaceManifests_uvVirtualWorkspace() throws IOException {
67+
Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_virtual").toAbsolutePath().normalize();
68+
69+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
70+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
71+
72+
assertThat(manifests).hasSize(2);
73+
assertThat(manifests).allMatch(p -> p.toString().endsWith("pyproject.toml"));
74+
assertThat(manifests).noneMatch(p -> p.equals(workspaceDir.resolve("pyproject.toml")));
75+
assertThat(manifests).anyMatch(p -> p.toString().contains("pkg-a"));
76+
assertThat(manifests).anyMatch(p -> p.toString().contains("pkg-b"));
77+
}
78+
79+
@Test
80+
void discoverWorkspaceManifests_uvExcludePatterns() throws IOException {
81+
Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_exclude").toAbsolutePath().normalize();
82+
83+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
84+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
85+
86+
assertThat(manifests).anyMatch(p -> p.toString().contains("core"));
87+
assertThat(manifests).noneMatch(p -> p.toString().contains("internal"));
88+
}
89+
90+
@Test
91+
void discoverWorkspaceManifests_uvNestedMultiplePatterns() throws IOException {
92+
Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_nested").toAbsolutePath().normalize();
93+
94+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
95+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
96+
97+
assertThat(manifests)
98+
.anyMatch(
99+
p ->
100+
p.toString()
101+
.contains(
102+
"apps" + File.separator + "backend" + File.separator + "pyproject.toml"));
103+
assertThat(manifests)
104+
.anyMatch(
105+
p ->
106+
p.toString()
107+
.contains(
108+
"libs" + File.separator + "core" + File.separator + "pyproject.toml"));
109+
}
110+
111+
@Test
112+
void discoverWorkspaceManifests_uvNoLockFile() throws IOException {
113+
Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_no_lock").toAbsolutePath().normalize();
114+
115+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
116+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
117+
118+
assertThat(manifests).isEmpty();
119+
}
120+
121+
@Test
122+
void discoverWorkspaceManifests_uvNoWorkspaceConfig() throws IOException {
123+
Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_no_config").toAbsolutePath().normalize();
124+
125+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
126+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
127+
128+
assertThat(manifests).isEmpty();
129+
}
130+
131+
@Test
132+
void discoverWorkspaceManifests_uvIgnorePatternFiltering() throws IOException {
133+
Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_nested").toAbsolutePath().normalize();
134+
135+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
136+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of("**/libs/**"));
137+
138+
assertThat(manifests).anyMatch(p -> p.toString().contains("backend"));
139+
assertThat(manifests)
140+
.noneMatch(p -> p.toString().contains(File.separator + "libs" + File.separator));
141+
}
142+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[project]
2+
name = "mid-pkg"
3+
version = "0.1.0"
4+
dependencies = [
5+
"sub-pkg",
6+
]
7+
8+
[tool.uv.sources]
9+
sub-pkg = { workspace = true }
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[project]
2+
name = "sub-pkg"
3+
version = "0.1.0"
4+
dependencies = [
5+
"requests>=2.0",
6+
]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[project]
2+
name = "uv-mono"
3+
version = "0.1.0"
4+
dependencies = [
5+
"mid-pkg",
6+
"flask==2.0.3"
7+
]
8+
9+
[tool.uv.sources]
10+
mid-pkg = { workspace = true }
11+
12+
[tool.uv.workspace]
13+
members = ["packages/*"]

src/test/resources/tst_manifests/workspace/uv/uv_workspace/uv.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[project]
2+
name = "core"
3+
version = "0.1.0"
4+
dependencies = []
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[project]
2+
name = "internal"
3+
version = "0.1.0"
4+
dependencies = []
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[project]
2+
name = "exclude-workspace"
3+
version = "0.1.0"
4+
dependencies = []
5+
6+
[tool.uv.workspace]
7+
members = ["packages/*"]
8+
exclude = ["packages/internal"]

src/test/resources/tst_manifests/workspace/uv/uv_workspace_exclude/uv.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)