Skip to content

Commit c1ef6fd

Browse files
authored
feat(workspace): add Go workspace (go.work) discovery (#445)
Co-authored-by: Claude Opus 4.6
1 parent 3654443 commit c1ef6fd

15 files changed

Lines changed: 382 additions & 1 deletion

File tree

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

Lines changed: 46 additions & 0 deletions
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.golang.model.GoWorkspace;
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;
@@ -875,6 +876,14 @@ List<Path> discoverWorkspaceManifests(Path workspaceDir, Set<String> ignorePatte
875876
return discoverCargoManifests(workspaceDir, ignorePatterns);
876877
}
877878

879+
// Go workspace: go.work
880+
if (Files.isRegularFile(workspaceDir.resolve("go.work"))) {
881+
List<Path> goManifests = discoverGoWorkspaceModules(workspaceDir, ignorePatterns);
882+
if (!goManifests.isEmpty()) {
883+
return goManifests;
884+
}
885+
}
886+
878887
// JS workspace: require package.json + a lock file
879888
Path packageJson = workspaceDir.resolve("package.json");
880889
boolean hasJsLock =
@@ -930,6 +939,43 @@ private List<Path> discoverCargoManifests(Path workspaceDir, Set<String> ignoreP
930939
}
931940
}
932941

942+
/**
943+
* Discover all go.mod manifest paths in a Go workspace. Uses {@code go work edit -json} to get
944+
* workspace members.
945+
*/
946+
private List<Path> discoverGoWorkspaceModules(Path workspaceDir, Set<String> ignorePatterns) {
947+
try {
948+
String goBin = Operations.getCustomPathOrElse("go");
949+
Path goWork = workspaceDir.resolve("go.work");
950+
Operations.ProcessExecOutput output =
951+
Operations.runProcessGetFullOutput(
952+
workspaceDir, new String[] {goBin, "work", "edit", "-json", goWork.toString()}, null);
953+
if (output.getExitCode() != 0) {
954+
LOG.warning("go work edit -json failed with exit code " + output.getExitCode());
955+
return Collections.emptyList();
956+
}
957+
GoWorkspace workspace = mapper.readValue(output.getOutput(), GoWorkspace.class);
958+
if (workspace.use() == null || workspace.use().isEmpty()) {
959+
return Collections.emptyList();
960+
}
961+
List<Path> manifests = new ArrayList<>();
962+
for (var entry : workspace.use()) {
963+
if (entry.diskPath() == null || entry.diskPath().isBlank()) {
964+
continue;
965+
}
966+
Path moduleDir = workspaceDir.resolve(entry.diskPath()).normalize();
967+
Path goMod = moduleDir.resolve("go.mod");
968+
if (Files.isRegularFile(goMod)) {
969+
manifests.add(goMod);
970+
}
971+
}
972+
return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifests, ignorePatterns);
973+
} catch (Exception e) {
974+
LOG.warning("Failed to discover Go workspace modules: " + e.getMessage());
975+
return Collections.emptyList();
976+
}
977+
}
978+
933979
/**
934980
* Checks whether a package.json has "private": true, meaning it should not be analyzed as a
935981
* publishable package.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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.golang.model;
18+
19+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
20+
import com.fasterxml.jackson.annotation.JsonProperty;
21+
import java.util.List;
22+
23+
/** JSON model for {@code go work edit -json} output. */
24+
@JsonIgnoreProperties(ignoreUnknown = true)
25+
public record GoWorkspace(@JsonProperty("Use") List<UseEntry> use) {
26+
27+
@JsonIgnoreProperties(ignoreUnknown = true)
28+
public record UseEntry(@JsonProperty("DiskPath") String diskPath) {}
29+
}

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()

src/main/java/module-info.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
com.fasterxml.jackson.databind;
1919
opens io.github.guacsec.trustifyda.providers.rust.model to
2020
com.fasterxml.jackson.databind;
21+
opens io.github.guacsec.trustifyda.providers.golang.model to
22+
com.fasterxml.jackson.databind;
2123

2224
exports io.github.guacsec.trustifyda;
2325
exports io.github.guacsec.trustifyda.impl;
@@ -35,6 +37,7 @@
3537
exports io.github.guacsec.trustifyda.providers.javascript.model;
3638
exports io.github.guacsec.trustifyda.providers.javascript.workspace;
3739
exports io.github.guacsec.trustifyda.providers.rust.model;
40+
exports io.github.guacsec.trustifyda.providers.golang.model;
3841
exports io.github.guacsec.trustifyda.logging;
3942
exports io.github.guacsec.trustifyda.image;
4043
exports io.github.guacsec.trustifyda.license;
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
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+
import static org.mockito.ArgumentMatchers.any;
21+
import static org.mockito.ArgumentMatchers.eq;
22+
import static org.mockito.ArgumentMatchers.isNull;
23+
24+
import com.fasterxml.jackson.databind.ObjectMapper;
25+
import io.github.guacsec.trustifyda.providers.golang.model.GoWorkspace;
26+
import io.github.guacsec.trustifyda.tools.Operations;
27+
import java.io.File;
28+
import java.io.IOException;
29+
import java.nio.file.Path;
30+
import java.util.List;
31+
import java.util.Set;
32+
import org.junit.jupiter.api.Test;
33+
import org.mockito.MockedStatic;
34+
import org.mockito.Mockito;
35+
36+
class GoWorkspaceDiscoveryTest {
37+
38+
private static final Path GO_FIXTURES = Path.of("src/test/resources/tst_manifests/workspace/go");
39+
40+
private static final ObjectMapper MAPPER = new ObjectMapper();
41+
42+
// --- GoWorkspace deserialization tests ---
43+
44+
@Test
45+
void goWorkspace_deserializesStandardOutput() throws Exception {
46+
String json =
47+
"""
48+
{
49+
"Go": "1.22",
50+
"Use": [
51+
{"DiskPath": "./module-a"},
52+
{"DiskPath": "./module-b"}
53+
]
54+
}
55+
""";
56+
GoWorkspace workspace = MAPPER.readValue(json, GoWorkspace.class);
57+
58+
assertThat(workspace.use()).hasSize(2);
59+
assertThat(workspace.use().getFirst().diskPath()).isEqualTo("./module-a");
60+
assertThat(workspace.use().get(1).diskPath()).isEqualTo("./module-b");
61+
}
62+
63+
@Test
64+
void goWorkspace_handlesNullUse() throws Exception {
65+
String json =
66+
"""
67+
{"Go": "1.22"}
68+
""";
69+
GoWorkspace workspace = MAPPER.readValue(json, GoWorkspace.class);
70+
assertThat(workspace.use()).isNull();
71+
}
72+
73+
@Test
74+
void goWorkspace_handlesEmptyUse() throws Exception {
75+
String json =
76+
"""
77+
{"Go": "1.22", "Use": []}
78+
""";
79+
GoWorkspace workspace = MAPPER.readValue(json, GoWorkspace.class);
80+
assertThat(workspace.use()).isEmpty();
81+
}
82+
83+
@Test
84+
void goWorkspace_ignoresUnknownFields() throws Exception {
85+
String json =
86+
"""
87+
{
88+
"Go": "1.22",
89+
"Use": [{"DiskPath": "./mod"}],
90+
"Replace": null,
91+
"Toolchain": {"Name": "go1.22.0"}
92+
}
93+
""";
94+
GoWorkspace workspace = MAPPER.readValue(json, GoWorkspace.class);
95+
assertThat(workspace.use()).hasSize(1);
96+
}
97+
98+
// --- discoverWorkspaceManifests tests (require mocking Operations) ---
99+
100+
@Test
101+
void discoverWorkspaceManifests_goMultiModule() throws IOException {
102+
Path workspaceDir = GO_FIXTURES.resolve("go_workspace").toAbsolutePath().normalize();
103+
String goWorkJson = buildGoWorkJson("./module-a", "./module-b");
104+
105+
try (MockedStatic<Operations> mockOps = Mockito.mockStatic(Operations.class)) {
106+
mockGoOperations(mockOps, workspaceDir, goWorkJson);
107+
108+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
109+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
110+
111+
assertThat(manifests).hasSize(2);
112+
assertThat(manifests).allMatch(p -> p.toString().endsWith("go.mod"));
113+
assertThat(manifests)
114+
.anyMatch(p -> p.toString().contains("module-a" + File.separator + "go.mod"));
115+
assertThat(manifests)
116+
.anyMatch(p -> p.toString().contains("module-b" + File.separator + "go.mod"));
117+
}
118+
}
119+
120+
@Test
121+
void discoverWorkspaceManifests_nestedModules() throws IOException {
122+
Path workspaceDir = GO_FIXTURES.resolve("go_workspace_nested").toAbsolutePath().normalize();
123+
String goWorkJson = buildGoWorkJson("./libs/core", "./libs/util");
124+
125+
try (MockedStatic<Operations> mockOps = Mockito.mockStatic(Operations.class)) {
126+
mockGoOperations(mockOps, workspaceDir, goWorkJson);
127+
128+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
129+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
130+
131+
assertThat(manifests).hasSize(2);
132+
assertThat(manifests)
133+
.anyMatch(
134+
p ->
135+
p.toString()
136+
.contains("libs" + File.separator + "core" + File.separator + "go.mod"));
137+
assertThat(manifests)
138+
.anyMatch(
139+
p ->
140+
p.toString()
141+
.contains("libs" + File.separator + "util" + File.separator + "go.mod"));
142+
}
143+
}
144+
145+
@Test
146+
void discoverWorkspaceManifests_singleModule() throws IOException {
147+
Path workspaceDir = GO_FIXTURES.resolve("go_workspace_single").toAbsolutePath().normalize();
148+
String goWorkJson = buildGoWorkJson("./mymod");
149+
150+
try (MockedStatic<Operations> mockOps = Mockito.mockStatic(Operations.class)) {
151+
mockGoOperations(mockOps, workspaceDir, goWorkJson);
152+
153+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
154+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
155+
156+
assertThat(manifests).hasSize(1);
157+
assertThat(manifests.getFirst().toString()).contains("mymod" + File.separator + "go.mod");
158+
}
159+
}
160+
161+
@Test
162+
void discoverWorkspaceManifests_missingModuleDirectory() throws IOException {
163+
Path workspaceDir =
164+
GO_FIXTURES.resolve("go_workspace_missing_module").toAbsolutePath().normalize();
165+
String goWorkJson = buildGoWorkJson("./existing", "./nonexistent");
166+
167+
try (MockedStatic<Operations> mockOps = Mockito.mockStatic(Operations.class)) {
168+
mockGoOperations(mockOps, workspaceDir, goWorkJson);
169+
170+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
171+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
172+
173+
assertThat(manifests).hasSize(1);
174+
assertThat(manifests.getFirst().toString()).contains("existing");
175+
assertThat(manifests).noneMatch(p -> p.toString().contains("nonexistent"));
176+
}
177+
}
178+
179+
@Test
180+
void discoverWorkspaceManifests_goCommandFails() throws IOException {
181+
Path workspaceDir = GO_FIXTURES.resolve("go_workspace").toAbsolutePath().normalize();
182+
183+
try (MockedStatic<Operations> mockOps = Mockito.mockStatic(Operations.class)) {
184+
mockOps.when(() -> Operations.getCustomPathOrElse("go")).thenReturn("go");
185+
mockOps
186+
.when(
187+
() ->
188+
Operations.runProcessGetFullOutput(
189+
eq(workspaceDir), any(String[].class), isNull()))
190+
.thenReturn(new Operations.ProcessExecOutput("", "go: not found", 1));
191+
192+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
193+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
194+
195+
assertThat(manifests).isEmpty();
196+
}
197+
}
198+
199+
@Test
200+
void discoverWorkspaceManifests_emptyUseList() throws IOException {
201+
Path workspaceDir = GO_FIXTURES.resolve("go_workspace").toAbsolutePath().normalize();
202+
String goWorkJson =
203+
"""
204+
{"Go": "1.22", "Use": []}
205+
""";
206+
207+
try (MockedStatic<Operations> mockOps = Mockito.mockStatic(Operations.class)) {
208+
mockGoOperations(mockOps, workspaceDir, goWorkJson);
209+
210+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
211+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of());
212+
213+
assertThat(manifests).isEmpty();
214+
}
215+
}
216+
217+
@Test
218+
void discoverWorkspaceManifests_ignorePatternFiltering() throws IOException {
219+
Path workspaceDir = GO_FIXTURES.resolve("go_workspace_nested").toAbsolutePath().normalize();
220+
String goWorkJson = buildGoWorkJson("./libs/core", "./libs/util");
221+
222+
try (MockedStatic<Operations> mockOps = Mockito.mockStatic(Operations.class)) {
223+
mockGoOperations(mockOps, workspaceDir, goWorkJson);
224+
225+
ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
226+
List<Path> manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of("**/util/**"));
227+
228+
assertThat(manifests).anyMatch(p -> p.toString().contains("core"));
229+
assertThat(manifests).noneMatch(p -> p.toString().contains("util"));
230+
}
231+
}
232+
233+
// --- helpers ---
234+
235+
private static String buildGoWorkJson(String... diskPaths) {
236+
StringBuilder sb = new StringBuilder("{\"Go\": \"1.22\", \"Use\": [");
237+
for (int i = 0; i < diskPaths.length; i++) {
238+
if (i > 0) sb.append(", ");
239+
sb.append("{\"DiskPath\": \"").append(diskPaths[i]).append("\"}");
240+
}
241+
sb.append("]}");
242+
return sb.toString();
243+
}
244+
245+
private static void mockGoOperations(
246+
MockedStatic<Operations> mockOps, Path workspaceDir, String goWorkJson) {
247+
mockOps.when(() -> Operations.getCustomPathOrElse("go")).thenReturn("go");
248+
mockOps
249+
.when(
250+
() ->
251+
Operations.runProcessGetFullOutput(eq(workspaceDir), any(String[].class), isNull()))
252+
.thenReturn(new Operations.ProcessExecOutput(goWorkJson, "", 0));
253+
}
254+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
go 1.22
2+
3+
use (
4+
./module-a
5+
./module-b
6+
)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module example.com/module-a
2+
3+
go 1.22

0 commit comments

Comments
 (0)