diff --git a/internal/golang/golang.go b/internal/golang/golang.go index 05c8704..81fc3e9 100644 --- a/internal/golang/golang.go +++ b/internal/golang/golang.go @@ -23,56 +23,101 @@ var ( // Multi-line require block entry: example.com/pkg v1.2.3 // indirect requireEntryRegex = regexp.MustCompile(`^\s*(\S+)\s+(\S+)(?:\s*//.*)?$`) + + // Single-line tool: tool example.com/pkg/cmd/foo + singleToolRegex = regexp.MustCompile(`^\s*tool\s+(\S+)`) + + // Multi-line tool block entry: example.com/pkg/cmd/foo + toolEntryRegex = regexp.MustCompile(`^\s*(\S+)\s*$`) ) func (p *goModParser) Parse(filename string, content []byte) ([]core.Dependency, error) { - var deps []core.Dependency lines := strings.Split(string(content), "\n") + // First pass: collect all tool paths + tools := make(map[string]bool) + inToolBlock := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if trimmed == "" || strings.HasPrefix(trimmed, "//") { + continue + } + + if strings.HasPrefix(trimmed, "tool (") || trimmed == "tool (" { + inToolBlock = true + continue + } + + if inToolBlock && trimmed == ")" { + inToolBlock = false + continue + } + + if strings.HasPrefix(trimmed, "tool ") && !strings.Contains(trimmed, "(") { + if match := singleToolRegex.FindStringSubmatch(trimmed); match != nil { + tools[match[1]] = true + } + continue + } + + if inToolBlock { + if match := toolEntryRegex.FindStringSubmatch(trimmed); match != nil { + tools[match[1]] = true + } + } + } + + // Second pass: parse require directives and check against tools + var deps []core.Dependency inRequireBlock := false for _, line := range lines { trimmed := strings.TrimSpace(line) - // Skip empty lines and comments if trimmed == "" || strings.HasPrefix(trimmed, "//") { continue } - // Detect require block start if strings.HasPrefix(trimmed, "require (") || trimmed == "require (" { inRequireBlock = true continue } - // Detect require block end if inRequireBlock && trimmed == ")" { inRequireBlock = false continue } - // Single-line require if strings.HasPrefix(trimmed, "require ") && !strings.Contains(trimmed, "(") { if match := singleRequireRegex.FindStringSubmatch(trimmed); match != nil { direct := !strings.Contains(line, "// indirect") + scope := core.Runtime + if isToolModule(match[1], tools) { + scope = core.Development + } deps = append(deps, core.Dependency{ Name: match[1], Version: match[2], - Scope: core.Runtime, + Scope: scope, Direct: direct, }) } continue } - // Inside require block if inRequireBlock { if match := requireEntryRegex.FindStringSubmatch(trimmed); match != nil { direct := !strings.Contains(line, "// indirect") + scope := core.Runtime + if isToolModule(match[1], tools) { + scope = core.Development + } deps = append(deps, core.Dependency{ Name: match[1], Version: match[2], - Scope: core.Runtime, + Scope: scope, Direct: direct, }) } @@ -82,6 +127,22 @@ func (p *goModParser) Parse(filename string, content []byte) ([]core.Dependency, return deps, nil } +// isToolModule checks if a module is used by any tool. +// A module matches if it equals a tool path or if a tool path starts with the module path. +func isToolModule(module string, tools map[string]bool) bool { + if tools[module] { + return true + } + // Check if any tool path starts with this module + // e.g., module "golang.org/x/tools" matches tool "golang.org/x/tools/cmd/stringer" + for tool := range tools { + if strings.HasPrefix(tool, module+"/") { + return true + } + } + return false +} + // goSumParser parses go.sum files. type goSumParser struct{} diff --git a/internal/golang/golang_test.go b/internal/golang/golang_test.go index 4aaa531..8928abf 100644 --- a/internal/golang/golang_test.go +++ b/internal/golang/golang_test.go @@ -60,6 +60,28 @@ func TestGoMod(t *testing.T) { t.Errorf("net version = %q, want %q", net.Version, "v1.2.3") } } + + // Check tool dependencies are marked as Development scope + if report, ok := depMap["github.com/jstemmer/go-junit-report"]; !ok { + t.Error("expected github.com/jstemmer/go-junit-report dependency") + } else { + if report.Scope != core.Development { + t.Errorf("go-junit-report scope = %v, want Development", report.Scope) + } + } + + if reportV2, ok := depMap["github.com/jstemmer/go-junit-report/v2"]; !ok { + t.Error("expected github.com/jstemmer/go-junit-report/v2 dependency") + } else { + if reportV2.Scope != core.Development { + t.Errorf("go-junit-report/v2 scope = %v, want Development", reportV2.Scope) + } + } + + // Non-tool dependencies should be Runtime scope + if depMap["github.com/gomodule/redigo"].Scope != core.Runtime { + t.Errorf("redigo scope = %v, want Runtime", depMap["github.com/gomodule/redigo"].Scope) + } } func TestGoSum(t *testing.T) { @@ -437,6 +459,62 @@ func TestGoSingleRequireMod(t *testing.T) { } } +func TestGoModToolDependencies(t *testing.T) { + // Test that tool dependencies are marked as Development scope + content := []byte(`module test + +go 1.24 + +require ( + example.com/runtime-pkg v1.0.0 + example.com/tool-pkg v2.0.0 + golang.org/x/tools v0.20.0 +) + +tool example.com/tool-pkg/cmd/mytool + +tool ( + golang.org/x/tools/cmd/stringer +) +`) + + parser := &goModParser{} + deps, err := parser.Parse("go.mod", content) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(deps) != 3 { + t.Fatalf("expected 3 dependencies, got %d", len(deps)) + } + + depMap := make(map[string]core.Dependency) + for _, d := range deps { + depMap[d.Name] = d + } + + // Runtime dependency should be Runtime scope + if runtime, ok := depMap["example.com/runtime-pkg"]; !ok { + t.Error("expected example.com/runtime-pkg dependency") + } else if runtime.Scope != core.Runtime { + t.Errorf("runtime-pkg scope = %v, want Runtime", runtime.Scope) + } + + // Tool dependency (exact match) should be Development scope + if tool, ok := depMap["example.com/tool-pkg"]; !ok { + t.Error("expected example.com/tool-pkg dependency") + } else if tool.Scope != core.Development { + t.Errorf("tool-pkg scope = %v, want Development", tool.Scope) + } + + // Tool dependency (module is prefix of tool path) should be Development scope + if tools, ok := depMap["golang.org/x/tools"]; !ok { + t.Error("expected golang.org/x/tools dependency") + } else if tools.Scope != core.Development { + t.Errorf("golang.org/x/tools scope = %v, want Development", tools.Scope) + } +} + func TestGoResolvedDepsJSON(t *testing.T) { content, err := os.ReadFile("../../testdata/golang/go-resolved-dependencies.json") if err != nil { diff --git a/internal/maven/gradle.go b/internal/maven/gradle.go index 4353d34..fcc8dd6 100644 --- a/internal/maven/gradle.go +++ b/internal/maven/gradle.go @@ -1,7 +1,9 @@ package maven import ( + "encoding/json" "encoding/xml" + "errors" "regexp" "strings" @@ -18,6 +20,12 @@ func init() { // verification-metadata.xml - lockfile (gradle dependency verification) core.Register("maven", core.Lockfile, &gradleVerificationParser{}, core.ExactMatch("verification-metadata.xml")) + + // dependencies.lock - lockfile (Nebula dependency-lock plugin) + core.Register("maven", core.Lockfile, &nebulaLockParser{}, core.ExactMatch("dependencies.lock")) + + // gradle-html-dependency-report.js - lockfile (gradle htmlDependencyReport task) + core.Register("maven", core.Lockfile, &gradleHtmlReportParser{}, core.ExactMatch("gradle-html-dependency-report.js")) } // gradleParser parses build.gradle and build.gradle.kts files. @@ -324,3 +332,143 @@ func (p *gradleVerificationParser) Parse(filename string, content []byte) ([]cor return deps, nil } + +// nebulaLockParser parses dependencies.lock files (Nebula gradle-dependency-lock-plugin). +type nebulaLockParser struct{} + +func (p *nebulaLockParser) Parse(filename string, content []byte) ([]core.Dependency, error) { + var lockfile map[string]map[string]nebulaLockEntry + if err := json.Unmarshal(content, &lockfile); err != nil { + return nil, &core.ParseError{Filename: filename, Err: err} + } + + var deps []core.Dependency + seen := make(map[string]bool) + + for config, entries := range lockfile { + isTest := strings.Contains(strings.ToLower(config), "test") + + for name, entry := range entries { + if entry.Locked == "" || seen[name] { + continue + } + seen[name] = true + + scope := core.Runtime + if isTest { + scope = core.Test + } + + // Direct deps have "requested", transitive have "firstLevelTransitive" + direct := entry.Requested != "" + + deps = append(deps, core.Dependency{ + Name: name, + Version: entry.Locked, + Scope: scope, + Direct: direct, + }) + } + } + + return deps, nil +} + +type nebulaLockEntry struct { + Locked string `json:"locked"` + Requested string `json:"requested"` + FirstLevelTransitive []string `json:"firstLevelTransitive"` + Project bool `json:"project"` +} + +// gradleHtmlReportParser parses gradle-html-dependency-report.js files. +type gradleHtmlReportParser struct{} + +func (p *gradleHtmlReportParser) Parse(filename string, content []byte) ([]core.Dependency, error) { + // Extract JSON from: window.project = { ... }; + text := string(content) + + // Find the start of the JSON object + start := strings.Index(text, "window.project = ") + if start < 0 { + start = strings.Index(text, "window.project=") + if start < 0 { + return nil, &core.ParseError{Filename: filename, Err: errors.New("missing window.project assignment")} + } + start += len("window.project=") + } else { + start += len("window.project = ") + } + + // Find the end (last } or };) + end := strings.LastIndex(text, "}") + if end < 0 || end < start { + return nil, &core.ParseError{Filename: filename, Err: errors.New("invalid JSON structure")} + } + + jsonContent := text[start : end+1] + + var project gradleHtmlProject + if err := json.Unmarshal([]byte(jsonContent), &project); err != nil { + return nil, &core.ParseError{Filename: filename, Err: err} + } + + var deps []core.Dependency + seen := make(map[string]bool) + + for _, config := range project.Configurations { + isTest := strings.Contains(strings.ToLower(config.Name), "test") + collectGradleHtmlDeps(&deps, seen, config.Dependencies, isTest) + } + + return deps, nil +} + +type gradleHtmlProject struct { + Name string `json:"name"` + Configurations []gradleHtmlConfig `json:"configurations"` +} + +type gradleHtmlConfig struct { + Name string `json:"name"` + Dependencies []gradleHtmlDep `json:"dependencies"` +} + +type gradleHtmlDep struct { + Module string `json:"module"` + Children []gradleHtmlDep `json:"children"` +} + +func collectGradleHtmlDeps(deps *[]core.Dependency, seen map[string]bool, htmlDeps []gradleHtmlDep, isTest bool) { + for _, dep := range htmlDeps { + // Parse module: "group:artifact:version" + parts := strings.Split(dep.Module, ":") + if len(parts) < 3 { + continue + } + + name := parts[0] + ":" + parts[1] + version := parts[2] + + if !seen[name] { + seen[name] = true + + scope := core.Runtime + if isTest { + scope = core.Test + } + + *deps = append(*deps, core.Dependency{ + Name: name, + Version: version, + Scope: scope, + Direct: false, + }) + } + + // Recursively collect children + if len(dep.Children) > 0 { + collectGradleHtmlDeps(deps, seen, dep.Children, isTest) + } + } +} diff --git a/internal/maven/maven.go b/internal/maven/maven.go index a57fa29..2a78494 100644 --- a/internal/maven/maven.go +++ b/internal/maven/maven.go @@ -1,6 +1,7 @@ package maven import ( + "encoding/json" "encoding/xml" "regexp" "strings" @@ -13,6 +14,9 @@ func init() { // maven-resolved-dependencies.txt - lockfile (mvn dependency:list output) core.Register("maven", core.Lockfile, &mavenResolvedDepsParser{}, core.ExactMatch("maven-resolved-dependencies.txt")) + + // maven.graph.json - lockfile (mvn dependency:tree -DoutputType=json output) + core.Register("maven", core.Lockfile, &mavenGraphJSONParser{}, core.ExactMatch("maven.graph.json")) } // pomXMLParser parses pom.xml files. @@ -130,3 +134,59 @@ func (p *mavenResolvedDepsParser) Parse(filename string, content []byte) ([]core func stripANSI(s string) string { return ansiEscapeRegex.ReplaceAllString(s, "") } + +// mavenGraphJSONParser parses maven.graph.json files (mvn dependency:tree -DoutputType=json output). +type mavenGraphJSONParser struct{} + +type mavenGraphNode struct { + GroupID string `json:"groupId"` + ArtifactID string `json:"artifactId"` + Version string `json:"version"` + Scope string `json:"scope"` + Children []mavenGraphNode `json:"children"` +} + +func (p *mavenGraphJSONParser) Parse(filename string, content []byte) ([]core.Dependency, error) { + var root mavenGraphNode + if err := json.Unmarshal(content, &root); err != nil { + return nil, &core.ParseError{Filename: filename, Err: err} + } + + var deps []core.Dependency + seen := make(map[string]bool) + + // Collect all children (skip the root which is the project itself) + collectMavenGraphDeps(&deps, seen, root.Children) + + return deps, nil +} + +func collectMavenGraphDeps(deps *[]core.Dependency, seen map[string]bool, nodes []mavenGraphNode) { + for _, node := range nodes { + name := node.GroupID + ":" + node.ArtifactID + + if !seen[name] { + seen[name] = true + + scope := core.Runtime + switch strings.ToLower(node.Scope) { + case "test": + scope = core.Test + case "provided": + scope = core.Optional + } + + *deps = append(*deps, core.Dependency{ + Name: name, + Version: node.Version, + Scope: scope, + Direct: false, + }) + } + + // Recursively collect children + if len(node.Children) > 0 { + collectMavenGraphDeps(deps, seen, node.Children) + } + } +} diff --git a/internal/maven/maven_test.go b/internal/maven/maven_test.go index dad95df..ef8482c 100644 --- a/internal/maven/maven_test.go +++ b/internal/maven/maven_test.go @@ -619,3 +619,154 @@ func TestGradleVerificationMetadata(t *testing.T) { } } } + +func TestMavenGraphJSON(t *testing.T) { + content, err := os.ReadFile("../../testdata/maven/maven.graph.json") + if err != nil { + t.Fatalf("failed to read fixture: %v", err) + } + + parser := &mavenGraphJSONParser{} + deps, err := parser.Parse("maven.graph.json", content) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(deps) != 7 { + t.Fatalf("expected 7 dependencies, got %d", len(deps)) + } + + depMap := make(map[string]core.Dependency) + for _, d := range deps { + depMap[d.Name] = d + } + + // Verify dependencies with expected versions and scopes + expected := []struct { + name string + version string + scope core.Scope + }{ + {"org.springframework:spring-core", "5.3.23", core.Runtime}, + {"org.springframework:spring-jcl", "5.3.23", core.Runtime}, + {"com.google.guava:guava", "31.1-jre", core.Runtime}, + {"com.google.guava:failureaccess", "1.0.1", core.Runtime}, + {"junit:junit", "4.13.2", core.Test}, + {"org.hamcrest:hamcrest-core", "1.3", core.Test}, + } + + for _, exp := range expected { + dep, ok := depMap[exp.name] + if !ok { + t.Errorf("expected %s dependency", exp.name) + continue + } + if dep.Version != exp.version { + t.Errorf("%s version = %q, want %q", exp.name, dep.Version, exp.version) + } + if dep.Scope != exp.scope { + t.Errorf("%s scope = %v, want %v", exp.name, dep.Scope, exp.scope) + } + } +} + +func TestNebulaLock(t *testing.T) { + content, err := os.ReadFile("../../testdata/maven/gradle/dependencies.lock") + if err != nil { + t.Fatalf("failed to read fixture: %v", err) + } + + parser := &nebulaLockParser{} + deps, err := parser.Parse("dependencies.lock", content) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(deps) != 6 { + t.Fatalf("expected 6 dependencies, got %d", len(deps)) + } + + depMap := make(map[string]core.Dependency) + for _, d := range deps { + depMap[d.Name] = d + } + + // Verify dependencies + expected := []struct { + name string + version string + direct bool + }{ + {"com.google.guava:guava", "31.1-jre", true}, + {"com.google.guava:failureaccess", "1.0.1", false}, + {"org.springframework:spring-core", "5.3.23", true}, + {"org.springframework:spring-jcl", "5.3.23", false}, + {"junit:junit", "4.13.2", true}, + {"org.hamcrest:hamcrest-core", "1.3", false}, + } + + for _, exp := range expected { + dep, ok := depMap[exp.name] + if !ok { + t.Errorf("expected %s dependency", exp.name) + continue + } + if dep.Version != exp.version { + t.Errorf("%s version = %q, want %q", exp.name, dep.Version, exp.version) + } + if dep.Direct != exp.direct { + t.Errorf("%s direct = %v, want %v", exp.name, dep.Direct, exp.direct) + } + } +} + +func TestGradleHtmlReport(t *testing.T) { + content, err := os.ReadFile("../../testdata/maven/gradle/gradle-html-dependency-report.js") + if err != nil { + t.Fatalf("failed to read fixture: %v", err) + } + + parser := &gradleHtmlReportParser{} + deps, err := parser.Parse("gradle-html-dependency-report.js", content) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(deps) != 7 { + t.Fatalf("expected 7 dependencies, got %d", len(deps)) + } + + depMap := make(map[string]core.Dependency) + for _, d := range deps { + depMap[d.Name] = d + } + + // Verify dependencies + expected := []struct { + name string + version string + scope core.Scope + }{ + {"com.google.guava:guava", "31.1-jre", core.Runtime}, + {"com.google.guava:failureaccess", "1.0.1", core.Runtime}, + {"com.google.guava:listenablefuture", "9999.0-empty-to-avoid-conflict-with-guava", core.Runtime}, + {"org.springframework:spring-core", "5.3.23", core.Runtime}, + {"org.springframework:spring-jcl", "5.3.23", core.Runtime}, + {"junit:junit", "4.13.2", core.Test}, + {"org.hamcrest:hamcrest-core", "1.3", core.Test}, + } + + for _, exp := range expected { + dep, ok := depMap[exp.name] + if !ok { + t.Errorf("expected %s dependency", exp.name) + continue + } + if dep.Version != exp.version { + t.Errorf("%s version = %q, want %q", exp.name, dep.Version, exp.version) + } + if dep.Scope != exp.scope { + t.Errorf("%s scope = %v, want %v", exp.name, dep.Scope, exp.scope) + } + } +} diff --git a/testdata/maven/gradle/dependencies.lock b/testdata/maven/gradle/dependencies.lock new file mode 100644 index 0000000..a943b49 --- /dev/null +++ b/testdata/maven/gradle/dependencies.lock @@ -0,0 +1,58 @@ +{ + "compileClasspath": { + "com.google.guava:guava": { + "locked": "31.1-jre", + "requested": "31.+" + }, + "com.google.guava:failureaccess": { + "locked": "1.0.1", + "firstLevelTransitive": ["com.google.guava:guava"] + }, + "org.springframework:spring-core": { + "locked": "5.3.23", + "requested": "5.3.+" + }, + "org.springframework:spring-jcl": { + "locked": "5.3.23", + "firstLevelTransitive": ["org.springframework:spring-core"] + } + }, + "runtimeClasspath": { + "com.google.guava:guava": { + "locked": "31.1-jre", + "requested": "31.+" + }, + "com.google.guava:failureaccess": { + "locked": "1.0.1", + "firstLevelTransitive": ["com.google.guava:guava"] + }, + "org.springframework:spring-core": { + "locked": "5.3.23", + "requested": "5.3.+" + }, + "org.springframework:spring-jcl": { + "locked": "5.3.23", + "firstLevelTransitive": ["org.springframework:spring-core"] + } + }, + "testCompileClasspath": { + "junit:junit": { + "locked": "4.13.2", + "requested": "4.13.+" + }, + "org.hamcrest:hamcrest-core": { + "locked": "1.3", + "firstLevelTransitive": ["junit:junit"] + } + }, + "testRuntimeClasspath": { + "junit:junit": { + "locked": "4.13.2", + "requested": "4.13.+" + }, + "org.hamcrest:hamcrest-core": { + "locked": "1.3", + "firstLevelTransitive": ["junit:junit"] + } + } +} diff --git a/testdata/maven/gradle/gradle-html-dependency-report.js b/testdata/maven/gradle/gradle-html-dependency-report.js new file mode 100644 index 0000000..a003d99 --- /dev/null +++ b/testdata/maven/gradle/gradle-html-dependency-report.js @@ -0,0 +1,78 @@ +// Generated by Gradle htmlDependencyReport task +window.project = { + "name": "myproject", + "description": "", + "configurations": [ + { + "name": "compileClasspath", + "description": "Compile classpath for source set 'main'.", + "dependencies": [ + { + "module": "com.google.guava:guava:31.1-jre", + "name": "guava", + "resolvable": true, + "alreadyRendered": false, + "hasConflict": false, + "children": [ + { + "module": "com.google.guava:failureaccess:1.0.1", + "name": "failureaccess", + "resolvable": true, + "alreadyRendered": false, + "hasConflict": false, + "children": [] + }, + { + "module": "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava", + "name": "listenablefuture", + "resolvable": true, + "alreadyRendered": false, + "hasConflict": false, + "children": [] + } + ] + }, + { + "module": "org.springframework:spring-core:5.3.23", + "name": "spring-core", + "resolvable": true, + "alreadyRendered": false, + "hasConflict": false, + "children": [ + { + "module": "org.springframework:spring-jcl:5.3.23", + "name": "spring-jcl", + "resolvable": true, + "alreadyRendered": false, + "hasConflict": false, + "children": [] + } + ] + } + ] + }, + { + "name": "testCompileClasspath", + "description": "Compile classpath for source set 'test'.", + "dependencies": [ + { + "module": "junit:junit:4.13.2", + "name": "junit", + "resolvable": true, + "alreadyRendered": false, + "hasConflict": false, + "children": [ + { + "module": "org.hamcrest:hamcrest-core:1.3", + "name": "hamcrest-core", + "resolvable": true, + "alreadyRendered": false, + "hasConflict": false, + "children": [] + } + ] + } + ] + } + ] +}; diff --git a/testdata/maven/maven.graph.json b/testdata/maven/maven.graph.json new file mode 100644 index 0000000..b22edcc --- /dev/null +++ b/testdata/maven/maven.graph.json @@ -0,0 +1,68 @@ +{ + "groupId": "com.example", + "artifactId": "myproject", + "version": "1.0.0", + "type": "jar", + "scope": "compile", + "children": [ + { + "groupId": "org.springframework", + "artifactId": "spring-core", + "version": "5.3.23", + "type": "jar", + "scope": "compile", + "children": [ + { + "groupId": "org.springframework", + "artifactId": "spring-jcl", + "version": "5.3.23", + "type": "jar", + "scope": "compile", + "children": [] + } + ] + }, + { + "groupId": "com.google.guava", + "artifactId": "guava", + "version": "31.1-jre", + "type": "jar", + "scope": "compile", + "children": [ + { + "groupId": "com.google.guava", + "artifactId": "failureaccess", + "version": "1.0.1", + "type": "jar", + "scope": "compile", + "children": [] + }, + { + "groupId": "com.google.guava", + "artifactId": "listenablefuture", + "version": "9999.0-empty-to-avoid-conflict-with-guava", + "type": "jar", + "scope": "compile", + "children": [] + } + ] + }, + { + "groupId": "junit", + "artifactId": "junit", + "version": "4.13.2", + "type": "jar", + "scope": "test", + "children": [ + { + "groupId": "org.hamcrest", + "artifactId": "hamcrest-core", + "version": "1.3", + "type": "jar", + "scope": "test", + "children": [] + } + ] + } + ] +}