From 9a5efc669f9d5f16b395c438afb187d1baab84bf Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Fri, 19 Jun 2026 01:54:51 -0700 Subject: [PATCH] Fix dogfood detector edge cases --- internal/detectors/gradle/detector.go | 21 +++++- internal/detectors/gradle/detector_test.go | 50 ++++++++++++++ internal/detectors/maven/detector.go | 22 ++++++ internal/detectors/maven/detector_test.go | 30 ++++++++ internal/detectors/sbt/detector_test.go | 67 ++++++++++++++++++ internal/detectors/sbt/sbt_native.go | 79 +++++++++++++++++++++- 6 files changed, 267 insertions(+), 2 deletions(-) diff --git a/internal/detectors/gradle/detector.go b/internal/detectors/gradle/detector.go index 69fa3188..9b373811 100644 --- a/internal/detectors/gradle/detector.go +++ b/internal/detectors/gradle/detector.go @@ -8,6 +8,7 @@ import ( "io" "os" "path/filepath" + "regexp" "runtime" "strings" "time" @@ -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)) @@ -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 diff --git a/internal/detectors/gradle/detector_test.go b/internal/detectors/gradle/detector_test.go index 9c9e7841..30a1ddb7 100644 --- a/internal/detectors/gradle/detector_test.go +++ b/internal/detectors/gradle/detector_test.go @@ -1,6 +1,7 @@ package gradle import ( + "bytes" "context" "os" "path/filepath" @@ -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) + } + + 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") + } +} diff --git a/internal/detectors/maven/detector.go b/internal/detectors/maven/detector.go index 517f1e0b..4ce9312f 100644 --- a/internal/detectors/maven/detector.go +++ b/internal/detectors/maven/detector.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "os" "path/filepath" "runtime" "strings" @@ -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) diff --git a/internal/detectors/maven/detector_test.go b/internal/detectors/maven/detector_test.go index 7ddd06e6..86de7ce5 100644 --- a/internal/detectors/maven/detector_test.go +++ b/internal/detectors/maven/detector_test.go @@ -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 }) diff --git a/internal/detectors/sbt/detector_test.go b/internal/detectors/sbt/detector_test.go index f9df16dd..031679de 100644 --- a/internal/detectors/sbt/detector_test.go +++ b/internal/detectors/sbt/detector_test.go @@ -2,6 +2,7 @@ package sbt import ( "context" + "os" "path/filepath" "testing" @@ -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") + } +} diff --git a/internal/detectors/sbt/sbt_native.go b/internal/detectors/sbt/sbt_native.go index 0f05e479..9c0e6c68 100644 --- a/internal/detectors/sbt/sbt_native.go +++ b/internal/detectors/sbt/sbt_native.go @@ -4,7 +4,10 @@ import ( "bytes" "context" "fmt" + "os" + "path/filepath" "regexp" + "strconv" "strings" "time" @@ -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. @@ -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 + } + } + return false +} + // sbtDepTreeLinePattern matches a dependency line from sbt dependencyTree. // Examples: //