From 3b89239bb21194b1bb55d629e0b8ca9bc37b4b72 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 25 Jan 2026 22:51:56 +0000 Subject: [PATCH] Add go.graph lockfile parser Adds support for parsing go.graph files, which contain the output of the go mod graph command. The parser extracts module name and version, and identifies direct dependencies based on whether they are required by the main module (which appears without a version). --- internal/golang/golang.go | 76 ++++++++++++++++++++++++++++++++++ internal/golang/golang_test.go | 73 ++++++++++++++++++++++++++++++++ testdata/golang/go.graph | 8 ++++ 3 files changed, 157 insertions(+) create mode 100644 testdata/golang/go.graph diff --git a/internal/golang/golang.go b/internal/golang/golang.go index 05c8704..54ec3ad 100644 --- a/internal/golang/golang.go +++ b/internal/golang/golang.go @@ -12,6 +12,9 @@ func init() { // go.sum - lockfile core.Register("golang", core.Lockfile, &goSumParser{}, core.ExactMatch("go.sum")) + + // go.graph - lockfile (go mod graph output) + core.Register("golang", core.Lockfile, &goGraphParser{}, core.ExactMatch("go.graph")) } // goModParser parses go.mod files. @@ -145,3 +148,76 @@ func (p *goSumParser) Parse(filename string, content []byte) ([]core.Dependency, return deps, nil } + +// goGraphParser parses go.graph files (go mod graph output). +type goGraphParser struct{} + +func (p *goGraphParser) Parse(filename string, content []byte) ([]core.Dependency, error) { + var deps []core.Dependency + seen := make(map[string]bool) + directDeps := make(map[string]bool) + lines := strings.Split(string(content), "\n") + + // First pass: identify direct dependencies (those required by the main module) + // The main module appears without a version in the first column + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.Fields(line) + if len(parts) != 2 { + continue + } + + parent := parts[0] + dep := parts[1] + + // If parent has no @version, it's the main module + if !strings.Contains(parent, "@") { + // Extract just the name from dep (before @) + if idx := strings.LastIndex(dep, "@"); idx > 0 { + directDeps[dep[:idx]] = true + } + } + } + + // Second pass: collect all dependencies + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.Fields(line) + if len(parts) != 2 { + continue + } + + dep := parts[1] + + // Parse name@version + idx := strings.LastIndex(dep, "@") + if idx <= 0 { + continue + } + + name := dep[:idx] + version := dep[idx+1:] + + if seen[name] { + continue + } + seen[name] = true + + deps = append(deps, core.Dependency{ + Name: name, + Version: version, + Scope: core.Runtime, + Direct: directDeps[name], + }) + } + + return deps, nil +} diff --git a/internal/golang/golang_test.go b/internal/golang/golang_test.go index 4aaa531..d568ee4 100644 --- a/internal/golang/golang_test.go +++ b/internal/golang/golang_test.go @@ -574,3 +574,76 @@ func TestGodepsText(t *testing.T) { } } } + +func TestGoGraph(t *testing.T) { + content, err := os.ReadFile("../../testdata/golang/go.graph") + if err != nil { + t.Fatalf("failed to read fixture: %v", err) + } + + parser := &goGraphParser{} + deps, err := parser.Parse("go.graph", content) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + // Should have 8 unique dependencies + if len(deps) != 8 { + t.Fatalf("expected 8 dependencies, got %d", len(deps)) + } + + depMap := make(map[string]core.Dependency) + for _, d := range deps { + depMap[d.Name] = d + } + + // Verify direct dependencies (from main module) + directDeps := []struct { + name string + version string + }{ + {"golang.org/x/text", "v0.14.0"}, + {"github.com/google/uuid", "v1.4.0"}, + {"github.com/stretchr/testify", "v1.8.4"}, + } + + for _, exp := range directDeps { + 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 { + t.Errorf("%s should be direct dependency", exp.name) + } + } + + // Verify transitive dependencies + transitiveDeps := []struct { + name string + version string + }{ + {"golang.org/x/tools", "v0.0.0-20180917221912-90fa682c2a6e"}, + {"github.com/davecgh/go-spew", "v1.1.1"}, + {"github.com/pmezard/go-difflib", "v1.0.0"}, + {"github.com/stretchr/objx", "v0.5.0"}, + {"gopkg.in/yaml.v3", "v3.0.1"}, + } + + for _, exp := range transitiveDeps { + 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 { + t.Errorf("%s should be transitive (indirect) dependency", exp.name) + } + } +} diff --git a/testdata/golang/go.graph b/testdata/golang/go.graph new file mode 100644 index 0000000..4bfd1a6 --- /dev/null +++ b/testdata/golang/go.graph @@ -0,0 +1,8 @@ +example.com/myproject golang.org/x/text@v0.14.0 +example.com/myproject github.com/google/uuid@v1.4.0 +example.com/myproject github.com/stretchr/testify@v1.8.4 +golang.org/x/text@v0.14.0 golang.org/x/tools@v0.0.0-20180917221912-90fa682c2a6e +github.com/stretchr/testify@v1.8.4 github.com/davecgh/go-spew@v1.1.1 +github.com/stretchr/testify@v1.8.4 github.com/pmezard/go-difflib@v1.0.0 +github.com/stretchr/testify@v1.8.4 github.com/stretchr/objx@v0.5.0 +github.com/stretchr/testify@v1.8.4 gopkg.in/yaml.v3@v3.0.1