Skip to content

Commit 34f9e41

Browse files
a-orenclaude
andcommitted
feat: add JS workspace discovery and public batch API
Add JavaScript/TypeScript monorepo workspace support to the Java client: - Add JsWorkspaceDiscovery utility for parsing pnpm-workspace.yaml and package.json workspaces field to discover member package.json manifests - Add parent-traversal lock file discovery to JavaScriptProviderFactory so workspace members find the lock file at the workspace root - Add stackAnalysisBatch() and stackAnalysisBatchHtml() to Api interface - Implement batch methods in ExhortApi using existing performBatchAnalysis() infrastructure with TRUSTIFY_DA_CONTINUE_ON_ERROR support - Add jackson-dataformat-yaml dependency for YAML parsing - Add comprehensive test suites for workspace discovery and lock file walk-up Implements TC-3863 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1aee31c commit 34f9e41

22 files changed

Lines changed: 830 additions & 1 deletion

File tree

pom.xml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,11 @@
151151
<artifactId>jackson-datatype-jsr310</artifactId>
152152
<version>${jackson.version}</version>
153153
</dependency>
154+
<dependency>
155+
<groupId>com.fasterxml.jackson.dataformat</groupId>
156+
<artifactId>jackson-dataformat-yaml</artifactId>
157+
<version>${jackson.version}</version>
158+
</dependency>
154159
<dependency>
155160
<groupId>jakarta.annotation</groupId>
156161
<artifactId>jakarta.annotation-api</artifactId>
@@ -228,7 +233,10 @@
228233
<groupId>com.fasterxml.jackson.core</groupId>
229234
<artifactId>jackson-databind</artifactId>
230235
</dependency>
231-
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.datatype/jackson-datatype-jsr310 -->
236+
<dependency>
237+
<groupId>com.fasterxml.jackson.dataformat</groupId>
238+
<artifactId>jackson-dataformat-yaml</artifactId>
239+
</dependency>
232240

233241
<dependency>
234242
<groupId>jakarta.annotation</groupId>

src/main/java/io/github/guacsec/trustifyda/Api.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import io.github.guacsec.trustifyda.api.v5.AnalysisReport;
2020
import io.github.guacsec.trustifyda.image.ImageRef;
2121
import java.io.IOException;
22+
import java.nio.file.Path;
2223
import java.util.Arrays;
2324
import java.util.Map;
2425
import java.util.Objects;
@@ -134,4 +135,29 @@ CompletableFuture<Map<ImageRef, AnalysisReport>> imageAnalysis(Set<ImageRef> ima
134135
* @throws IllegalStateException when the manifest file type is not supported
135136
*/
136137
String generateSbom(String manifestFile) throws IOException;
138+
139+
/**
140+
* Performs batch stack analysis for all workspace members discovered in the given workspace
141+
* directory. Discovers package.json manifests via pnpm-workspace.yaml or package.json workspaces
142+
* field, generates SBOMs for each, and sends them as a batch to the backend.
143+
*
144+
* @param workspaceDir the workspace root directory path
145+
* @param ignorePatterns glob patterns for paths to exclude from workspace discovery
146+
* @return a map of manifest name to analysis report, wrapped in a CompletableFuture
147+
* @throws IOException when workspace discovery or SBOM generation fails
148+
*/
149+
CompletableFuture<Map<String, AnalysisReport>> stackAnalysisBatch(
150+
Path workspaceDir, Set<String> ignorePatterns) throws IOException;
151+
152+
/**
153+
* Performs batch stack analysis returning an HTML report for all workspace members discovered in
154+
* the given workspace directory.
155+
*
156+
* @param workspaceDir the workspace root directory path
157+
* @param ignorePatterns glob patterns for paths to exclude from workspace discovery
158+
* @return the HTML report as a byte array wrapped in a CompletableFuture
159+
* @throws IOException when workspace discovery or SBOM generation fails
160+
*/
161+
CompletableFuture<byte[]> stackAnalysisBatchHtml(Path workspaceDir, Set<String> ignorePatterns)
162+
throws IOException;
137163
}

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

Lines changed: 82 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.javascript.workspace.JsWorkspaceDiscovery;
3334
import io.github.guacsec.trustifyda.tools.Ecosystem;
3435
import io.github.guacsec.trustifyda.utils.Environment;
3536
import jakarta.mail.MessagingException;
@@ -51,6 +52,7 @@
5152
import java.time.temporal.ChronoUnit;
5253
import java.util.AbstractMap;
5354
import java.util.Collections;
55+
import java.util.List;
5456
import java.util.Map;
5557
import java.util.Objects;
5658
import java.util.Optional;
@@ -703,6 +705,86 @@ public CompletableFuture<String> identifyLicense(Path licenseFilePath) {
703705
});
704706
}
705707

708+
@Override
709+
public CompletableFuture<Map<String, AnalysisReport>> stackAnalysisBatch(
710+
final Path workspaceDir, final Set<String> ignorePatterns) throws IOException {
711+
return this.performBatchAnalysis(
712+
() -> getBatchStackSboms(workspaceDir, ignorePatterns),
713+
MediaType.APPLICATION_JSON,
714+
HttpResponse.BodyHandlers.ofString(),
715+
this::getBatchStackAnalysisReports,
716+
Collections::emptyMap,
717+
"Batch Stack Analysis");
718+
}
719+
720+
@Override
721+
public CompletableFuture<byte[]> stackAnalysisBatchHtml(
722+
final Path workspaceDir, final Set<String> ignorePatterns) throws IOException {
723+
return this.performBatchAnalysis(
724+
() -> getBatchStackSboms(workspaceDir, ignorePatterns),
725+
MediaType.TEXT_HTML,
726+
HttpResponse.BodyHandlers.ofByteArray(),
727+
HttpResponse::body,
728+
() -> new byte[0],
729+
"Batch Stack Analysis");
730+
}
731+
732+
Map<String, JsonNode> getBatchStackSboms(
733+
final Path workspaceDir, final Set<String> ignorePatterns) {
734+
boolean continueOnError = Environment.getBoolean("TRUSTIFY_DA_CONTINUE_ON_ERROR", false);
735+
try {
736+
List<Path> manifests =
737+
JsWorkspaceDiscovery.discoverWorkspaceManifests(workspaceDir, ignorePatterns);
738+
if (manifests.isEmpty()) {
739+
LOG.warning("No workspace members discovered in " + workspaceDir);
740+
return Collections.emptyMap();
741+
}
742+
743+
return manifests.parallelStream()
744+
.map(
745+
manifest -> {
746+
try {
747+
var provider = Ecosystem.getProvider(manifest);
748+
var content = provider.provideStack();
749+
var sbomJson = mapper.readTree(content.buffer);
750+
String key = manifest.getParent().getFileName().toString();
751+
return new AbstractMap.SimpleEntry<>(key, sbomJson);
752+
} catch (Exception ex) {
753+
if (continueOnError) {
754+
LOG.warning(
755+
String.format(
756+
"Skipping manifest %s due to error: %s", manifest, ex.getMessage()));
757+
return null;
758+
}
759+
throw new RuntimeException("Failed to generate SBOM for " + manifest, ex);
760+
}
761+
})
762+
.filter(Objects::nonNull)
763+
.collect(
764+
Collectors.toMap(AbstractMap.SimpleEntry::getKey, AbstractMap.SimpleEntry::getValue));
765+
} catch (IOException e) {
766+
throw new RuntimeException("Failed to discover workspace manifests", e);
767+
}
768+
}
769+
770+
Map<String, AnalysisReport> getBatchStackAnalysisReports(
771+
final HttpResponse<String> httpResponse) {
772+
if (httpResponse.statusCode() == 200) {
773+
try {
774+
Map<?, ?> reports = this.mapper.readValue(httpResponse.body(), Map.class);
775+
return reports.entrySet().stream()
776+
.collect(
777+
Collectors.toMap(
778+
e -> e.getKey().toString(),
779+
e -> mapper.convertValue(e.getValue(), AnalysisReport.class)));
780+
} catch (JsonProcessingException e) {
781+
throw new CompletionException(e);
782+
}
783+
} else {
784+
return Collections.emptyMap();
785+
}
786+
}
787+
706788
/**
707789
* Build an HTTP request for sending to the Backend API.
708790
*

src/main/java/io/github/guacsec/trustifyda/providers/JavaScriptProviderFactory.java

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@
1616
*/
1717
package io.github.guacsec.trustifyda.providers;
1818

19+
import io.github.guacsec.trustifyda.providers.javascript.workspace.JsWorkspaceDiscovery;
20+
import io.github.guacsec.trustifyda.tools.Operations;
21+
import io.github.guacsec.trustifyda.utils.Environment;
1922
import java.nio.file.Files;
2023
import java.nio.file.Path;
2124
import java.util.Map;
2225
import java.util.function.Function;
2326

27+
/** Factory for creating the appropriate {@link JavaScriptProvider} based on lock file presence. */
2428
public final class JavaScriptProviderFactory {
2529

2630
private static final Map<String, Function<Path, JavaScriptProvider>> JS_PROVIDERS =
@@ -29,18 +33,78 @@ public final class JavaScriptProviderFactory {
2933
JavaScriptYarnProvider.LOCK_FILE, JavaScriptYarnProvider::new,
3034
JavaScriptPnpmProvider.LOCK_FILE, JavaScriptPnpmProvider::new);
3135

36+
/**
37+
* Creates a JavaScript provider by locating the lock file. Checks the manifest directory first,
38+
* then walks up parent directories to find the lock file at a workspace root.
39+
*
40+
* @param manifestPath the path to the package.json manifest
41+
* @return the matching JavaScript provider
42+
* @throws IllegalStateException if no supported lock file is found
43+
*/
3244
public static JavaScriptProvider create(final Path manifestPath) {
3345
var manifestDir = manifestPath.getParent();
46+
47+
// Check manifest directory first (fast path)
3448
for (var entry : JS_PROVIDERS.entrySet()) {
3549
var lockFilePath = manifestDir.resolve(entry.getKey());
3650
if (Files.isRegularFile(lockFilePath)) {
3751
return entry.getValue().apply(manifestPath);
3852
}
3953
}
54+
55+
// Walk up parent directories to find lock file at workspace root
56+
Path lockFileDir = findLockFileDirInParents(manifestDir);
57+
if (lockFileDir != null) {
58+
for (var entry : JS_PROVIDERS.entrySet()) {
59+
if (Files.isRegularFile(lockFileDir.resolve(entry.getKey()))) {
60+
return entry.getValue().apply(manifestPath);
61+
}
62+
}
63+
}
64+
4065
var validLockFiles = String.join(",", JS_PROVIDERS.keySet());
4166
throw new IllegalStateException(
4267
String.format(
4368
"No known lock file found for %s. Supported lock files: %s",
4469
manifestPath, validLockFiles));
4570
}
71+
72+
private static Path findLockFileDirInParents(Path startDir) {
73+
// Environment override takes precedence
74+
String workspaceDirOverride = Environment.get("TRUSTIFY_DA_WORKSPACE_DIR");
75+
if (workspaceDirOverride != null && !workspaceDirOverride.isBlank()) {
76+
Path overrideDir = Path.of(workspaceDirOverride);
77+
for (String lockFile : JS_PROVIDERS.keySet()) {
78+
if (Files.isRegularFile(overrideDir.resolve(lockFile))) {
79+
return overrideDir;
80+
}
81+
}
82+
return null;
83+
}
84+
85+
String gitRoot = Operations.getGitRootDir(startDir.toString()).orElse(null);
86+
Path boundary = gitRoot != null ? Path.of(gitRoot) : startDir.toAbsolutePath().getRoot();
87+
88+
Path current = startDir.toAbsolutePath().normalize().getParent();
89+
while (current != null) {
90+
for (String lockFile : JS_PROVIDERS.keySet()) {
91+
if (Files.isRegularFile(current.resolve(lockFile))) {
92+
return current;
93+
}
94+
}
95+
96+
// Stop at workspace root boundary
97+
if (JsWorkspaceDiscovery.isWorkspaceRoot(current)) {
98+
return null;
99+
}
100+
101+
if (current.equals(boundary.toAbsolutePath().normalize())) {
102+
break;
103+
}
104+
105+
current = current.getParent();
106+
}
107+
108+
return null;
109+
}
46110
}

0 commit comments

Comments
 (0)