Skip to content

Commit 326c4eb

Browse files
committed
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.
1 parent 3268109 commit 326c4eb

4 files changed

Lines changed: 264 additions & 0 deletions

File tree

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ go 1.25.7
44

55
require github.com/git-pkgs/purl v0.1.8
66

7+
require gopkg.in/yaml.v3 v3.0.1 // indirect
8+
79
require (
10+
github.com/git-pkgs/managers v0.8.2-0.20260327140953-3ae446558edc
811
github.com/git-pkgs/packageurl-go v0.2.1 // indirect
912
github.com/git-pkgs/vers v0.2.2 // indirect
1013
)

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
github.com/git-pkgs/managers v0.8.2-0.20260327140953-3ae446558edc h1:ps+yFaDqHvvKzgmFCfAJqN5agU4fXiAMsYOJNG6pt3s=
2+
github.com/git-pkgs/managers v0.8.2-0.20260327140953-3ae446558edc/go.mod h1:8DR7tIQEEyPyJ7QGzStVbovnGl7tAJcrhQLzjQPzFzc=
13
github.com/git-pkgs/packageurl-go v0.2.1 h1:j6VnjJiYS9b1nTLfJGsG6SLaA7Nk6Io+ta8grOyTa4o=
24
github.com/git-pkgs/packageurl-go v0.2.1/go.mod h1:rcIxiG37BlQLB6FZfgdj9Fm7yjhRQd3l+5o7J0QPAk4=
35
github.com/git-pkgs/purl v0.1.8 h1:iyjEHM2WIZUL9A3+q9ylrabqILsN4nOay9X6jfEjmzQ=
46
github.com/git-pkgs/purl v0.1.8/go.mod h1:ihlHw3bnSLXat+9Nl9MsJZBYiG7s3NkwmvE3L/Es/sI=
57
github.com/git-pkgs/vers v0.2.2 h1:42QkiIURhGN2wM8AuYYU+FbzS1YV6jmdGd1RiFp7gXs=
68
github.com/git-pkgs/vers v0.2.2/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo=
9+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
10+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
11+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
12+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

resolve_deps.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package resolve
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"os"
8+
9+
"github.com/git-pkgs/managers"
10+
"github.com/git-pkgs/managers/definitions"
11+
)
12+
13+
// InputDep describes a dependency to add to the generated project.
14+
type InputDep struct {
15+
Name string // package name in ecosystem-native format
16+
Version string // version constraint (optional)
17+
}
18+
19+
// ErrInitNotSupported is returned when the manager does not support the init operation.
20+
var ErrInitNotSupported = errors.New("manager does not support init")
21+
22+
// ErrResolveNotSupported is returned when the manager does not support the resolve operation.
23+
var ErrResolveNotSupported = errors.New("manager does not support resolve")
24+
25+
// Managers returns the list of manager names that have registered parsers.
26+
func Managers() []string {
27+
names := make([]string, 0, len(parsers))
28+
for name := range parsers {
29+
names = append(names, name)
30+
}
31+
return names
32+
}
33+
34+
// EcosystemForManager returns the ecosystem name for a registered manager.
35+
func EcosystemForManager(manager string) (string, bool) {
36+
eco, ok := managerEcosystem[manager]
37+
return eco, ok
38+
}
39+
40+
// ResolveDeps creates a temporary project, adds the given dependencies using the
41+
// specified package manager, runs resolution, and parses the output into a
42+
// dependency graph.
43+
//
44+
// The package manager CLI must be installed and available on PATH.
45+
func ResolveDeps(ctx context.Context, manager string, deps []InputDep) (*Result, error) {
46+
// Verify we have a parser for this manager.
47+
if _, ok := parsers[manager]; !ok {
48+
return nil, fmt.Errorf("%w: %s", ErrUnsupportedManager, manager)
49+
}
50+
51+
// Clear environment variables that might leak from parent processes
52+
// (e.g. BUNDLE_GEMFILE from a Rails app calling this binary).
53+
clearParentEnv()
54+
55+
// Create temp directory for the project.
56+
tmpDir, err := os.MkdirTemp("", "resolve-*")
57+
if err != nil {
58+
return nil, fmt.Errorf("creating temp dir: %w", err)
59+
}
60+
defer os.RemoveAll(tmpDir)
61+
62+
// Set up the managers library.
63+
defs, err := definitions.LoadEmbedded()
64+
if err != nil {
65+
return nil, fmt.Errorf("loading manager definitions: %w", err)
66+
}
67+
68+
translator := managers.NewTranslator()
69+
runner := managers.NewExecRunner()
70+
detector := managers.NewDetector(translator, runner)
71+
for _, def := range defs {
72+
detector.Register(def)
73+
}
74+
75+
mgr, err := detector.Detect(tmpDir, managers.DetectOptions{Manager: manager})
76+
if err != nil {
77+
return nil, fmt.Errorf("setting up manager %s: %w", manager, err)
78+
}
79+
80+
// Init the project.
81+
if !mgr.Supports(managers.CapInit) {
82+
return nil, fmt.Errorf("%w: %s", ErrInitNotSupported, manager)
83+
}
84+
85+
result, err := mgr.Init(ctx)
86+
if err != nil {
87+
return nil, fmt.Errorf("init %s: %w", manager, err)
88+
}
89+
if !result.Success() {
90+
return nil, fmt.Errorf("init %s: exit %d: %s", manager, result.ExitCode, result.Stderr)
91+
}
92+
93+
// Some init commands create a subdirectory (mix new, lein new, etc.).
94+
// Re-detect from the same dir; the init should have created the manifest.
95+
mgr, err = detector.Detect(tmpDir, managers.DetectOptions{Manager: manager})
96+
if err != nil {
97+
return nil, fmt.Errorf("re-detecting manager after init: %w", err)
98+
}
99+
100+
// Add each dependency, skipping duplicates.
101+
if mgr.Supports(managers.CapAdd) {
102+
seen := make(map[string]bool)
103+
for _, dep := range deps {
104+
if seen[dep.Name] {
105+
continue
106+
}
107+
seen[dep.Name] = true
108+
109+
opts := managers.AddOptions{
110+
Version: dep.Version,
111+
}
112+
113+
result, err := mgr.Add(ctx, dep.Name, opts)
114+
if err != nil {
115+
return nil, fmt.Errorf("add %s: %w", dep.Name, err)
116+
}
117+
if !result.Success() {
118+
return nil, fmt.Errorf("add %s: exit %d: %s", dep.Name, result.ExitCode, result.Stderr)
119+
}
120+
}
121+
}
122+
123+
// Run resolve to get the dependency graph output.
124+
if !mgr.Supports(managers.CapResolve) {
125+
return nil, fmt.Errorf("%w: %s", ErrResolveNotSupported, manager)
126+
}
127+
128+
resolveResult, err := mgr.Resolve(ctx)
129+
if err != nil {
130+
return nil, fmt.Errorf("resolve %s: %w", manager, err)
131+
}
132+
133+
// Parse the output.
134+
return Parse(manager, []byte(resolveResult.Stdout))
135+
}
136+
137+
// envVarsToClear lists specific environment variables that point to a parent
138+
// project and would cause package manager commands to operate on the wrong
139+
// directory (e.g. BUNDLE_GEMFILE from a Rails app).
140+
var envVarsToClear = []string{
141+
"BUNDLE_GEMFILE",
142+
"BUNDLE_LOCKFILE",
143+
"BUNDLE_BIN_PATH",
144+
"BUNDLE_PATH",
145+
"BUNDLER_SETUP",
146+
"BUNDLER_VERSION",
147+
"GEM_HOME",
148+
"GEM_PATH",
149+
"RUBYOPT",
150+
"RUBYLIB",
151+
}
152+
153+
// clearParentEnv removes environment variables that could interfere with
154+
// package manager commands run in a temporary directory.
155+
func clearParentEnv() {
156+
for _, key := range envVarsToClear {
157+
os.Unsetenv(key)
158+
}
159+
}

resolve_deps_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package resolve_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"os/exec"
7+
"testing"
8+
"time"
9+
10+
"github.com/git-pkgs/resolve"
11+
_ "github.com/git-pkgs/resolve/parsers"
12+
)
13+
14+
func hasCommand(name string) bool {
15+
_, err := exec.LookPath(name)
16+
return err == nil
17+
}
18+
19+
func TestResolveDepsUnsupportedManager(t *testing.T) {
20+
ctx := context.Background()
21+
_, err := resolve.ResolveDeps(ctx, "nonexistent", nil)
22+
if !errors.Is(err, resolve.ErrUnsupportedManager) {
23+
t.Errorf("expected ErrUnsupportedManager, got %v", err)
24+
}
25+
}
26+
27+
func TestResolveDepsNPM(t *testing.T) {
28+
if !hasCommand("npm") {
29+
t.Skip("npm not installed")
30+
}
31+
32+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
33+
defer cancel()
34+
35+
result, err := resolve.ResolveDeps(ctx, "npm", []resolve.InputDep{
36+
{Name: "express", Version: "4.21.2"},
37+
})
38+
if err != nil {
39+
t.Fatalf("ResolveDeps failed: %v", err)
40+
}
41+
42+
if result.Manager != "npm" {
43+
t.Errorf("Manager = %q, want %q", result.Manager, "npm")
44+
}
45+
if result.Ecosystem != "npm" {
46+
t.Errorf("Ecosystem = %q, want %q", result.Ecosystem, "npm")
47+
}
48+
49+
express := findDep(result.Direct, "express")
50+
if express == nil {
51+
t.Fatal("missing express in results")
52+
}
53+
if express.Version != "4.21.2" {
54+
t.Errorf("express version = %q, want %q", express.Version, "4.21.2")
55+
}
56+
// express has 30+ transitive deps
57+
if len(express.Deps) < 10 {
58+
t.Errorf("expected express to have 10+ transitive deps, got %d", len(express.Deps))
59+
}
60+
61+
// spot check a known transitive dep
62+
bodyParser := findDep(express.Deps, "body-parser")
63+
if bodyParser == nil {
64+
t.Fatal("missing body-parser as transitive dep of express")
65+
}
66+
}
67+
68+
func TestResolveDepsManagers(t *testing.T) {
69+
names := resolve.Managers()
70+
if len(names) == 0 {
71+
t.Fatal("expected at least one registered manager")
72+
}
73+
}
74+
75+
func TestResolveDepsEcosystemForManager(t *testing.T) {
76+
eco, ok := resolve.EcosystemForManager("npm")
77+
if !ok {
78+
t.Fatal("expected npm to be registered")
79+
}
80+
if eco != "npm" {
81+
t.Errorf("ecosystem = %q, want %q", eco, "npm")
82+
}
83+
84+
eco, ok = resolve.EcosystemForManager("bundler")
85+
if !ok {
86+
t.Fatal("expected bundler to be registered")
87+
}
88+
if eco != "gem" {
89+
t.Errorf("ecosystem = %q, want %q", eco, "gem")
90+
}
91+
92+
_, ok = resolve.EcosystemForManager("nonexistent")
93+
if ok {
94+
t.Error("expected nonexistent manager to not be registered")
95+
}
96+
}

0 commit comments

Comments
 (0)