Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion internal/detectors/gradle/detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
Expand Down Expand Up @@ -158,7 +159,7 @@ func (d Detector) runDependencies(stderr io.Writer, workingDir string, verbose b
return nil, fmt.Errorf("run gradle dependencies: %w", err)
}

depsGraph, err := depGraphFromGradleOutput(gradleOut.Bytes(), filepath.Base(workingDir))
depsGraph, err := depGraphFromGradleOutput(gradleOut.Bytes(), gradleRootName(workingDir))
if err != nil {
logger.Error(fmt.Sprintf("Failed to map Gradle output to a dependency graph: %v", err))
logger.Debug("gradle output mapping failed", zap.Error(err))
Expand Down Expand Up @@ -306,6 +307,24 @@ func depGraphFromGradleOutput(raw []byte, rootName string) (*sdk.Graph, error) {
return depsGraph, nil
}

var gradleRootProjectNamePattern = regexp.MustCompile(`(?m)\brootProject\.name\s*=\s*["']([^"']+)["']`)

func gradleRootName(workingDir string) string {
for _, name := range []string{"settings.gradle", "settings.gradle.kts"} {
raw, err := os.ReadFile(filepath.Join(workingDir, name))
if err != nil {
continue
}
matches := gradleRootProjectNamePattern.FindSubmatch(raw)
if len(matches) == 2 {
if value := strings.TrimSpace(string(matches[1])); value != "" {
return value
}
}
}
return filepath.Base(workingDir)
}

func isGradleConfigurationHeader(line string) bool {
if strings.HasPrefix(line, "Root project") || strings.HasPrefix(line, "Project '") {
return false
Expand Down
50 changes: 50 additions & 0 deletions internal/detectors/gradle/detector_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package gradle

import (
"bytes"
"context"
"os"
"path/filepath"
Expand Down Expand Up @@ -197,3 +198,52 @@ func TestDepGraphFromGradleOutput_UsesResolvedVersion(t *testing.T) {
t.Fatalf("expected resolved version package to exist")
}
}

func TestGradleRootName_ReadsSettingsGradle(t *testing.T) {
projectDir := t.TempDir()
if err := os.WriteFile(filepath.Join(projectDir, "settings.gradle"), []byte("rootProject.name = 'example-java-gradle'\n"), 0o644); err != nil {
t.Fatalf("write settings.gradle: %v", err)
}

if got := gradleRootName(projectDir); got != "example-java-gradle" {
t.Fatalf("gradleRootName() = %q, want example-java-gradle", got)
}
}

func TestGradleRootName_ReadsSettingsGradleKts(t *testing.T) {
projectDir := t.TempDir()
if err := os.WriteFile(filepath.Join(projectDir, "settings.gradle.kts"), []byte("rootProject.name = \"example-kts\"\n"), 0o644); err != nil {
t.Fatalf("write settings.gradle.kts: %v", err)
}

if got := gradleRootName(projectDir); got != "example-kts" {
t.Fatalf("gradleRootName() = %q, want example-kts", got)
}
}

func TestRunDependencies_UsesSettingsGradleRootName(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell fixture is unix-only")
}

projectDir := t.TempDir()
if err := os.WriteFile(filepath.Join(projectDir, "settings.gradle"), []byte("rootProject.name = 'example-java-gradle'\n"), 0o644); err != nil {
t.Fatalf("write settings.gradle: %v", err)
}
gradlePath := filepath.Join(projectDir, "gradle-fixture")
script := "#!/bin/sh\ncat <<'OUT'\nruntimeClasspath - Runtime classpath of source set 'main'.\n\\--- org.springframework:spring-core:6.1.1\nOUT\n"
if err := os.WriteFile(gradlePath, []byte(script), 0o755); err != nil {
t.Fatalf("write gradle fixture: %v", err)
}
Comment on lines +233 to +237

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Move fake Gradle binary setup into shared TestMain fixtures.

Line 233-Line 237 create a fake Gradle executable inline; this should be centralized in the shared TestMain fake-binary setup to keep fixture behavior consistent across detector tests.

As per coding guidelines, "Fake binaries (npm, go, Gradle, plugin) must be built in TestMain — see internal/cli/root_test_main_test.go."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/detectors/gradle/detector_test.go` around lines 233 - 237, Remove
the inline fake Gradle binary creation code that uses os.WriteFile to create the
gradlePath fixture in this test. Instead, integrate this Gradle script setup
into the shared TestMain function fixture setup in the test file (following the
pattern used in internal/cli/root_test_main_test.go for other fake binaries like
npm and go), then have the test reference the centralized fixture rather than
creating it locally. This ensures all fake binary setup for Gradle is consistent
and centralized in TestMain.

Source: Coding guidelines


g, err := (Detector{}).runDependencies(&bytes.Buffer{}, projectDir, false, gradlePath, nil)
if err != nil {
t.Fatalf("runDependencies() error = %v", err)
}
if _, ok := g.Node("example-java-gradle"); !ok {
t.Fatalf("expected settings.gradle root node")
}
if _, ok := g.Node(filepath.Base(projectDir)); ok {
t.Fatalf("did not expect temp directory root node")
}
}
22 changes: 22 additions & 0 deletions internal/detectors/maven/detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
Expand Down Expand Up @@ -182,9 +183,30 @@ func wrapCommand(path string) (string, []string, error) {
if runtime.GOOS == "windows" && (ext == ".cmd" || ext == ".bat") {
return "cmd", []string{"/c", path}, nil
}
if err := ensureExecutableMavenWrapper(path); err != nil {
return "", nil, err
}
return path, nil, nil
}

func ensureExecutableMavenWrapper(path string) error {
if runtime.GOOS == "windows" {
return nil
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("stat maven wrapper: %w", err)
}
mode := info.Mode()
if mode&0o111 != 0 {
return nil
}
if err := os.Chmod(path, mode|0o755); err != nil {
return fmt.Errorf("chmod maven wrapper executable: %w", err)
}
return nil
}

func findWrapper(workingDir string) (string, bool, error) {
for _, name := range wrapperCandidates() {
candidate := filepath.Join(workingDir, name)
Expand Down
30 changes: 30 additions & 0 deletions internal/detectors/maven/detector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,36 @@ func TestMavenDetectorResolveRunner_PrefersWrapper(t *testing.T) {
}
}

func TestMavenDetectorResolveRunner_MakesUnixWrapperExecutable(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix-only executable-bit behavior")
}

projectDir := t.TempDir()
wrapperPath := filepath.Join(projectDir, "mvnw")
if err := os.WriteFile(wrapperPath, []byte("wrapper\n"), 0o644); err != nil {
t.Fatalf("write wrapper: %v", err)
}
t.Setenv("PATH", "")

detector := Detector{WorkingDir: projectDir}
executable, _, err := detector.resolveRunner()
if err != nil {
t.Fatalf("resolveRunner() error = %v", err)
}
if executable != wrapperPath {
t.Fatalf("expected wrapper executable %q, got %q", wrapperPath, executable)
}

info, err := os.Stat(wrapperPath)
if err != nil {
t.Fatalf("stat wrapper: %v", err)
}
if info.Mode()&0o111 == 0 {
t.Fatalf("expected wrapper to be executable, mode=%#o", info.Mode().Perm())
}
}

func TestMavenDetectorResolveRunner_FallsBackToInstalledMaven(t *testing.T) {
originalLookPath := execLookPath
t.Cleanup(func() { execLookPath = originalLookPath })
Expand Down
67 changes: 67 additions & 0 deletions internal/detectors/sbt/detector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package sbt

import (
"context"
"os"
"path/filepath"
"testing"

Expand Down Expand Up @@ -64,3 +65,69 @@ func TestDepGraphFromSBTDependencyTreePreservesScalaArtifactSuffix(t *testing.T)
t.Fatalf("expected cats-kernel_2.13 child, got %#v", children)
}
}

func TestNativeDetectorApplicable_SkipsOldSBTWithoutDependencyGraphPlugin(t *testing.T) {
projectDir := t.TempDir()
if err := os.WriteFile(filepath.Join(projectDir, "build.sbt"), []byte(`libraryDependencies += "org.mindrot" % "jbcrypt" % "0.3m"`), 0o644); err != nil {
t.Fatalf("write build.sbt: %v", err)
}
if err := os.MkdirAll(filepath.Join(projectDir, "project"), 0o755); err != nil {
t.Fatalf("mkdir project: %v", err)
}
if err := os.WriteFile(filepath.Join(projectDir, "project", "build.properties"), []byte("sbt.version = 0.13.16\n"), 0o644); err != nil {
t.Fatalf("write build.properties: %v", err)
}

applicable, err := (NativeDetector{WorkingDir: projectDir}).Applicable(context.Background(), sdk.DetectionRequest{ProjectPath: projectDir})
if err != nil {
t.Fatalf("Applicable() error = %v", err)
}
if applicable {
t.Fatalf("expected old sbt project without dependency graph plugin to skip native detector")
}
}

func TestNativeDetectorApplicable_AllowsOldSBTWithDependencyGraphPlugin(t *testing.T) {
projectDir := t.TempDir()
if err := os.WriteFile(filepath.Join(projectDir, "build.sbt"), []byte(`libraryDependencies += "org.mindrot" % "jbcrypt" % "0.3m"`), 0o644); err != nil {
t.Fatalf("write build.sbt: %v", err)
}
if err := os.MkdirAll(filepath.Join(projectDir, "project"), 0o755); err != nil {
t.Fatalf("mkdir project: %v", err)
}
if err := os.WriteFile(filepath.Join(projectDir, "project", "build.properties"), []byte("sbt.version = 0.13.16\n"), 0o644); err != nil {
t.Fatalf("write build.properties: %v", err)
}
if err := os.WriteFile(filepath.Join(projectDir, "project", "plugins.sbt"), []byte(`addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.9.2")`), 0o644); err != nil {
t.Fatalf("write plugins.sbt: %v", err)
}

applicable, err := (NativeDetector{WorkingDir: projectDir}).Applicable(context.Background(), sdk.DetectionRequest{ProjectPath: projectDir})
if err != nil {
t.Fatalf("Applicable() error = %v", err)
}
if !applicable {
t.Fatalf("expected old sbt project with dependency graph plugin to use native detector")
}
}

func TestNativeDetectorApplicable_AllowsModernSBT(t *testing.T) {
projectDir := t.TempDir()
if err := os.WriteFile(filepath.Join(projectDir, "build.sbt"), []byte(`libraryDependencies += "org.mindrot" % "jbcrypt" % "0.3m"`), 0o644); err != nil {
t.Fatalf("write build.sbt: %v", err)
}
if err := os.MkdirAll(filepath.Join(projectDir, "project"), 0o755); err != nil {
t.Fatalf("mkdir project: %v", err)
}
if err := os.WriteFile(filepath.Join(projectDir, "project", "build.properties"), []byte("sbt.version = 1.10.0\n"), 0o644); err != nil {
t.Fatalf("write build.properties: %v", err)
}

applicable, err := (NativeDetector{WorkingDir: projectDir}).Applicable(context.Background(), sdk.DetectionRequest{ProjectPath: projectDir})
if err != nil {
t.Fatalf("Applicable() error = %v", err)
}
if !applicable {
t.Fatalf("expected modern sbt project to use native detector")
}
}
79 changes: 78 additions & 1 deletion internal/detectors/sbt/sbt_native.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -37,7 +40,19 @@ func (d NativeDetector) Ready() bool {

// Applicable reports whether sbt build files are present.
func (d NativeDetector) Applicable(ctx context.Context, req sdk.DetectionRequest) (bool, error) {
return (Detector{WorkingDir: d.workingDir(req.ProjectPath)}).Applicable(ctx, req)
workingDir := d.workingDir(req.ProjectPath)
applicable, err := (Detector{WorkingDir: workingDir}).Applicable(ctx, req)
if err != nil || !applicable {
return applicable, err
}
if requiresDependencyGraphPlugin(workingDir) && !hasDependencyGraphPlugin(workingDir) {
d.logger().Debug("sbt native detector skipped: dependencyTree task is unavailable for this sbt version",
zap.String("working_dir", workingDir),
zap.String("sbt_version", sbtVersion(workingDir)),
)
return false, nil
}
return true, nil
}

// Descriptor describes the sbt native detector.
Expand Down Expand Up @@ -99,6 +114,68 @@ func (d NativeDetector) logger() *zap.Logger {
return zap.NewNop()
}

func requiresDependencyGraphPlugin(workingDir string) bool {
version := sbtVersion(workingDir)
if version == "" {
return false
}
major, minor, ok := parseSBTMajorMinor(version)
if !ok {
return false
}
return major == 0 || (major == 1 && minor < 4)
}

func sbtVersion(workingDir string) string {
raw, err := os.ReadFile(filepath.Join(workingDir, "project", "build.properties"))
if err != nil {
return ""
}
for _, line := range strings.Split(string(raw), "\n") {
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
if strings.TrimSpace(key) == "sbt.version" {
return strings.TrimSpace(value)
}
}
return ""
}

func parseSBTMajorMinor(version string) (int, int, bool) {
parts := strings.Split(strings.TrimSpace(version), ".")
if len(parts) < 2 {
return 0, 0, false
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, false
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, false
}
return major, minor, true
}

func hasDependencyGraphPlugin(workingDir string) bool {
for _, name := range []string{
filepath.Join("project", "plugins.sbt"),
filepath.Join("project", "build.sbt"),
"build.sbt",
} {
raw, err := os.ReadFile(filepath.Join(workingDir, name))
if err != nil {
continue
}
if strings.Contains(string(raw), "sbt-dependency-graph") {
return true
Comment on lines +162 to +173

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Plugin detection is too broad and can false-positive on comments/text.

At Line 172, strings.Contains(..., "sbt-dependency-graph") treats any mention (including comments) as plugin presence, which can incorrectly keep native mode enabled for old SBT projects.

Suggested change
+var sbtDependencyGraphPluginDecl = regexp.MustCompile(`(?m)^\s*addSbtPlugin\([^)\n]*"sbt-dependency-graph"`)
+
 func hasDependencyGraphPlugin(workingDir string) bool {
 	for _, name := range []string{
 		filepath.Join("project", "plugins.sbt"),
 		filepath.Join("project", "build.sbt"),
 		"build.sbt",
 	} {
 		raw, err := os.ReadFile(filepath.Join(workingDir, name))
 		if err != nil {
 			continue
 		}
-		if strings.Contains(string(raw), "sbt-dependency-graph") {
+		if sbtDependencyGraphPluginDecl.Match(raw) {
 			return true
 		}
 	}
 	return false
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/detectors/sbt/sbt_native.go` around lines 162 - 173, The
hasDependencyGraphPlugin function uses strings.Contains to check for
"sbt-dependency-graph" in the file content, which matches both actual plugin
declarations and commented-out text, causing false positives. Instead of simple
string matching, parse the file content more carefully to identify only actual
plugin declarations by checking for the proper SBT syntax patterns (such as
addSbtPlugin directives) rather than matching any occurrence of the plugin name
string. This will prevent commented-out or incidental mentions from incorrectly
triggering native mode enablement.

}
}
return false
}

// sbtDepTreeLinePattern matches a dependency line from sbt dependencyTree.
// Examples:
//
Expand Down