diff --git a/internal/maven/ivy.go b/internal/maven/ivy.go index e331979..70449cd 100644 --- a/internal/maven/ivy.go +++ b/internal/maven/ivy.go @@ -8,6 +8,10 @@ import ( func init() { core.Register("maven", core.Manifest, &ivyXMLParser{}, core.ExactMatch("ivy.xml")) + + // ivy-report.xml - lockfile (sbt dependencyLookup output) + // Files are named {org}-{module}-{conf}.xml (e.g., com.example-hello_2.12-compile.xml) + core.Register("maven", core.Lockfile, &ivyReportParser{}, ivyReportMatcher) } // ivyXMLParser parses ivy.xml files. @@ -63,3 +67,81 @@ func (p *ivyXMLParser) Parse(filename string, content []byte) ([]core.Dependency return deps, nil } + +// ivyReportMatcher matches ivy report files (e.g., com.example-hello-compile.xml) +func ivyReportMatcher(filename string) bool { + // Match files ending in -compile.xml, -test.xml, -runtime.xml, etc. + // These are generated by sbt's dependencyLookup command + suffixes := []string{"-compile.xml", "-test.xml", "-runtime.xml", "-provided.xml"} + for _, suffix := range suffixes { + if strings.HasSuffix(filename, suffix) { + return true + } + } + return false +} + +// ivyReportParser parses ivy report XML files. +type ivyReportParser struct{} + +type ivyReport struct { + Info ivyReportInfo `xml:"info"` + Dependencies ivyReportDeps `xml:"dependencies"` +} + +type ivyReportInfo struct { + Conf string `xml:"conf,attr"` +} + +type ivyReportDeps struct { + Modules []ivyReportModule `xml:"module"` +} + +type ivyReportModule struct { + Org string `xml:"organisation,attr"` + Name string `xml:"name,attr"` + Revisions []ivyReportRevision `xml:"revision"` +} + +type ivyReportRevision struct { + Name string `xml:"name,attr"` +} + +func (p *ivyReportParser) Parse(filename string, content []byte) ([]core.Dependency, error) { + var report ivyReport + if err := xml.Unmarshal(content, &report); err != nil { + return nil, &core.ParseError{Filename: filename, Err: err} + } + + var deps []core.Dependency + seen := make(map[string]bool) + + // Determine scope from the info conf attribute + scope := core.Runtime + conf := strings.ToLower(report.Info.Conf) + if strings.Contains(conf, "test") { + scope = core.Test + } + + for _, mod := range report.Dependencies.Modules { + name := mod.Org + ":" + mod.Name + if seen[name] { + continue + } + seen[name] = true + + version := "" + if len(mod.Revisions) > 0 { + version = mod.Revisions[0].Name + } + + deps = append(deps, core.Dependency{ + Name: name, + Version: version, + Scope: scope, + Direct: false, // Ivy reports show resolved deps, not just direct + }) + } + + return deps, nil +} diff --git a/internal/maven/maven_test.go b/internal/maven/maven_test.go index ef8482c..fdeaf83 100644 --- a/internal/maven/maven_test.go +++ b/internal/maven/maven_test.go @@ -770,3 +770,163 @@ func TestGradleHtmlReport(t *testing.T) { } } } + +func TestIvyReportCompile(t *testing.T) { + content, err := os.ReadFile("../../testdata/maven/ivy_reports/com.example-hello_2.12-compile.xml") + if err != nil { + t.Fatalf("failed to read fixture: %v", err) + } + + parser := &ivyReportParser{} + deps, err := parser.Parse("com.example-hello_2.12-compile.xml", content) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(deps) != 1 { + t.Fatalf("expected 1 dependency, got %d", len(deps)) + } + + dep := deps[0] + if dep.Name != "org.scala-lang:scala-library" { + t.Errorf("name = %q, want %q", dep.Name, "org.scala-lang:scala-library") + } + if dep.Version != "2.12.5" { + t.Errorf("version = %q, want %q", dep.Version, "2.12.5") + } + if dep.Scope != core.Runtime { + t.Errorf("scope = %v, want Runtime", dep.Scope) + } +} + +func TestIvyReportTest(t *testing.T) { + content, err := os.ReadFile("../../testdata/maven/ivy_reports/com.example-hello_2.12-test.xml") + if err != nil { + t.Fatalf("failed to read fixture: %v", err) + } + + parser := &ivyReportParser{} + deps, err := parser.Parse("com.example-hello_2.12-test.xml", content) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(deps) != 5 { + t.Fatalf("expected 5 dependencies, got %d", len(deps)) + } + + depMap := make(map[string]core.Dependency) + for _, d := range deps { + depMap[d.Name] = d + } + + // Verify some dependencies and that they have test scope + expectedTest := map[string]string{ + "org.scala-lang:scala-reflect": "2.12.5", + "org.scalatest:scalatest_2.12": "3.0.5", + "org.scala-lang.modules:scala-xml_2.12": "1.0.6", + "org.scalactic:scalactic_2.12": "3.0.5", + "org.scala-lang:scala-library": "2.12.5", + } + + for name, wantVer := range expectedTest { + dep, ok := depMap[name] + if !ok { + t.Errorf("expected %s dependency", name) + continue + } + if dep.Version != wantVer { + t.Errorf("%s version = %q, want %q", name, dep.Version, wantVer) + } + if dep.Scope != core.Test { + t.Errorf("%s scope = %v, want Test", name, dep.Scope) + } + } +} + +func TestIvyReportMatcher(t *testing.T) { + tests := []struct { + filename string + match bool + }{ + {"com.example-hello_2.12-compile.xml", true}, + {"com.example-hello_2.12-test.xml", true}, + {"com.example-hello_2.12-runtime.xml", true}, + {"com.example-hello_2.12-provided.xml", true}, + {"ivy.xml", false}, + {"pom.xml", false}, + {"build.gradle", false}, + } + + for _, tc := range tests { + if got := ivyReportMatcher(tc.filename); got != tc.match { + t.Errorf("ivyReportMatcher(%q) = %v, want %v", tc.filename, got, tc.match) + } + } +} + +func TestSbtDot(t *testing.T) { + content, err := os.ReadFile("../../testdata/maven/dependencies-compile.dot") + if err != nil { + t.Fatalf("failed to read fixture: %v", err) + } + + parser := &sbtDotParser{} + deps, err := parser.Parse("dependencies-compile.dot", 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 := map[string]string{ + "com.example:myproject": "1.0.0", + "org.scala-lang:scala-library": "2.12.5", + "com.typesafe:config": "1.3.4", + "ch.qos.logback:logback-classic": "1.2.3", + "ch.qos.logback:logback-core": "1.2.3", + "org.slf4j:slf4j-api": "1.7.25", + } + + for name, wantVer := range expected { + dep, ok := depMap[name] + if !ok { + t.Errorf("expected %s dependency", name) + continue + } + if dep.Version != wantVer { + t.Errorf("%s version = %q, want %q", name, dep.Version, wantVer) + } + if dep.Scope != core.Runtime { + t.Errorf("%s scope = %v, want Runtime", name, dep.Scope) + } + } +} + +func TestSbtDotMatcher(t *testing.T) { + tests := []struct { + filename string + match bool + }{ + {"dependencies-compile.dot", true}, + {"dependencies-test.dot", true}, + {"dependencies-runtime.dot", true}, + {"build.sbt", false}, + {"pom.xml", false}, + {"graph.dot", false}, + } + + for _, tc := range tests { + if got := sbtDotMatcher(tc.filename); got != tc.match { + t.Errorf("sbtDotMatcher(%q) = %v, want %v", tc.filename, got, tc.match) + } + } +} diff --git a/internal/maven/sbt.go b/internal/maven/sbt.go index a9c7aaf..1d38dbf 100644 --- a/internal/maven/sbt.go +++ b/internal/maven/sbt.go @@ -8,6 +8,9 @@ import ( func init() { core.Register("maven", core.Manifest, &sbtParser{}, core.ExactMatch("build.sbt")) + + // dependencies-*.dot - lockfile (sbt dependencyDot output) + core.Register("maven", core.Lockfile, &sbtDotParser{}, sbtDotMatcher) } // sbtParser parses build.sbt files. @@ -79,3 +82,48 @@ func (p *sbtParser) Parse(filename string, content []byte) ([]core.Dependency, e return deps, nil } + +// sbtDotMatcher matches sbt dependency DOT files (e.g., dependencies-compile.dot) +func sbtDotMatcher(filename string) bool { + return strings.HasPrefix(filename, "dependencies-") && strings.HasSuffix(filename, ".dot") +} + +// sbtDotParser parses sbt dependencyDot output files. +type sbtDotParser struct{} + +// Match "group:artifact:version" in DOT node/edge definitions +var sbtDotDepRegex = regexp.MustCompile(`"([^":]+):([^":]+):([^"]+)"`) + +func (p *sbtDotParser) Parse(filename string, content []byte) ([]core.Dependency, error) { + var deps []core.Dependency + seen := make(map[string]bool) + text := string(content) + + // Determine scope from filename + scope := core.Runtime + if strings.Contains(filename, "-test") { + scope = core.Test + } + + // Find all group:artifact:version patterns + for _, match := range sbtDotDepRegex.FindAllStringSubmatch(text, -1) { + group := match[1] + artifact := match[2] + version := match[3] + + name := group + ":" + artifact + if seen[name] { + continue + } + seen[name] = true + + deps = append(deps, core.Dependency{ + Name: name, + Version: version, + Scope: scope, + Direct: false, // DOT file shows full graph, not just direct deps + }) + } + + return deps, nil +} diff --git a/testdata/maven/dependencies-compile.dot b/testdata/maven/dependencies-compile.dot new file mode 100644 index 0000000..7ec8846 --- /dev/null +++ b/testdata/maven/dependencies-compile.dot @@ -0,0 +1,17 @@ +digraph "com.example:myproject:1.0.0" { + graph[rankdir="LR"] + node [ + shape="record" + ] + "com.example:myproject:1.0.0"[label=myproject
1.0.0>] + "com.example:myproject:1.0.0" -> "org.scala-lang:scala-library:2.12.5" + "org.scala-lang:scala-library:2.12.5"[label=scala-library
2.12.5>] + "com.example:myproject:1.0.0" -> "com.typesafe:config:1.3.4" + "com.typesafe:config:1.3.4"[label=config
1.3.4>] + "com.example:myproject:1.0.0" -> "ch.qos.logback:logback-classic:1.2.3" + "ch.qos.logback:logback-classic:1.2.3"[label=logback-classic
1.2.3>] + "ch.qos.logback:logback-classic:1.2.3" -> "ch.qos.logback:logback-core:1.2.3" + "ch.qos.logback:logback-core:1.2.3"[label=logback-core
1.2.3>] + "ch.qos.logback:logback-classic:1.2.3" -> "org.slf4j:slf4j-api:1.7.25" + "org.slf4j:slf4j-api:1.7.25"[label=slf4j-api
1.7.25>] +}