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
148 changes: 148 additions & 0 deletions internal/maven/gradle.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package maven

import (
"encoding/json"
"encoding/xml"
"errors"
"regexp"
"strings"

Expand All @@ -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.
Expand Down Expand Up @@ -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)
}
}
}
60 changes: 60 additions & 0 deletions internal/maven/maven.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package maven

import (
"encoding/json"
"encoding/xml"
"regexp"
"strings"
Expand All @@ -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.
Expand Down Expand Up @@ -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)
}
}
}
151 changes: 151 additions & 0 deletions internal/maven/maven_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Loading