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": [] + } + ] + } + ] +}