Skip to content

Commit 8602496

Browse files
Strum355claude
andcommitted
feat(workspace): add uv workspace (pyproject.toml + uv.lock) discovery
Parse [tool.uv.workspace] member and exclude globs from pyproject.toml to discover all workspace member manifests. Virtual workspaces (no [project] section) correctly exclude the root pyproject.toml. Adds __pycache__ and .venv to default ignore patterns. Also fix Java PathMatcher quirk where **/X/** doesn't match root-level paths in WorkspaceUtils.filterByIgnorePatterns. TC-4266 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b2119d6 commit 8602496

22 files changed

Lines changed: 336 additions & 2 deletions

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
@@ -35,6 +35,7 @@
3535
import io.github.guacsec.trustifyda.tools.Ecosystem;
3636
import io.github.guacsec.trustifyda.tools.Operations;
3737
import io.github.guacsec.trustifyda.utils.Environment;
38+
import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils;
3839
import io.github.guacsec.trustifyda.utils.WorkspaceUtils;
3940
import jakarta.mail.MessagingException;
4041
import jakarta.mail.internet.MimeMultipart;
@@ -49,8 +50,10 @@
4950
import java.net.http.HttpRequest;
5051
import java.net.http.HttpResponse;
5152
import java.nio.charset.StandardCharsets;
53+
import java.nio.file.FileSystems;
5254
import java.nio.file.Files;
5355
import java.nio.file.Path;
56+
import java.nio.file.PathMatcher;
5457
import java.time.LocalDateTime;
5558
import java.time.temporal.ChronoUnit;
5659
import java.util.AbstractMap;
@@ -66,8 +69,12 @@
6669
import java.util.concurrent.CompletionException;
6770
import java.util.function.Function;
6871
import java.util.function.Supplier;
72+
import java.util.logging.Level;
6973
import java.util.logging.Logger;
7074
import java.util.stream.Collectors;
75+
import org.tomlj.TomlArray;
76+
import org.tomlj.TomlParseResult;
77+
import org.tomlj.TomlTable;
7178

7279
/** Concrete implementation of the Exhort {@link Api} Service. */
7380
public final class ExhortApi implements Api {
@@ -842,7 +849,7 @@ int resolveBatchConcurrency() {
842849
}
843850

844851
private static final Set<String> DEFAULT_WORKSPACE_DISCOVERY_IGNORE =
845-
Set.of("**/node_modules/**", "**/.git/**");
852+
Set.of("**/node_modules/**", "**/.git/**", "**/__pycache__/**", "**/.venv/**");
846853

847854
/** Merges default ignore patterns, env var overrides, and caller-provided patterns. */
848855
Set<String> resolveIgnorePatterns(Set<String> callerPatterns) {
@@ -875,6 +882,15 @@ List<Path> discoverWorkspaceManifests(Path workspaceDir, Set<String> ignorePatte
875882
return discoverCargoManifests(workspaceDir, ignorePatterns);
876883
}
877884

885+
// uv workspace: pyproject.toml with [tool.uv.workspace] + uv.lock
886+
if (Files.isRegularFile(workspaceDir.resolve("pyproject.toml"))
887+
&& Files.isRegularFile(workspaceDir.resolve("uv.lock"))) {
888+
List<Path> uvManifests = discoverUvWorkspaceMembers(workspaceDir, ignorePatterns);
889+
if (!uvManifests.isEmpty()) {
890+
return uvManifests;
891+
}
892+
}
893+
878894
// JS workspace: require package.json + a lock file
879895
Path packageJson = workspaceDir.resolve("package.json");
880896
boolean hasJsLock =
@@ -930,6 +946,87 @@ private List<Path> discoverCargoManifests(Path workspaceDir, Set<String> ignoreP
930946
}
931947
}
932948

949+
/**
950+
* Discover all pyproject.toml manifest paths in a uv workspace. Parses the root pyproject.toml
951+
* for {@code [tool.uv.workspace]} member and exclude globs, then walks the filesystem to find
952+
* matching member directories.
953+
*/
954+
private List<Path> discoverUvWorkspaceMembers(Path workspaceDir, Set<String> ignorePatterns) {
955+
try {
956+
Path rootPyproject = workspaceDir.resolve("pyproject.toml");
957+
TomlParseResult toml = PyprojectTomlUtils.parseToml(rootPyproject);
958+
959+
TomlTable workspaceConfig = toml.getTable("tool.uv.workspace");
960+
if (workspaceConfig == null) {
961+
return Collections.emptyList();
962+
}
963+
964+
TomlArray membersArray = workspaceConfig.getArray("members");
965+
if (membersArray == null || membersArray.isEmpty()) {
966+
return Collections.emptyList();
967+
}
968+
969+
List<String> memberPatterns = new ArrayList<>();
970+
for (int i = 0; i < membersArray.size(); i++) {
971+
String pattern = membersArray.getString(i);
972+
if (pattern != null && !pattern.isBlank()) {
973+
memberPatterns.add(pattern.trim());
974+
}
975+
}
976+
if (memberPatterns.isEmpty()) {
977+
return Collections.emptyList();
978+
}
979+
980+
Set<String> excludePatterns = new java.util.HashSet<>();
981+
TomlArray excludeArray = workspaceConfig.getArray("exclude");
982+
if (excludeArray != null) {
983+
for (int i = 0; i < excludeArray.size(); i++) {
984+
String pattern = excludeArray.getString(i);
985+
if (pattern != null && !pattern.isBlank()) {
986+
excludePatterns.add(pattern.trim());
987+
}
988+
}
989+
}
990+
991+
List<PathMatcher> memberMatchers =
992+
memberPatterns.stream()
993+
.map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p))
994+
.toList();
995+
996+
List<PathMatcher> excludeMatchers =
997+
excludePatterns.stream()
998+
.map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p))
999+
.toList();
1000+
1001+
List<Path> manifests = new ArrayList<>();
1002+
try (var stream = Files.walk(workspaceDir)) {
1003+
stream
1004+
.filter(p -> p.getFileName().toString().equals("pyproject.toml"))
1005+
.filter(p -> !p.equals(rootPyproject))
1006+
.forEach(
1007+
p -> {
1008+
Path relative = workspaceDir.relativize(p.getParent());
1009+
boolean matchesMember =
1010+
memberMatchers.stream().anyMatch(m -> m.matches(relative));
1011+
boolean matchesExclude =
1012+
excludeMatchers.stream().anyMatch(m -> m.matches(relative));
1013+
if (matchesMember && !matchesExclude) {
1014+
manifests.add(p);
1015+
}
1016+
});
1017+
}
1018+
1019+
if (PyprojectTomlUtils.getProjectName(toml) != null) {
1020+
manifests.addFirst(rootPyproject);
1021+
}
1022+
1023+
return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifests, ignorePatterns);
1024+
} catch (Exception e) {
1025+
LOG.log(Level.WARNING, "Failed to discover uv workspace members", e);
1026+
return Collections.emptyList();
1027+
}
1028+
}
1029+
9331030
/**
9341031
* Checks whether a package.json has "private": true, meaning it should not be analyzed as a
9351032
* publishable package.

src/main/java/io/github/guacsec/trustifyda/utils/WorkspaceUtils.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,17 @@ public static List<Path> filterByIgnorePatterns(
4343

4444
List<PathMatcher> matchers =
4545
ignorePatterns.stream()
46-
.map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p))
46+
.flatMap(
47+
p -> {
48+
var fs = FileSystems.getDefault();
49+
java.util.stream.Stream.Builder<PathMatcher> b =
50+
java.util.stream.Stream.builder();
51+
b.add(fs.getPathMatcher("glob:" + p));
52+
if (p.startsWith("**/")) {
53+
b.add(fs.getPathMatcher("glob:" + p.substring(3)));
54+
}
55+
return b.build();
56+
})
4757
.toList();
4858

4959
return manifests.stream()
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"]

0 commit comments

Comments
 (0)