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>]
+}