From 8cfe901b5b426542be9b2a5bed577e3d8b895cdc Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Fri, 27 Mar 2026 14:04:30 +0000 Subject: [PATCH] Add ResolveDeps for end-to-end dependency resolution Adds ResolveDeps() which creates a temporary project, adds dependencies using the managers library (init + add), runs resolution, and parses the output into a normalized dependency graph. Also adds Managers() and EcosystemForManager() helpers for discovering registered parsers. --- go.mod | 3 + go.sum | 6 + resolve_deps.go | 262 +++++++++++++++++++++++++++++++++++++++++++ resolve_deps_test.go | 96 ++++++++++++++++ 4 files changed, 367 insertions(+) create mode 100644 resolve_deps.go create mode 100644 resolve_deps_test.go diff --git a/go.mod b/go.mod index 69ee792..7a44a62 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,10 @@ go 1.25.7 require github.com/git-pkgs/purl v0.1.8 +require gopkg.in/yaml.v3 v3.0.1 // indirect + require ( + github.com/git-pkgs/managers v0.8.2-0.20260327140953-3ae446558edc github.com/git-pkgs/packageurl-go v0.2.1 // indirect github.com/git-pkgs/vers v0.2.2 // indirect ) diff --git a/go.sum b/go.sum index d68576a..8460519 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,12 @@ +github.com/git-pkgs/managers v0.8.2-0.20260327140953-3ae446558edc h1:ps+yFaDqHvvKzgmFCfAJqN5agU4fXiAMsYOJNG6pt3s= +github.com/git-pkgs/managers v0.8.2-0.20260327140953-3ae446558edc/go.mod h1:8DR7tIQEEyPyJ7QGzStVbovnGl7tAJcrhQLzjQPzFzc= github.com/git-pkgs/packageurl-go v0.2.1 h1:j6VnjJiYS9b1nTLfJGsG6SLaA7Nk6Io+ta8grOyTa4o= github.com/git-pkgs/packageurl-go v0.2.1/go.mod h1:rcIxiG37BlQLB6FZfgdj9Fm7yjhRQd3l+5o7J0QPAk4= github.com/git-pkgs/purl v0.1.8 h1:iyjEHM2WIZUL9A3+q9ylrabqILsN4nOay9X6jfEjmzQ= github.com/git-pkgs/purl v0.1.8/go.mod h1:ihlHw3bnSLXat+9Nl9MsJZBYiG7s3NkwmvE3L/Es/sI= github.com/git-pkgs/vers v0.2.2 h1:42QkiIURhGN2wM8AuYYU+FbzS1YV6jmdGd1RiFp7gXs= github.com/git-pkgs/vers v0.2.2/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/resolve_deps.go b/resolve_deps.go new file mode 100644 index 0000000..a9a4742 --- /dev/null +++ b/resolve_deps.go @@ -0,0 +1,262 @@ +package resolve + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/git-pkgs/managers" + "github.com/git-pkgs/managers/definitions" +) + +// InputDep describes a dependency to add to the generated project. +type InputDep struct { + Name string // package name in ecosystem-native format + Version string // version constraint (optional) +} + +// ErrResolveNotSupported is returned when the manager does not support the resolve operation. +var ErrResolveNotSupported = errors.New("manager does not support resolve") + +// Managers returns the list of manager names that have registered parsers. +func Managers() []string { + names := make([]string, 0, len(parsers)) + for name := range parsers { + names = append(names, name) + } + return names +} + +// EcosystemForManager returns the ecosystem name for a registered manager. +func EcosystemForManager(manager string) (string, bool) { + eco, ok := managerEcosystem[manager] + return eco, ok +} + +// ResolveDeps creates a temporary project, adds the given dependencies using the +// specified package manager, runs resolution, and parses the output into a +// dependency graph. +// +// The package manager CLI must be installed and available on PATH. +func ResolveDeps(ctx context.Context, manager string, deps []InputDep) (*Result, error) { + // Verify we have a parser for this manager. + if _, ok := parsers[manager]; !ok { + return nil, fmt.Errorf("%w: %s", ErrUnsupportedManager, manager) + } + + // Clear environment variables that might leak from parent processes + // (e.g. BUNDLE_GEMFILE from a Rails app calling this binary). + clearParentEnv() + + // Create temp directory for the project. + tmpDir, err := os.MkdirTemp("", "resolve-*") + if err != nil { + return nil, fmt.Errorf("creating temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + // Set up the managers library. + defs, err := definitions.LoadEmbedded() + if err != nil { + return nil, fmt.Errorf("loading manager definitions: %w", err) + } + + translator := managers.NewTranslator() + runner := managers.NewExecRunner() + detector := managers.NewDetector(translator, runner) + for _, def := range defs { + detector.Register(def) + } + + mgr, err := detector.Detect(tmpDir, managers.DetectOptions{Manager: manager}) + if err != nil { + return nil, fmt.Errorf("setting up manager %s: %w", manager, err) + } + + // Init the project and add dependencies. + if mgr.Supports(managers.CapInit) { + result, err := mgr.Init(ctx) + if err != nil { + return nil, fmt.Errorf("init %s: %w", manager, err) + } + if !result.Success() { + return nil, fmt.Errorf("init %s: exit %d: %s", manager, result.ExitCode, result.Stderr) + } + + // Re-detect after init (some commands create subdirectories). + mgr, err = detector.Detect(tmpDir, managers.DetectOptions{Manager: manager}) + if err != nil { + return nil, fmt.Errorf("re-detecting manager after init: %w", err) + } + + // Add each dependency via the manager CLI. + if mgr.Supports(managers.CapAdd) { + seen := make(map[string]bool) + for _, dep := range deps { + if seen[dep.Name] { + continue + } + seen[dep.Name] = true + + result, err := mgr.Add(ctx, dep.Name, managers.AddOptions{Version: dep.Version}) + if err != nil { + return nil, fmt.Errorf("add %s: %w", dep.Name, err) + } + if !result.Success() { + return nil, fmt.Errorf("add %s: exit %d: %s", dep.Name, result.ExitCode, result.Stderr) + } + } + } + } else { + // Fallback: write a minimal manifest for managers without init. + if err := writeManifest(tmpDir, manager, deps); err != nil { + return nil, fmt.Errorf("writing manifest for %s: %w", manager, err) + } + + // Re-detect so the manager sees the manifest. + mgr, err = detector.Detect(tmpDir, managers.DetectOptions{Manager: manager}) + if err != nil { + return nil, fmt.Errorf("detecting manager after manifest write: %w", err) + } + + // Run install to resolve dependencies. + installResult, err := mgr.Install(ctx, managers.InstallOptions{}) + if err != nil { + return nil, fmt.Errorf("install %s: %w", manager, err) + } + if !installResult.Success() { + return nil, fmt.Errorf("install %s: exit %d: %s", manager, installResult.ExitCode, installResult.Stderr) + } + } + + // Run resolve to get the dependency graph output. + if !mgr.Supports(managers.CapResolve) { + return nil, fmt.Errorf("%w: %s", ErrResolveNotSupported, manager) + } + + resolveResult, err := mgr.Resolve(ctx) + if err != nil { + return nil, fmt.Errorf("resolve %s: %w", manager, err) + } + + // Parse the output. + return Parse(manager, []byte(resolveResult.Stdout)) +} + +// writeManifest creates a minimal manifest file for managers that don't support init. +func writeManifest(dir, manager string, deps []InputDep) error { + switch manager { + case "pip": + return writePipManifest(dir, deps) + case "maven": + return writeMavenManifest(dir, deps) + case "sbt": + return writeSbtManifest(dir, deps) + default: + return fmt.Errorf("no manifest template for manager %s", manager) + } +} + +func writePipManifest(dir string, deps []InputDep) error { + var lines []string + for _, dep := range deps { + if dep.Version != "" { + lines = append(lines, dep.Name+dep.Version) + } else { + lines = append(lines, dep.Name) + } + } + return os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0644) +} + +func writeMavenManifest(dir string, deps []InputDep) error { + var depXML strings.Builder + for _, dep := range deps { + // Maven deps use groupId:artifactId format + parts := strings.SplitN(dep.Name, ":", 2) + groupID := parts[0] + artifactID := groupID + if len(parts) == 2 { + artifactID = parts[1] + } + version := dep.Version + if version == "" { + version = "[0,)" + } + depXML.WriteString(" \n") + depXML.WriteString(" " + groupID + "\n") + depXML.WriteString(" " + artifactID + "\n") + depXML.WriteString(" " + version + "\n") + depXML.WriteString(" \n") + } + + pom := ` + + 4.0.0 + resolve + resolve-tmp + 0.0.1 + +` + depXML.String() + ` + +` + return os.WriteFile(filepath.Join(dir, "pom.xml"), []byte(pom), 0644) +} + +func writeSbtManifest(dir string, deps []InputDep) error { + var lines []string + lines = append(lines, `name := "resolve-tmp"`) + lines = append(lines, `version := "0.0.1"`) + lines = append(lines, "") + + var depLines []string + for _, dep := range deps { + parts := strings.SplitN(dep.Name, ":", 2) + groupID := parts[0] + artifactID := groupID + if len(parts) == 2 { + artifactID = parts[1] + } + version := dep.Version + if version == "" { + version = "LATEST" + } + depLines = append(depLines, fmt.Sprintf(` "%s" %% "%s" %% "%s"`, groupID, artifactID, version)) + } + if len(depLines) > 0 { + lines = append(lines, "libraryDependencies ++= Seq(") + lines = append(lines, strings.Join(depLines, ",\n")) + lines = append(lines, ")") + } + + return os.WriteFile(filepath.Join(dir, "build.sbt"), []byte(strings.Join(lines, "\n")+"\n"), 0644) +} + +// envVarsToClear lists specific environment variables that point to a parent +// project and would cause package manager commands to operate on the wrong +// directory (e.g. BUNDLE_GEMFILE from a Rails app). +var envVarsToClear = []string{ + "BUNDLE_GEMFILE", + "BUNDLE_LOCKFILE", + "BUNDLE_BIN_PATH", + "BUNDLE_PATH", + "BUNDLER_SETUP", + "BUNDLER_VERSION", + "GEM_HOME", + "GEM_PATH", + "RUBYOPT", + "RUBYLIB", +} + +// clearParentEnv removes environment variables that could interfere with +// package manager commands run in a temporary directory. +func clearParentEnv() { + for _, key := range envVarsToClear { + os.Unsetenv(key) + } +} diff --git a/resolve_deps_test.go b/resolve_deps_test.go new file mode 100644 index 0000000..79117b3 --- /dev/null +++ b/resolve_deps_test.go @@ -0,0 +1,96 @@ +package resolve_test + +import ( + "context" + "errors" + "os/exec" + "testing" + "time" + + "github.com/git-pkgs/resolve" + _ "github.com/git-pkgs/resolve/parsers" +) + +func hasCommand(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +func TestResolveDepsUnsupportedManager(t *testing.T) { + ctx := context.Background() + _, err := resolve.ResolveDeps(ctx, "nonexistent", nil) + if !errors.Is(err, resolve.ErrUnsupportedManager) { + t.Errorf("expected ErrUnsupportedManager, got %v", err) + } +} + +func TestResolveDepsNPM(t *testing.T) { + if !hasCommand("npm") { + t.Skip("npm not installed") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + result, err := resolve.ResolveDeps(ctx, "npm", []resolve.InputDep{ + {Name: "express", Version: "4.21.2"}, + }) + if err != nil { + t.Fatalf("ResolveDeps failed: %v", err) + } + + if result.Manager != "npm" { + t.Errorf("Manager = %q, want %q", result.Manager, "npm") + } + if result.Ecosystem != "npm" { + t.Errorf("Ecosystem = %q, want %q", result.Ecosystem, "npm") + } + + express := findDep(result.Direct, "express") + if express == nil { + t.Fatal("missing express in results") + } + if express.Version != "4.21.2" { + t.Errorf("express version = %q, want %q", express.Version, "4.21.2") + } + // express has 30+ transitive deps + if len(express.Deps) < 10 { + t.Errorf("expected express to have 10+ transitive deps, got %d", len(express.Deps)) + } + + // spot check a known transitive dep + bodyParser := findDep(express.Deps, "body-parser") + if bodyParser == nil { + t.Fatal("missing body-parser as transitive dep of express") + } +} + +func TestResolveDepsManagers(t *testing.T) { + names := resolve.Managers() + if len(names) == 0 { + t.Fatal("expected at least one registered manager") + } +} + +func TestResolveDepsEcosystemForManager(t *testing.T) { + eco, ok := resolve.EcosystemForManager("npm") + if !ok { + t.Fatal("expected npm to be registered") + } + if eco != "npm" { + t.Errorf("ecosystem = %q, want %q", eco, "npm") + } + + eco, ok = resolve.EcosystemForManager("bundler") + if !ok { + t.Fatal("expected bundler to be registered") + } + if eco != "gem" { + t.Errorf("ecosystem = %q, want %q", eco, "gem") + } + + _, ok = resolve.EcosystemForManager("nonexistent") + if ok { + t.Error("expected nonexistent manager to not be registered") + } +}