Skip to content

Commit 8cfe901

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 8cfe901

4 files changed

Lines changed: 367 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: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
package resolve
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/git-pkgs/managers"
12+
"github.com/git-pkgs/managers/definitions"
13+
)
14+
15+
// InputDep describes a dependency to add to the generated project.
16+
type InputDep struct {
17+
Name string // package name in ecosystem-native format
18+
Version string // version constraint (optional)
19+
}
20+
21+
// ErrResolveNotSupported is returned when the manager does not support the resolve operation.
22+
var ErrResolveNotSupported = errors.New("manager does not support resolve")
23+
24+
// Managers returns the list of manager names that have registered parsers.
25+
func Managers() []string {
26+
names := make([]string, 0, len(parsers))
27+
for name := range parsers {
28+
names = append(names, name)
29+
}
30+
return names
31+
}
32+
33+
// EcosystemForManager returns the ecosystem name for a registered manager.
34+
func EcosystemForManager(manager string) (string, bool) {
35+
eco, ok := managerEcosystem[manager]
36+
return eco, ok
37+
}
38+
39+
// ResolveDeps creates a temporary project, adds the given dependencies using the
40+
// specified package manager, runs resolution, and parses the output into a
41+
// dependency graph.
42+
//
43+
// The package manager CLI must be installed and available on PATH.
44+
func ResolveDeps(ctx context.Context, manager string, deps []InputDep) (*Result, error) {
45+
// Verify we have a parser for this manager.
46+
if _, ok := parsers[manager]; !ok {
47+
return nil, fmt.Errorf("%w: %s", ErrUnsupportedManager, manager)
48+
}
49+
50+
// Clear environment variables that might leak from parent processes
51+
// (e.g. BUNDLE_GEMFILE from a Rails app calling this binary).
52+
clearParentEnv()
53+
54+
// Create temp directory for the project.
55+
tmpDir, err := os.MkdirTemp("", "resolve-*")
56+
if err != nil {
57+
return nil, fmt.Errorf("creating temp dir: %w", err)
58+
}
59+
defer os.RemoveAll(tmpDir)
60+
61+
// Set up the managers library.
62+
defs, err := definitions.LoadEmbedded()
63+
if err != nil {
64+
return nil, fmt.Errorf("loading manager definitions: %w", err)
65+
}
66+
67+
translator := managers.NewTranslator()
68+
runner := managers.NewExecRunner()
69+
detector := managers.NewDetector(translator, runner)
70+
for _, def := range defs {
71+
detector.Register(def)
72+
}
73+
74+
mgr, err := detector.Detect(tmpDir, managers.DetectOptions{Manager: manager})
75+
if err != nil {
76+
return nil, fmt.Errorf("setting up manager %s: %w", manager, err)
77+
}
78+
79+
// Init the project and add dependencies.
80+
if mgr.Supports(managers.CapInit) {
81+
result, err := mgr.Init(ctx)
82+
if err != nil {
83+
return nil, fmt.Errorf("init %s: %w", manager, err)
84+
}
85+
if !result.Success() {
86+
return nil, fmt.Errorf("init %s: exit %d: %s", manager, result.ExitCode, result.Stderr)
87+
}
88+
89+
// Re-detect after init (some commands create subdirectories).
90+
mgr, err = detector.Detect(tmpDir, managers.DetectOptions{Manager: manager})
91+
if err != nil {
92+
return nil, fmt.Errorf("re-detecting manager after init: %w", err)
93+
}
94+
95+
// Add each dependency via the manager CLI.
96+
if mgr.Supports(managers.CapAdd) {
97+
seen := make(map[string]bool)
98+
for _, dep := range deps {
99+
if seen[dep.Name] {
100+
continue
101+
}
102+
seen[dep.Name] = true
103+
104+
result, err := mgr.Add(ctx, dep.Name, managers.AddOptions{Version: dep.Version})
105+
if err != nil {
106+
return nil, fmt.Errorf("add %s: %w", dep.Name, err)
107+
}
108+
if !result.Success() {
109+
return nil, fmt.Errorf("add %s: exit %d: %s", dep.Name, result.ExitCode, result.Stderr)
110+
}
111+
}
112+
}
113+
} else {
114+
// Fallback: write a minimal manifest for managers without init.
115+
if err := writeManifest(tmpDir, manager, deps); err != nil {
116+
return nil, fmt.Errorf("writing manifest for %s: %w", manager, err)
117+
}
118+
119+
// Re-detect so the manager sees the manifest.
120+
mgr, err = detector.Detect(tmpDir, managers.DetectOptions{Manager: manager})
121+
if err != nil {
122+
return nil, fmt.Errorf("detecting manager after manifest write: %w", err)
123+
}
124+
125+
// Run install to resolve dependencies.
126+
installResult, err := mgr.Install(ctx, managers.InstallOptions{})
127+
if err != nil {
128+
return nil, fmt.Errorf("install %s: %w", manager, err)
129+
}
130+
if !installResult.Success() {
131+
return nil, fmt.Errorf("install %s: exit %d: %s", manager, installResult.ExitCode, installResult.Stderr)
132+
}
133+
}
134+
135+
// Run resolve to get the dependency graph output.
136+
if !mgr.Supports(managers.CapResolve) {
137+
return nil, fmt.Errorf("%w: %s", ErrResolveNotSupported, manager)
138+
}
139+
140+
resolveResult, err := mgr.Resolve(ctx)
141+
if err != nil {
142+
return nil, fmt.Errorf("resolve %s: %w", manager, err)
143+
}
144+
145+
// Parse the output.
146+
return Parse(manager, []byte(resolveResult.Stdout))
147+
}
148+
149+
// writeManifest creates a minimal manifest file for managers that don't support init.
150+
func writeManifest(dir, manager string, deps []InputDep) error {
151+
switch manager {
152+
case "pip":
153+
return writePipManifest(dir, deps)
154+
case "maven":
155+
return writeMavenManifest(dir, deps)
156+
case "sbt":
157+
return writeSbtManifest(dir, deps)
158+
default:
159+
return fmt.Errorf("no manifest template for manager %s", manager)
160+
}
161+
}
162+
163+
func writePipManifest(dir string, deps []InputDep) error {
164+
var lines []string
165+
for _, dep := range deps {
166+
if dep.Version != "" {
167+
lines = append(lines, dep.Name+dep.Version)
168+
} else {
169+
lines = append(lines, dep.Name)
170+
}
171+
}
172+
return os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0644)
173+
}
174+
175+
func writeMavenManifest(dir string, deps []InputDep) error {
176+
var depXML strings.Builder
177+
for _, dep := range deps {
178+
// Maven deps use groupId:artifactId format
179+
parts := strings.SplitN(dep.Name, ":", 2)
180+
groupID := parts[0]
181+
artifactID := groupID
182+
if len(parts) == 2 {
183+
artifactID = parts[1]
184+
}
185+
version := dep.Version
186+
if version == "" {
187+
version = "[0,)"
188+
}
189+
depXML.WriteString(" <dependency>\n")
190+
depXML.WriteString(" <groupId>" + groupID + "</groupId>\n")
191+
depXML.WriteString(" <artifactId>" + artifactID + "</artifactId>\n")
192+
depXML.WriteString(" <version>" + version + "</version>\n")
193+
depXML.WriteString(" </dependency>\n")
194+
}
195+
196+
pom := `<?xml version="1.0" encoding="UTF-8"?>
197+
<project xmlns="http://maven.apache.org/POM/4.0.0"
198+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
199+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
200+
<modelVersion>4.0.0</modelVersion>
201+
<groupId>resolve</groupId>
202+
<artifactId>resolve-tmp</artifactId>
203+
<version>0.0.1</version>
204+
<dependencies>
205+
` + depXML.String() + ` </dependencies>
206+
</project>
207+
`
208+
return os.WriteFile(filepath.Join(dir, "pom.xml"), []byte(pom), 0644)
209+
}
210+
211+
func writeSbtManifest(dir string, deps []InputDep) error {
212+
var lines []string
213+
lines = append(lines, `name := "resolve-tmp"`)
214+
lines = append(lines, `version := "0.0.1"`)
215+
lines = append(lines, "")
216+
217+
var depLines []string
218+
for _, dep := range deps {
219+
parts := strings.SplitN(dep.Name, ":", 2)
220+
groupID := parts[0]
221+
artifactID := groupID
222+
if len(parts) == 2 {
223+
artifactID = parts[1]
224+
}
225+
version := dep.Version
226+
if version == "" {
227+
version = "LATEST"
228+
}
229+
depLines = append(depLines, fmt.Sprintf(` "%s" %% "%s" %% "%s"`, groupID, artifactID, version))
230+
}
231+
if len(depLines) > 0 {
232+
lines = append(lines, "libraryDependencies ++= Seq(")
233+
lines = append(lines, strings.Join(depLines, ",\n"))
234+
lines = append(lines, ")")
235+
}
236+
237+
return os.WriteFile(filepath.Join(dir, "build.sbt"), []byte(strings.Join(lines, "\n")+"\n"), 0644)
238+
}
239+
240+
// envVarsToClear lists specific environment variables that point to a parent
241+
// project and would cause package manager commands to operate on the wrong
242+
// directory (e.g. BUNDLE_GEMFILE from a Rails app).
243+
var envVarsToClear = []string{
244+
"BUNDLE_GEMFILE",
245+
"BUNDLE_LOCKFILE",
246+
"BUNDLE_BIN_PATH",
247+
"BUNDLE_PATH",
248+
"BUNDLER_SETUP",
249+
"BUNDLER_VERSION",
250+
"GEM_HOME",
251+
"GEM_PATH",
252+
"RUBYOPT",
253+
"RUBYLIB",
254+
}
255+
256+
// clearParentEnv removes environment variables that could interfere with
257+
// package manager commands run in a temporary directory.
258+
func clearParentEnv() {
259+
for _, key := range envVarsToClear {
260+
os.Unsetenv(key)
261+
}
262+
}

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)