From 6f71da72ffd55eba9696be4f3df1ac8095b6edb6 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Tue, 28 Apr 2026 12:36:03 +0100 Subject: [PATCH 1/7] feat(workspace): add Gradle multi-project workspace discovery Discover subprojects in Gradle multi-project builds using a custom init script that emits structured project listings. Supports Groovy and Kotlin DSL variants, gradlew wrapper detection, and workspaceDiscoveryIgnore filtering. Adds **/build/** and **/.gradle/** to default ignore patterns. Co-Authored-By: Claude Opus 4.6 --- .../guacsec/trustifyda/impl/ExhortApi.java | 130 ++++++- .../impl/GradleWorkspaceDiscoveryTest.java | 355 ++++++++++++++++++ .../app/build.gradle | 0 .../gradle_missing_subproject/build.gradle | 0 .../gradle_missing_subproject/settings.gradle | 0 .../gradle_mixed_variants/app/build.gradle | 0 .../gradle_mixed_variants/build.gradle.kts | 0 .../lib/build.gradle.kts | 0 .../gradle_mixed_variants/settings.gradle.kts | 0 .../gradle_multi_project/app/build.gradle | 0 .../gradle/gradle_multi_project/build.gradle | 0 .../gradle_multi_project/lib/build.gradle | 0 .../gradle_multi_project/settings.gradle | 0 .../gradle_nested_subprojects/build.gradle | 0 .../libs/core/build.gradle | 0 .../libs/util/build.gradle | 0 .../gradle_nested_subprojects/settings.gradle | 0 .../gradle/gradle_no_subprojects/build.gradle | 0 .../gradle_no_subprojects/settings.gradle | 0 19 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 src/test/java/io/github/guacsec/trustifyda/impl/GradleWorkspaceDiscoveryTest.java create mode 100644 src/test/resources/tst_manifests/workspace/gradle/gradle_missing_subproject/app/build.gradle create mode 100644 src/test/resources/tst_manifests/workspace/gradle/gradle_missing_subproject/build.gradle create mode 100644 src/test/resources/tst_manifests/workspace/gradle/gradle_missing_subproject/settings.gradle create mode 100644 src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/app/build.gradle create mode 100644 src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/build.gradle.kts create mode 100644 src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/lib/build.gradle.kts create mode 100644 src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/settings.gradle.kts create mode 100644 src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/app/build.gradle create mode 100644 src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/build.gradle create mode 100644 src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/lib/build.gradle create mode 100644 src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/settings.gradle create mode 100644 src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/build.gradle create mode 100644 src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/libs/core/build.gradle create mode 100644 src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/libs/util/build.gradle create mode 100644 src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/settings.gradle create mode 100644 src/test/resources/tst_manifests/workspace/gradle/gradle_no_subprojects/build.gradle create mode 100644 src/test/resources/tst_manifests/workspace/gradle/gradle_no_subprojects/settings.gradle diff --git a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java index a38d3b8a..e2f78a41 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -852,7 +852,7 @@ int resolveBatchConcurrency() { private static final Set DEFAULT_WORKSPACE_DISCOVERY_IGNORE = Set.of( - "**/node_modules/**", "**/.git/**", "**/target/**", "**/__pycache__/**", "**/.venv/**"); + "**/node_modules/**", "**/.git/**", "**/target/**", "**/__pycache__/**", "**/.venv/**", "**/build/**", "**/.gradle/**"); /** Merges default ignore patterns, env var overrides, and caller-provided patterns. */ Set resolveIgnorePatterns(Set callerPatterns) { @@ -912,6 +912,14 @@ List discoverWorkspaceManifests(Path workspaceDir, Set ignorePatte } } + // Gradle multi-project: settings.gradle or settings.gradle.kts + boolean hasGradleSettings = + Files.isRegularFile(workspaceDir.resolve("settings.gradle")) + || Files.isRegularFile(workspaceDir.resolve("settings.gradle.kts")); + if (hasGradleSettings) { + return discoverGradleSubprojects(workspaceDir, ignorePatterns); + } + // JS workspace: require package.json + a lock file Path packageJson = workspaceDir.resolve("package.json"); boolean hasJsLock = @@ -967,6 +975,126 @@ private List discoverCargoManifests(Path workspaceDir, Set ignoreP } } + private static final String GRADLE_INIT_SCRIPT = + "allprojects {\n" + + " task daListProjects {\n" + + " doLast {\n" + + " println \"::DA_PROJECT::${project.path}::${project.projectDir}\"\n" + + " }\n" + + " }\n" + + "}\n"; + + /** + * Resolve the Gradle binary, preferring gradlew wrapper when available and configured. + * + * @param startDir directory from which to start the wrapper search + * @return path to the Gradle binary + */ + private static String resolveGradleBinary(Path startDir) { + if (Operations.getWrapperPreference("gradle")) { + String wrapperName = Operations.isWindows() ? "gradlew.bat" : "gradlew"; + String wrapper = + JavaMavenProvider.traverseForMvnw( + wrapperName, startDir.resolve("build.gradle").toString()); + if (wrapper != null) { + return wrapper; + } + } + return Operations.getCustomPathOrElse("gradle"); + } + + /** + * Discover all build.gradle[.kts] manifest paths in a Gradle multi-project build. Uses a custom + * init script to get a structured project listing. + */ + private List discoverGradleSubprojects(Path workspaceDir, Set ignorePatterns) { + Path rootBuildKts = workspaceDir.resolve("build.gradle.kts"); + Path rootBuild = workspaceDir.resolve("build.gradle"); + + List manifestPaths = new ArrayList<>(); + if (Files.isRegularFile(rootBuildKts)) { + manifestPaths.add(rootBuildKts); + } else if (Files.isRegularFile(rootBuild)) { + manifestPaths.add(rootBuild); + } + + String gradleBin = resolveGradleBinary(workspaceDir); + Path initScriptPath = null; + try { + initScriptPath = Files.createTempFile("da-list-projects-", ".gradle"); + Files.writeString(initScriptPath, GRADLE_INIT_SCRIPT); + + Operations.ProcessExecOutput output = + Operations.runProcessGetFullOutput( + workspaceDir, + new String[] { + gradleBin, + "-q", + "--no-daemon", + "--init-script", + initScriptPath.toString(), + "daListProjects" + }, + null); + + if (output.getExitCode() != 0) { + LOG.warning("gradle daListProjects failed with exit code " + output.getExitCode()); + return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns); + } + + for (var proj : parseGradleInitScriptOutput(output.getOutput())) { + if (":".equals(proj.path())) { + continue; + } + Path projDir = Path.of(proj.dir()).toAbsolutePath().normalize(); + Path buildKts = projDir.resolve("build.gradle.kts"); + Path buildGroovy = projDir.resolve("build.gradle"); + if (Files.isRegularFile(buildKts)) { + manifestPaths.add(buildKts); + } else if (Files.isRegularFile(buildGroovy)) { + manifestPaths.add(buildGroovy); + } + } + } catch (Exception e) { + LOG.warning("Failed to discover Gradle subprojects: " + e.getMessage()); + return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns); + } finally { + if (initScriptPath != null) { + try { + Files.deleteIfExists(initScriptPath); + } catch (IOException ignored) { + } + } + } + + return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns); + } + + record GradleProject(String path, String dir) {} + + static List parseGradleInitScriptOutput(String raw) { + if (raw == null || raw.isBlank()) { + return List.of(); + } + List projects = new ArrayList<>(); + for (String line : raw.split("\n")) { + if (!line.startsWith("::DA_PROJECT::")) { + continue; + } + String[] parts = line.split("::"); + List nonEmpty = new ArrayList<>(); + for (String part : parts) { + if (!part.isEmpty()) { + nonEmpty.add(part); + } + } + if (nonEmpty.size() >= 3) { + projects.add(new GradleProject(nonEmpty.get(1), nonEmpty.get(2))); + } + } + return projects; + } + /** * Discover all go.mod manifest paths in a Go workspace. Uses {@code go work edit -json} to get * workspace members. diff --git a/src/test/java/io/github/guacsec/trustifyda/impl/GradleWorkspaceDiscoveryTest.java b/src/test/java/io/github/guacsec/trustifyda/impl/GradleWorkspaceDiscoveryTest.java new file mode 100644 index 00000000..06a72337 --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/impl/GradleWorkspaceDiscoveryTest.java @@ -0,0 +1,355 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.guacsec.trustifyda.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; + +import io.github.guacsec.trustifyda.tools.Operations; +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +class GradleWorkspaceDiscoveryTest { + + private static final Path GRADLE_FIXTURES = + Path.of("src/test/resources/tst_manifests/workspace/gradle"); + + // --- parseGradleInitScriptOutput tests (pure function, no mocking needed) --- + + @Test + void parseGradleInitScriptOutput_standardOutput() { + String raw = + "::DA_PROJECT::::/home/project\n" + + "::DA_PROJECT:::app::/home/project/app\n" + + "::DA_PROJECT:::lib::/home/project/lib\n"; + + List result = ExhortApi.parseGradleInitScriptOutput(raw); + + assertThat(result).hasSize(3); + assertThat(result.get(0).path()).isEqualTo(":"); + assertThat(result.get(0).dir()).isEqualTo("/home/project"); + assertThat(result.get(1).path()).isEqualTo(":app"); + assertThat(result.get(1).dir()).isEqualTo("/home/project/app"); + } + + @Test + void parseGradleInitScriptOutput_nestedProjects() { + String raw = + "::DA_PROJECT::::/home/project\n" + + "::DA_PROJECT:::libs:core::/home/project/libs/core\n" + + "::DA_PROJECT:::libs:util::/home/project/libs/util\n"; + + List result = ExhortApi.parseGradleInitScriptOutput(raw); + + assertThat(result).hasSize(3); + assertThat(result.get(1).path()).isEqualTo(":libs:core"); + assertThat(result.get(1).dir()).isEqualTo("/home/project/libs/core"); + } + + @Test + void parseGradleInitScriptOutput_nullInput() { + assertThat(ExhortApi.parseGradleInitScriptOutput(null)).isEmpty(); + } + + @Test + void parseGradleInitScriptOutput_emptyInput() { + assertThat(ExhortApi.parseGradleInitScriptOutput("")).isEmpty(); + } + + @Test + void parseGradleInitScriptOutput_ignoresNonPrefixedLines() { + String raw = "some gradle log output\n::DA_PROJECT:::app::/home/project/app\nmore output\n"; + + List result = ExhortApi.parseGradleInitScriptOutput(raw); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().path()).isEqualTo(":app"); + } + + // --- discoverWorkspaceManifests tests (require mocking Operations) --- + + @Test + void discoverWorkspaceManifests_gradleMultiProject() throws IOException { + Path workspaceDir = + GRADLE_FIXTURES.resolve("gradle_multi_project").toAbsolutePath().normalize(); + + String initScriptOutput = + "::DA_PROJECT::::" + + workspaceDir + + "\n" + + "::DA_PROJECT:::app::" + + workspaceDir.resolve("app") + + "\n" + + "::DA_PROJECT:::lib::" + + workspaceDir.resolve("lib") + + "\n"; + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("gradle")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); + + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(3); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle")); + assertThat(manifests) + .anyMatch(p -> p.toString().contains("app" + File.separator + "build.gradle")); + assertThat(manifests) + .anyMatch(p -> p.toString().contains("lib" + File.separator + "build.gradle")); + } + } + + @Test + void discoverWorkspaceManifests_nestedSubprojects() throws IOException { + Path workspaceDir = + GRADLE_FIXTURES.resolve("gradle_nested_subprojects").toAbsolutePath().normalize(); + + String initScriptOutput = + "::DA_PROJECT::::" + + workspaceDir + + "\n" + + "::DA_PROJECT:::libs:core::" + + workspaceDir.resolve("libs/core") + + "\n" + + "::DA_PROJECT:::libs:util::" + + workspaceDir.resolve("libs/util") + + "\n"; + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("gradle")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); + + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(3); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle")); + assertThat(manifests) + .anyMatch( + p -> + p.toString() + .contains( + "libs" + File.separator + "core" + File.separator + "build.gradle")); + assertThat(manifests) + .anyMatch( + p -> + p.toString() + .contains( + "libs" + File.separator + "util" + File.separator + "build.gradle")); + } + } + + @Test + void discoverWorkspaceManifests_mixedGroovyAndKotlin() throws IOException { + Path workspaceDir = + GRADLE_FIXTURES.resolve("gradle_mixed_variants").toAbsolutePath().normalize(); + + String initScriptOutput = + "::DA_PROJECT::::" + + workspaceDir + + "\n" + + "::DA_PROJECT:::app::" + + workspaceDir.resolve("app") + + "\n" + + "::DA_PROJECT:::lib::" + + workspaceDir.resolve("lib") + + "\n"; + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("gradle")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); + + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(3); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle.kts")); + assertThat(manifests) + .anyMatch(p -> p.toString().endsWith("app" + File.separator + "build.gradle")); + assertThat(manifests) + .anyMatch(p -> p.toString().endsWith("lib" + File.separator + "build.gradle.kts")); + } + } + + @Test + void discoverWorkspaceManifests_noSubprojects() throws IOException { + Path workspaceDir = + GRADLE_FIXTURES.resolve("gradle_no_subprojects").toAbsolutePath().normalize(); + + String initScriptOutput = "::DA_PROJECT::::" + workspaceDir + "\n"; + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("gradle")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); + + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(1); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle")); + } + } + + @Test + void discoverWorkspaceManifests_gradleCommandFails() throws IOException { + Path workspaceDir = + GRADLE_FIXTURES.resolve("gradle_multi_project").toAbsolutePath().normalize(); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("gradle")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); + + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput("", "error", 1)); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(1); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle")); + } + } + + @Test + void discoverWorkspaceManifests_missingSubprojectDirectory() throws IOException { + Path workspaceDir = + GRADLE_FIXTURES.resolve("gradle_missing_subproject").toAbsolutePath().normalize(); + + String initScriptOutput = + "::DA_PROJECT::::" + + workspaceDir + + "\n" + + "::DA_PROJECT:::app::" + + workspaceDir.resolve("app") + + "\n" + + "::DA_PROJECT:::lib-missing::" + + workspaceDir.resolve("lib-missing") + + "\n"; + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("gradle")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); + + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(2); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle")); + assertThat(manifests).anyMatch(p -> p.toString().contains("app")); + assertThat(manifests).noneMatch(p -> p.toString().contains("lib-missing")); + } + } + + @Test + void discoverWorkspaceManifests_ignorePatternFiltering() throws IOException { + Path workspaceDir = + GRADLE_FIXTURES.resolve("gradle_multi_project").toAbsolutePath().normalize(); + + String initScriptOutput = + "::DA_PROJECT::::" + + workspaceDir + + "\n" + + "::DA_PROJECT:::app::" + + workspaceDir.resolve("app") + + "\n" + + "::DA_PROJECT:::lib::" + + workspaceDir.resolve("lib") + + "\n"; + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("gradle")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); + + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of("**/lib/**")); + + assertThat(manifests).anyMatch(p -> p.toString().contains("app")); + assertThat(manifests).noneMatch(p -> p.toString().contains("lib")); + } + } + + @Test + void defaultIgnorePatterns_includesBuildAndGradle() { + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + Set resolvedPatterns = api.resolveIgnorePatterns(null); + + assertThat(resolvedPatterns).contains("**/build/**"); + assertThat(resolvedPatterns).contains("**/.gradle/**"); + } +} diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_missing_subproject/app/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_missing_subproject/app/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_missing_subproject/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_missing_subproject/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_missing_subproject/settings.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_missing_subproject/settings.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/app/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/app/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/build.gradle.kts b/src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/build.gradle.kts new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/lib/build.gradle.kts b/src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/lib/build.gradle.kts new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/settings.gradle.kts b/src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/settings.gradle.kts new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/app/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/app/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/lib/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/lib/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/settings.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/settings.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/libs/core/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/libs/core/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/libs/util/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/libs/util/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/settings.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/settings.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_no_subprojects/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_no_subprojects/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_no_subprojects/settings.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_no_subprojects/settings.gradle new file mode 100644 index 00000000..e69de29b From 82c7b524c9cb27ea512524111a0125c1f1ea9d71 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Thu, 30 Apr 2026 16:23:27 +0100 Subject: [PATCH 2/7] fix(workspace): fix Gradle init script output parsing for root project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parser used split("::") which failed on root project lines where project.path is ":" — the single colon merged with the :: delimiters, creating an ambiguous sequence that split consumed incorrectly. Replaced with lastIndexOf-based parsing: strip the ::DA_PROJECT:: prefix, find the last :: separator (directory paths never contain ::), and extract path and dir from there. Also fixed test data to use the correct number of colons matching actual Gradle init script output (::DA_PROJECT:: + ":" + :: = 5 colons for the root project line). Co-Authored-By: Claude Opus 4.6 --- .../guacsec/trustifyda/impl/ExhortApi.java | 19 ++++++++++--------- .../impl/GradleWorkspaceDiscoveryTest.java | 16 ++++++++-------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java index e2f78a41..b2fec2a2 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -1076,20 +1076,21 @@ static List parseGradleInitScriptOutput(String raw) { if (raw == null || raw.isBlank()) { return List.of(); } + String prefix = "::DA_PROJECT::"; List projects = new ArrayList<>(); for (String line : raw.split("\n")) { - if (!line.startsWith("::DA_PROJECT::")) { + if (!line.startsWith(prefix)) { continue; } - String[] parts = line.split("::"); - List nonEmpty = new ArrayList<>(); - for (String part : parts) { - if (!part.isEmpty()) { - nonEmpty.add(part); - } + String remainder = line.substring(prefix.length()); + int lastSep = remainder.lastIndexOf("::"); + if (lastSep < 0) { + continue; } - if (nonEmpty.size() >= 3) { - projects.add(new GradleProject(nonEmpty.get(1), nonEmpty.get(2))); + String path = remainder.substring(0, lastSep); + String dir = remainder.substring(lastSep + 2); + if (!path.isEmpty() && !dir.isEmpty()) { + projects.add(new GradleProject(path, dir)); } } return projects; diff --git a/src/test/java/io/github/guacsec/trustifyda/impl/GradleWorkspaceDiscoveryTest.java b/src/test/java/io/github/guacsec/trustifyda/impl/GradleWorkspaceDiscoveryTest.java index 06a72337..9fe328d5 100644 --- a/src/test/java/io/github/guacsec/trustifyda/impl/GradleWorkspaceDiscoveryTest.java +++ b/src/test/java/io/github/guacsec/trustifyda/impl/GradleWorkspaceDiscoveryTest.java @@ -41,7 +41,7 @@ class GradleWorkspaceDiscoveryTest { @Test void parseGradleInitScriptOutput_standardOutput() { String raw = - "::DA_PROJECT::::/home/project\n" + "::DA_PROJECT:::::/home/project\n" + "::DA_PROJECT:::app::/home/project/app\n" + "::DA_PROJECT:::lib::/home/project/lib\n"; @@ -57,7 +57,7 @@ void parseGradleInitScriptOutput_standardOutput() { @Test void parseGradleInitScriptOutput_nestedProjects() { String raw = - "::DA_PROJECT::::/home/project\n" + "::DA_PROJECT:::::/home/project\n" + "::DA_PROJECT:::libs:core::/home/project/libs/core\n" + "::DA_PROJECT:::libs:util::/home/project/libs/util\n"; @@ -96,7 +96,7 @@ void discoverWorkspaceManifests_gradleMultiProject() throws IOException { GRADLE_FIXTURES.resolve("gradle_multi_project").toAbsolutePath().normalize(); String initScriptOutput = - "::DA_PROJECT::::" + "::DA_PROJECT:::::" + workspaceDir + "\n" + "::DA_PROJECT:::app::" @@ -136,7 +136,7 @@ void discoverWorkspaceManifests_nestedSubprojects() throws IOException { GRADLE_FIXTURES.resolve("gradle_nested_subprojects").toAbsolutePath().normalize(); String initScriptOutput = - "::DA_PROJECT::::" + "::DA_PROJECT:::::" + workspaceDir + "\n" + "::DA_PROJECT:::libs:core::" @@ -184,7 +184,7 @@ void discoverWorkspaceManifests_mixedGroovyAndKotlin() throws IOException { GRADLE_FIXTURES.resolve("gradle_mixed_variants").toAbsolutePath().normalize(); String initScriptOutput = - "::DA_PROJECT::::" + "::DA_PROJECT:::::" + workspaceDir + "\n" + "::DA_PROJECT:::app::" @@ -223,7 +223,7 @@ void discoverWorkspaceManifests_noSubprojects() throws IOException { Path workspaceDir = GRADLE_FIXTURES.resolve("gradle_no_subprojects").toAbsolutePath().normalize(); - String initScriptOutput = "::DA_PROJECT::::" + workspaceDir + "\n"; + String initScriptOutput = "::DA_PROJECT:::::" + workspaceDir + "\n"; try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { mockOps.when(() -> Operations.getWrapperPreference("gradle")).thenReturn(false); @@ -276,7 +276,7 @@ void discoverWorkspaceManifests_missingSubprojectDirectory() throws IOException GRADLE_FIXTURES.resolve("gradle_missing_subproject").toAbsolutePath().normalize(); String initScriptOutput = - "::DA_PROJECT::::" + "::DA_PROJECT:::::" + workspaceDir + "\n" + "::DA_PROJECT:::app::" @@ -314,7 +314,7 @@ void discoverWorkspaceManifests_ignorePatternFiltering() throws IOException { GRADLE_FIXTURES.resolve("gradle_multi_project").toAbsolutePath().normalize(); String initScriptOutput = - "::DA_PROJECT::::" + "::DA_PROJECT:::::" + workspaceDir + "\n" + "::DA_PROJECT:::app::" From 100b7168d7572635f2092728544448352cf11362 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Fri, 1 May 2026 18:52:08 +0100 Subject: [PATCH 3/7] fix(workspace): use public static overload of traverseForMvnw The 2-arg traverseForMvnw is a private instance method on JavaMavenProvider. Call the 3-arg public static overload with null repoRoot instead, which has the same behavior. Co-Authored-By: Claude Opus 4.6 --- src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java index b2fec2a2..0d903a9f 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -995,7 +995,7 @@ private static String resolveGradleBinary(Path startDir) { String wrapperName = Operations.isWindows() ? "gradlew.bat" : "gradlew"; String wrapper = JavaMavenProvider.traverseForMvnw( - wrapperName, startDir.resolve("build.gradle").toString()); + wrapperName, startDir.resolve("build.gradle").toString(), null); if (wrapper != null) { return wrapper; } From e54a559dd1346cd0e5872a282abdca0f2741f3c8 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Tue, 5 May 2026 13:10:54 +0100 Subject: [PATCH 4/7] fix: use String.lines() instead of split("\n") for CRLF-safe parsing Replace raw.split("\n") with raw.lines().toList() in parseGradleInitScriptOutput to correctly handle Windows CRLF line endings. Co-Authored-By: Claude Opus 4.6 --- src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java index 0d903a9f..f5718776 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -1078,7 +1078,7 @@ static List parseGradleInitScriptOutput(String raw) { } String prefix = "::DA_PROJECT::"; List projects = new ArrayList<>(); - for (String line : raw.split("\n")) { + for (String line : raw.lines().toList()) { if (!line.startsWith(prefix)) { continue; } From 72062e7a0394fee26a676c7f629491c6cdcee748 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Tue, 5 May 2026 13:29:53 +0100 Subject: [PATCH 5/7] fix: include stderr in Gradle init script failure warning Log the error output alongside the exit code when gradle daListProjects fails, so the root cause is visible without rerunning manually. Co-Authored-By: Claude Opus 4.6 --- .../java/io/github/guacsec/trustifyda/impl/ExhortApi.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java index f5718776..4ca33273 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -1038,7 +1038,11 @@ private List discoverGradleSubprojects(Path workspaceDir, Set igno null); if (output.getExitCode() != 0) { - LOG.warning("gradle daListProjects failed with exit code " + output.getExitCode()); + LOG.warning( + "gradle daListProjects failed with exit code " + + output.getExitCode() + + ": " + + output.getErrorOutput()); return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns); } From 81a38e0f843e119cd2052a20d1c593b2ccc65842 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Tue, 5 May 2026 17:46:53 +0100 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20correct=20method=20name=20getErrorOu?= =?UTF-8?q?tput()=20=E2=86=92=20getError()=20on=20ProcessExecOutput?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java index 4ca33273..e2ccfa05 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -1042,7 +1042,7 @@ private List discoverGradleSubprojects(Path workspaceDir, Set igno "gradle daListProjects failed with exit code " + output.getExitCode() + ": " - + output.getErrorOutput()); + + output.getError()); return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns); } From afb152ae4298870baf99dbe371cf003755551075 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Fri, 8 May 2026 12:20:27 +0100 Subject: [PATCH 7/7] refactor: move Gradle workspace discovery to provider layer Extract ~120 lines of Gradle-specific code (init script, subproject parsing, binary resolution) from ExhortApi into a dedicated GradleWorkspaceDiscovery utility class in the provider layer, following the same pattern as JsWorkspaceDiscovery. Add wrapper-preference tests for gradlew resolution path. Co-Authored-By: Claude Opus 4.6 --- .../guacsec/trustifyda/impl/ExhortApi.java | 136 +--------------- .../workspace/GradleWorkspaceDiscovery.java | 154 ++++++++++++++++++ src/main/java/module-info.java | 1 + .../GradleWorkspaceDiscoveryTest.java | 149 +++++++++++++---- 4 files changed, 280 insertions(+), 160 deletions(-) create mode 100644 src/main/java/io/github/guacsec/trustifyda/providers/gradle/workspace/GradleWorkspaceDiscovery.java rename src/test/java/io/github/guacsec/trustifyda/{impl => providers/gradle/workspace}/GradleWorkspaceDiscoveryTest.java (68%) diff --git a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java index e2ccfa05..5052c519 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -32,6 +32,7 @@ import io.github.guacsec.trustifyda.logging.LoggersFactory; import io.github.guacsec.trustifyda.providers.JavaMavenProvider; import io.github.guacsec.trustifyda.providers.golang.model.GoWorkspace; +import io.github.guacsec.trustifyda.providers.gradle.workspace.GradleWorkspaceDiscovery; import io.github.guacsec.trustifyda.providers.javascript.workspace.JsWorkspaceDiscovery; import io.github.guacsec.trustifyda.providers.rust.model.CargoMetadata; import io.github.guacsec.trustifyda.tools.Ecosystem; @@ -852,7 +853,13 @@ int resolveBatchConcurrency() { private static final Set DEFAULT_WORKSPACE_DISCOVERY_IGNORE = Set.of( - "**/node_modules/**", "**/.git/**", "**/target/**", "**/__pycache__/**", "**/.venv/**", "**/build/**", "**/.gradle/**"); + "**/node_modules/**", + "**/.git/**", + "**/target/**", + "**/__pycache__/**", + "**/.venv/**", + "**/build/**", + "**/.gradle/**"); /** Merges default ignore patterns, env var overrides, and caller-provided patterns. */ Set resolveIgnorePatterns(Set callerPatterns) { @@ -917,7 +924,7 @@ List discoverWorkspaceManifests(Path workspaceDir, Set ignorePatte Files.isRegularFile(workspaceDir.resolve("settings.gradle")) || Files.isRegularFile(workspaceDir.resolve("settings.gradle.kts")); if (hasGradleSettings) { - return discoverGradleSubprojects(workspaceDir, ignorePatterns); + return GradleWorkspaceDiscovery.discoverSubprojects(workspaceDir, ignorePatterns); } // JS workspace: require package.json + a lock file @@ -975,131 +982,6 @@ private List discoverCargoManifests(Path workspaceDir, Set ignoreP } } - private static final String GRADLE_INIT_SCRIPT = - "allprojects {\n" - + " task daListProjects {\n" - + " doLast {\n" - + " println \"::DA_PROJECT::${project.path}::${project.projectDir}\"\n" - + " }\n" - + " }\n" - + "}\n"; - - /** - * Resolve the Gradle binary, preferring gradlew wrapper when available and configured. - * - * @param startDir directory from which to start the wrapper search - * @return path to the Gradle binary - */ - private static String resolveGradleBinary(Path startDir) { - if (Operations.getWrapperPreference("gradle")) { - String wrapperName = Operations.isWindows() ? "gradlew.bat" : "gradlew"; - String wrapper = - JavaMavenProvider.traverseForMvnw( - wrapperName, startDir.resolve("build.gradle").toString(), null); - if (wrapper != null) { - return wrapper; - } - } - return Operations.getCustomPathOrElse("gradle"); - } - - /** - * Discover all build.gradle[.kts] manifest paths in a Gradle multi-project build. Uses a custom - * init script to get a structured project listing. - */ - private List discoverGradleSubprojects(Path workspaceDir, Set ignorePatterns) { - Path rootBuildKts = workspaceDir.resolve("build.gradle.kts"); - Path rootBuild = workspaceDir.resolve("build.gradle"); - - List manifestPaths = new ArrayList<>(); - if (Files.isRegularFile(rootBuildKts)) { - manifestPaths.add(rootBuildKts); - } else if (Files.isRegularFile(rootBuild)) { - manifestPaths.add(rootBuild); - } - - String gradleBin = resolveGradleBinary(workspaceDir); - Path initScriptPath = null; - try { - initScriptPath = Files.createTempFile("da-list-projects-", ".gradle"); - Files.writeString(initScriptPath, GRADLE_INIT_SCRIPT); - - Operations.ProcessExecOutput output = - Operations.runProcessGetFullOutput( - workspaceDir, - new String[] { - gradleBin, - "-q", - "--no-daemon", - "--init-script", - initScriptPath.toString(), - "daListProjects" - }, - null); - - if (output.getExitCode() != 0) { - LOG.warning( - "gradle daListProjects failed with exit code " - + output.getExitCode() - + ": " - + output.getError()); - return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns); - } - - for (var proj : parseGradleInitScriptOutput(output.getOutput())) { - if (":".equals(proj.path())) { - continue; - } - Path projDir = Path.of(proj.dir()).toAbsolutePath().normalize(); - Path buildKts = projDir.resolve("build.gradle.kts"); - Path buildGroovy = projDir.resolve("build.gradle"); - if (Files.isRegularFile(buildKts)) { - manifestPaths.add(buildKts); - } else if (Files.isRegularFile(buildGroovy)) { - manifestPaths.add(buildGroovy); - } - } - } catch (Exception e) { - LOG.warning("Failed to discover Gradle subprojects: " + e.getMessage()); - return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns); - } finally { - if (initScriptPath != null) { - try { - Files.deleteIfExists(initScriptPath); - } catch (IOException ignored) { - } - } - } - - return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns); - } - - record GradleProject(String path, String dir) {} - - static List parseGradleInitScriptOutput(String raw) { - if (raw == null || raw.isBlank()) { - return List.of(); - } - String prefix = "::DA_PROJECT::"; - List projects = new ArrayList<>(); - for (String line : raw.lines().toList()) { - if (!line.startsWith(prefix)) { - continue; - } - String remainder = line.substring(prefix.length()); - int lastSep = remainder.lastIndexOf("::"); - if (lastSep < 0) { - continue; - } - String path = remainder.substring(0, lastSep); - String dir = remainder.substring(lastSep + 2); - if (!path.isEmpty() && !dir.isEmpty()) { - projects.add(new GradleProject(path, dir)); - } - } - return projects; - } - /** * Discover all go.mod manifest paths in a Go workspace. Uses {@code go work edit -json} to get * workspace members. diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/gradle/workspace/GradleWorkspaceDiscovery.java b/src/main/java/io/github/guacsec/trustifyda/providers/gradle/workspace/GradleWorkspaceDiscovery.java new file mode 100644 index 00000000..b111b08d --- /dev/null +++ b/src/main/java/io/github/guacsec/trustifyda/providers/gradle/workspace/GradleWorkspaceDiscovery.java @@ -0,0 +1,154 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.guacsec.trustifyda.providers.gradle.workspace; + +import io.github.guacsec.trustifyda.logging.LoggersFactory; +import io.github.guacsec.trustifyda.providers.JavaMavenProvider; +import io.github.guacsec.trustifyda.tools.Operations; +import io.github.guacsec.trustifyda.utils.WorkspaceUtils; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** Discovers Gradle multi-project build manifest paths using a custom init script. */ +public final class GradleWorkspaceDiscovery { + + private static final Logger LOG = + LoggersFactory.getLogger(GradleWorkspaceDiscovery.class.getName()); + + private static final String GRADLE_INIT_SCRIPT = + "allprojects {\n" + + " task daListProjects {\n" + + " doLast {\n" + + " println \"::DA_PROJECT::${project.path}::${project.projectDir}\"\n" + + " }\n" + + " }\n" + + "}\n"; + + private GradleWorkspaceDiscovery() {} + + public static List discoverSubprojects(Path workspaceDir, Set ignorePatterns) { + Path rootBuildKts = workspaceDir.resolve("build.gradle.kts"); + Path rootBuild = workspaceDir.resolve("build.gradle"); + + List manifestPaths = new ArrayList<>(); + if (Files.isRegularFile(rootBuildKts)) { + manifestPaths.add(rootBuildKts); + } else if (Files.isRegularFile(rootBuild)) { + manifestPaths.add(rootBuild); + } + + String gradleBin = resolveGradleBinary(workspaceDir); + Path initScriptPath = null; + try { + initScriptPath = Files.createTempFile("da-list-projects-", ".gradle"); + Files.writeString(initScriptPath, GRADLE_INIT_SCRIPT); + + Operations.ProcessExecOutput output = + Operations.runProcessGetFullOutput( + workspaceDir, + new String[] { + gradleBin, + "-q", + "--no-daemon", + "--init-script", + initScriptPath.toString(), + "daListProjects" + }, + null); + + if (output.getExitCode() != 0) { + LOG.warning( + "gradle daListProjects failed with exit code " + + output.getExitCode() + + ": " + + output.getError()); + return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns); + } + + for (var proj : parseGradleInitScriptOutput(output.getOutput())) { + if (":".equals(proj.path())) { + continue; + } + Path projDir = Path.of(proj.dir()).toAbsolutePath().normalize(); + Path buildKts = projDir.resolve("build.gradle.kts"); + Path buildGroovy = projDir.resolve("build.gradle"); + if (Files.isRegularFile(buildKts)) { + manifestPaths.add(buildKts); + } else if (Files.isRegularFile(buildGroovy)) { + manifestPaths.add(buildGroovy); + } + } + } catch (Exception e) { + LOG.log(Level.WARNING, "Failed to discover Gradle subprojects", e); + return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns); + } finally { + if (initScriptPath != null) { + try { + Files.deleteIfExists(initScriptPath); + } catch (IOException ignored) { + } + } + } + + return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns); + } + + static String resolveGradleBinary(Path startDir) { + if (Operations.getWrapperPreference("gradle")) { + String wrapperName = Operations.isWindows() ? "gradlew.bat" : "gradlew"; + String wrapper = + JavaMavenProvider.traverseForMvnw( + wrapperName, startDir.resolve("build.gradle").toString(), null); + if (wrapper != null) { + return wrapper; + } + } + return Operations.getCustomPathOrElse("gradle"); + } + + record GradleProject(String path, String dir) {} + + static List parseGradleInitScriptOutput(String raw) { + if (raw == null || raw.isBlank()) { + return List.of(); + } + String prefix = "::DA_PROJECT::"; + List projects = new ArrayList<>(); + for (String line : raw.lines().toList()) { + if (!line.startsWith(prefix)) { + continue; + } + String remainder = line.substring(prefix.length()); + int lastSep = remainder.lastIndexOf("::"); + if (lastSep < 0) { + continue; + } + String path = remainder.substring(0, lastSep); + String dir = remainder.substring(lastSep + 2); + if (!path.isEmpty() && !dir.isEmpty()) { + projects.add(new GradleProject(path, dir)); + } + } + return projects; + } +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index a4d61227..48241f57 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -35,6 +35,7 @@ exports io.github.guacsec.trustifyda.providers; exports io.github.guacsec.trustifyda.providers.javascript.model; + exports io.github.guacsec.trustifyda.providers.gradle.workspace; exports io.github.guacsec.trustifyda.providers.javascript.workspace; exports io.github.guacsec.trustifyda.providers.rust.model; exports io.github.guacsec.trustifyda.providers.golang.model; diff --git a/src/test/java/io/github/guacsec/trustifyda/impl/GradleWorkspaceDiscoveryTest.java b/src/test/java/io/github/guacsec/trustifyda/providers/gradle/workspace/GradleWorkspaceDiscoveryTest.java similarity index 68% rename from src/test/java/io/github/guacsec/trustifyda/impl/GradleWorkspaceDiscoveryTest.java rename to src/test/java/io/github/guacsec/trustifyda/providers/gradle/workspace/GradleWorkspaceDiscoveryTest.java index 9fe328d5..1af9e53c 100644 --- a/src/test/java/io/github/guacsec/trustifyda/impl/GradleWorkspaceDiscoveryTest.java +++ b/src/test/java/io/github/guacsec/trustifyda/providers/gradle/workspace/GradleWorkspaceDiscoveryTest.java @@ -14,13 +14,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.github.guacsec.trustifyda.impl; +package io.github.guacsec.trustifyda.providers.gradle.workspace; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; +import io.github.guacsec.trustifyda.providers.JavaMavenProvider; import io.github.guacsec.trustifyda.tools.Operations; import java.io.File; import java.io.IOException; @@ -45,7 +46,7 @@ void parseGradleInitScriptOutput_standardOutput() { + "::DA_PROJECT:::app::/home/project/app\n" + "::DA_PROJECT:::lib::/home/project/lib\n"; - List result = ExhortApi.parseGradleInitScriptOutput(raw); + var result = GradleWorkspaceDiscovery.parseGradleInitScriptOutput(raw); assertThat(result).hasSize(3); assertThat(result.get(0).path()).isEqualTo(":"); @@ -61,7 +62,7 @@ void parseGradleInitScriptOutput_nestedProjects() { + "::DA_PROJECT:::libs:core::/home/project/libs/core\n" + "::DA_PROJECT:::libs:util::/home/project/libs/util\n"; - List result = ExhortApi.parseGradleInitScriptOutput(raw); + var result = GradleWorkspaceDiscovery.parseGradleInitScriptOutput(raw); assertThat(result).hasSize(3); assertThat(result.get(1).path()).isEqualTo(":libs:core"); @@ -70,28 +71,28 @@ void parseGradleInitScriptOutput_nestedProjects() { @Test void parseGradleInitScriptOutput_nullInput() { - assertThat(ExhortApi.parseGradleInitScriptOutput(null)).isEmpty(); + assertThat(GradleWorkspaceDiscovery.parseGradleInitScriptOutput(null)).isEmpty(); } @Test void parseGradleInitScriptOutput_emptyInput() { - assertThat(ExhortApi.parseGradleInitScriptOutput("")).isEmpty(); + assertThat(GradleWorkspaceDiscovery.parseGradleInitScriptOutput("")).isEmpty(); } @Test void parseGradleInitScriptOutput_ignoresNonPrefixedLines() { String raw = "some gradle log output\n::DA_PROJECT:::app::/home/project/app\nmore output\n"; - List result = ExhortApi.parseGradleInitScriptOutput(raw); + var result = GradleWorkspaceDiscovery.parseGradleInitScriptOutput(raw); assertThat(result).hasSize(1); assertThat(result.getFirst().path()).isEqualTo(":app"); } - // --- discoverWorkspaceManifests tests (require mocking Operations) --- + // --- discoverSubprojects tests (require mocking Operations) --- @Test - void discoverWorkspaceManifests_gradleMultiProject() throws IOException { + void discoverSubprojects_gradleMultiProject() throws IOException { Path workspaceDir = GRADLE_FIXTURES.resolve("gradle_multi_project").toAbsolutePath().normalize(); @@ -118,8 +119,7 @@ void discoverWorkspaceManifests_gradleMultiProject() throws IOException { eq(workspaceDir), any(String[].class), isNull())) .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); - ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); - List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + List manifests = GradleWorkspaceDiscovery.discoverSubprojects(workspaceDir, Set.of()); assertThat(manifests).hasSize(3); assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle")); @@ -131,7 +131,7 @@ void discoverWorkspaceManifests_gradleMultiProject() throws IOException { } @Test - void discoverWorkspaceManifests_nestedSubprojects() throws IOException { + void discoverSubprojects_nestedSubprojects() throws IOException { Path workspaceDir = GRADLE_FIXTURES.resolve("gradle_nested_subprojects").toAbsolutePath().normalize(); @@ -158,8 +158,7 @@ void discoverWorkspaceManifests_nestedSubprojects() throws IOException { eq(workspaceDir), any(String[].class), isNull())) .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); - ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); - List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + List manifests = GradleWorkspaceDiscovery.discoverSubprojects(workspaceDir, Set.of()); assertThat(manifests).hasSize(3); assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle")); @@ -179,7 +178,7 @@ void discoverWorkspaceManifests_nestedSubprojects() throws IOException { } @Test - void discoverWorkspaceManifests_mixedGroovyAndKotlin() throws IOException { + void discoverSubprojects_mixedGroovyAndKotlin() throws IOException { Path workspaceDir = GRADLE_FIXTURES.resolve("gradle_mixed_variants").toAbsolutePath().normalize(); @@ -206,8 +205,7 @@ void discoverWorkspaceManifests_mixedGroovyAndKotlin() throws IOException { eq(workspaceDir), any(String[].class), isNull())) .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); - ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); - List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + List manifests = GradleWorkspaceDiscovery.discoverSubprojects(workspaceDir, Set.of()); assertThat(manifests).hasSize(3); assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle.kts")); @@ -219,7 +217,7 @@ void discoverWorkspaceManifests_mixedGroovyAndKotlin() throws IOException { } @Test - void discoverWorkspaceManifests_noSubprojects() throws IOException { + void discoverSubprojects_noSubprojects() throws IOException { Path workspaceDir = GRADLE_FIXTURES.resolve("gradle_no_subprojects").toAbsolutePath().normalize(); @@ -237,8 +235,7 @@ void discoverWorkspaceManifests_noSubprojects() throws IOException { eq(workspaceDir), any(String[].class), isNull())) .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); - ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); - List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + List manifests = GradleWorkspaceDiscovery.discoverSubprojects(workspaceDir, Set.of()); assertThat(manifests).hasSize(1); assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle")); @@ -246,7 +243,7 @@ void discoverWorkspaceManifests_noSubprojects() throws IOException { } @Test - void discoverWorkspaceManifests_gradleCommandFails() throws IOException { + void discoverSubprojects_gradleCommandFails() throws IOException { Path workspaceDir = GRADLE_FIXTURES.resolve("gradle_multi_project").toAbsolutePath().normalize(); @@ -262,8 +259,7 @@ void discoverWorkspaceManifests_gradleCommandFails() throws IOException { eq(workspaceDir), any(String[].class), isNull())) .thenReturn(new Operations.ProcessExecOutput("", "error", 1)); - ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); - List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + List manifests = GradleWorkspaceDiscovery.discoverSubprojects(workspaceDir, Set.of()); assertThat(manifests).hasSize(1); assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle")); @@ -271,7 +267,7 @@ void discoverWorkspaceManifests_gradleCommandFails() throws IOException { } @Test - void discoverWorkspaceManifests_missingSubprojectDirectory() throws IOException { + void discoverSubprojects_missingSubprojectDirectory() throws IOException { Path workspaceDir = GRADLE_FIXTURES.resolve("gradle_missing_subproject").toAbsolutePath().normalize(); @@ -298,8 +294,7 @@ void discoverWorkspaceManifests_missingSubprojectDirectory() throws IOException eq(workspaceDir), any(String[].class), isNull())) .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); - ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); - List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + List manifests = GradleWorkspaceDiscovery.discoverSubprojects(workspaceDir, Set.of()); assertThat(manifests).hasSize(2); assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle")); @@ -309,7 +304,7 @@ void discoverWorkspaceManifests_missingSubprojectDirectory() throws IOException } @Test - void discoverWorkspaceManifests_ignorePatternFiltering() throws IOException { + void discoverSubprojects_ignorePatternFiltering() throws IOException { Path workspaceDir = GRADLE_FIXTURES.resolve("gradle_multi_project").toAbsolutePath().normalize(); @@ -336,20 +331,108 @@ void discoverWorkspaceManifests_ignorePatternFiltering() throws IOException { eq(workspaceDir), any(String[].class), isNull())) .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); - ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); - List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of("**/lib/**")); + List manifests = + GradleWorkspaceDiscovery.discoverSubprojects(workspaceDir, Set.of("**/lib/**")); assertThat(manifests).anyMatch(p -> p.toString().contains("app")); assertThat(manifests).noneMatch(p -> p.toString().contains("lib")); } } + // --- wrapper preference tests --- + @Test - void defaultIgnorePatterns_includesBuildAndGradle() { - ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); - Set resolvedPatterns = api.resolveIgnorePatterns(null); + void discoverSubprojects_usesGradleWrapperWhenPreferred() throws IOException { + Path workspaceDir = + GRADLE_FIXTURES.resolve("gradle_multi_project").toAbsolutePath().normalize(); + + String initScriptOutput = + "::DA_PROJECT:::::" + + workspaceDir + + "\n" + + "::DA_PROJECT:::app::" + + workspaceDir.resolve("app") + + "\n"; - assertThat(resolvedPatterns).contains("**/build/**"); - assertThat(resolvedPatterns).contains("**/.gradle/**"); + String expectedWrapperPath = workspaceDir.resolve("gradlew").toString(); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class); + MockedStatic mockMaven = Mockito.mockStatic(JavaMavenProvider.class)) { + + mockOps.when(() -> Operations.getWrapperPreference("gradle")).thenReturn(true); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + + mockMaven + .when( + () -> + JavaMavenProvider.traverseForMvnw( + eq("gradlew"), eq(workspaceDir.resolve("build.gradle").toString()), isNull())) + .thenReturn(expectedWrapperPath); + + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenAnswer( + invocation -> { + String[] cmd = invocation.getArgument(1); + assertThat(cmd[0]).isEqualTo(expectedWrapperPath); + return new Operations.ProcessExecOutput(initScriptOutput, "", 0); + }); + + List manifests = GradleWorkspaceDiscovery.discoverSubprojects(workspaceDir, Set.of()); + + assertThat(manifests).isNotEmpty(); + mockMaven.verify( + () -> + JavaMavenProvider.traverseForMvnw( + eq("gradlew"), eq(workspaceDir.resolve("build.gradle").toString()), isNull())); + } + } + + @Test + void discoverSubprojects_fallsBackWhenWrapperNotFound() throws IOException { + Path workspaceDir = + GRADLE_FIXTURES.resolve("gradle_multi_project").toAbsolutePath().normalize(); + + String initScriptOutput = + "::DA_PROJECT:::::" + + workspaceDir + + "\n" + + "::DA_PROJECT:::app::" + + workspaceDir.resolve("app") + + "\n"; + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class); + MockedStatic mockMaven = Mockito.mockStatic(JavaMavenProvider.class)) { + + mockOps.when(() -> Operations.getWrapperPreference("gradle")).thenReturn(true); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); + + mockMaven + .when( + () -> + JavaMavenProvider.traverseForMvnw( + eq("gradlew"), eq(workspaceDir.resolve("build.gradle").toString()), isNull())) + .thenReturn(null); + + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenAnswer( + invocation -> { + String[] cmd = invocation.getArgument(1); + assertThat(cmd[0]).isEqualTo("gradle"); + return new Operations.ProcessExecOutput(initScriptOutput, "", 0); + }); + + List manifests = GradleWorkspaceDiscovery.discoverSubprojects(workspaceDir, Set.of()); + + assertThat(manifests).isNotEmpty(); + } } }