feat: Add separate package manager and resolver

This commit is contained in:
abhisek
2025-05-12 19:06:57 +05:30
parent 8b46964c7a
commit e294936b0f
8 changed files with 477 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
golang 1.24.1
+4 -1
View File
@@ -7,9 +7,10 @@ require (
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.6-20250418165058-162f6b0cc319.1
github.com/fatih/color v1.18.0
github.com/jedib0t/go-pretty/v6 v6.6.7
github.com/safedep/dry v0.0.0-20250410092643-c7079e2f9442
github.com/safedep/dry v0.0.0-20250512123505-23dcce9fe1af
github.com/safedep/vet v1.10.1
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
google.golang.org/grpc v1.71.1
)
@@ -32,6 +33,7 @@ require (
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/deepmap/oapi-codegen v1.16.3 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/flosch/pongo2/v4 v4.0.2 // indirect
@@ -83,6 +85,7 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/package-url/packageurl-go v0.1.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sanity-io/litter v1.5.8 // indirect
+2
View File
@@ -242,6 +242,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/safedep/dry v0.0.0-20250410092643-c7079e2f9442 h1:+wee7FBBGm/SD46Y+cHh6GOihYI2PZ4ql2la62in7Pk=
github.com/safedep/dry v0.0.0-20250410092643-c7079e2f9442/go.mod h1:EBAaPIWWi5sPFShU5wsTGynZekSo1Mxf+l/zzs1KTls=
github.com/safedep/dry v0.0.0-20250512123505-23dcce9fe1af h1:3Pomh9zoEIwvzWLBAc5ikJ8kPT53O2APD06HmrlE6A4=
github.com/safedep/dry v0.0.0-20250512123505-23dcce9fe1af/go.mod h1:Mdqx/Q2DhAcN38XiUNTGCC5MktofYDQW9Az7YWGEF0s=
github.com/safedep/vet v1.10.1 h1:L8yG3X/t3I9fDVICc0DZ+zGweoLhDeyFIK6ubcNiXUc=
github.com/safedep/vet v1.10.1/go.mod h1:DN+59m5kB1QSOpdstCk9rfLTMCPtT/ZLMP5rwuix/j8=
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
+138
View File
@@ -0,0 +1,138 @@
package packagemanager
import (
"fmt"
"strings"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
)
type NpmPackageManagerConfig struct{}
func DefaultNpmPackageManagerConfig() NpmPackageManagerConfig {
return NpmPackageManagerConfig{}
}
type npmPackageManager struct {
Config NpmPackageManagerConfig
}
func NewNpmPackageManager(config NpmPackageManagerConfig) (*npmPackageManager, error) {
return &npmPackageManager{
Config: config,
}, nil
}
func (npm *npmPackageManager) Name() string {
return "npm"
}
func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error) {
// No command specified
if len(args) < 2 {
return &ParsedCommand{
Command: Command{Args: args},
}, nil
}
// Check if this is an install command
if !npm.isInstallCommand(args[1]) {
return &ParsedCommand{
Command: Command{Args: args},
}, nil
}
// Extract packages from args
var packages []string
for i := 2; i < len(args); i++ {
arg := args[i]
if !strings.HasPrefix(arg, "-") {
packages = append(packages, arg)
}
}
// No packages specified
if len(packages) == 0 {
return &ParsedCommand{
Command: Command{Args: args},
}, nil
}
// Process all package arguments
var installTargets []*PackageInstallTarget
for _, pkg := range packages {
packageName, version, err := npmParsePackageInfo(pkg)
if err != nil {
return nil, fmt.Errorf("failed to parse package info: %w", err)
}
// Clean version if specified
if version != "" {
version = npmCleanVersion(version)
}
installTargets = append(installTargets, &PackageInstallTarget{
PackageVersion: &packagev1.PackageVersion{
Package: &packagev1.Package{
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
Name: packageName,
},
Version: version,
},
})
}
return &ParsedCommand{
Command: Command{Args: args},
InstallTargets: installTargets,
}, nil
}
func (npm *npmPackageManager) isInstallCommand(cmd string) bool {
return cmd == "install" || cmd == "i" || cmd == "add"
}
func npmParsePackageInfo(input string) (packageName, version string, err error) {
if input == "" {
return "", "", fmt.Errorf("package info cannot be empty")
}
input = strings.TrimSpace(input)
if strings.HasPrefix(input, "@") {
// Scoped package (e.g. @types/node or @types/node@1.0.0)
lastAtIndex := strings.LastIndex(input, "@")
if lastAtIndex > 0 {
packageName = strings.TrimSpace(input[:lastAtIndex])
version = strings.TrimSpace(input[lastAtIndex+1:])
return packageName, version, nil
}
// If no version specifier, return the whole input as package name
return strings.TrimSpace(input), "", nil
}
// Normal package (e.g. lodash or lodash@4.17.21)
parts := strings.Split(input, "@")
if len(parts) == 2 {
packageName = strings.TrimSpace(parts[0])
version = strings.TrimSpace(parts[1])
return packageName, version, nil
}
if len(parts) == 1 {
packageName = strings.TrimSpace(parts[0])
return packageName, "", nil
}
return "", "", fmt.Errorf("invalid format: expected 'package' OR 'package@version', got '%s'", input)
}
func npmCleanVersion(version string) string {
version = strings.TrimPrefix(version, "^")
version = strings.TrimPrefix(version, "~")
if version == "*" {
return "latest"
}
return version
}
+148
View File
@@ -0,0 +1,148 @@
package packagemanager
import (
"context"
"fmt"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log"
"github.com/safedep/dry/packageregistry"
)
type NpmDependencyResolverConfig struct {
IncludeDevDependencies bool
IncludeTransitiveDependencies bool
TransitiveDepth int
// FailFast will stop resolving dependencies after the first error
FailFast bool
}
func NewDefaultNpmDependencyResolverConfig() NpmDependencyResolverConfig {
return NpmDependencyResolverConfig{
IncludeDevDependencies: true,
IncludeTransitiveDependencies: true,
TransitiveDepth: 100,
FailFast: false,
}
}
type npmDependencyResolver struct {
registry packageregistry.Client
config NpmDependencyResolverConfig
}
var _ PackageResolver = &npmDependencyResolver{}
func NewNpmDependencyResolver(config NpmDependencyResolverConfig) (*npmDependencyResolver, error) {
client, err := packageregistry.NewNpmAdapter()
if err != nil {
return nil, fmt.Errorf("failed to create npm adapter: %w", err)
}
return &npmDependencyResolver{
registry: client,
config: config,
}, nil
}
func (r *npmDependencyResolver) ResolveLatestVersion(ctx context.Context,
pkg *packagev1.Package) (*packagev1.PackageVersion, error) {
pd, err := r.registry.PackageDiscovery()
if err != nil {
return nil, fmt.Errorf("failed to get package discovery: %w", err)
}
pkgInfo, err := pd.GetPackage(pkg.Name)
if err != nil {
return nil, fmt.Errorf("failed to get package: %w", err)
}
log.Debugf("Resolved npm/%s to latest version %s", pkg.Name, pkgInfo.LatestVersion)
return &packagev1.PackageVersion{
Package: pkg,
Version: pkgInfo.LatestVersion,
}, nil
}
func (r *npmDependencyResolver) ResolveDependencies(ctx context.Context,
packageVersion *packagev1.PackageVersion) ([]*packagev1.PackageVersion, error) {
pd, err := r.registry.PackageDiscovery()
if err != nil {
return nil, fmt.Errorf("failed to get package discovery: %w", err)
}
// inputQueue is the queue of package versions to resolve
inputQueue := make([]*packagev1.PackageVersion, 0)
// outputQueue is the queue of resolved package versions
outputQueue := make([]*packagev1.PackageVersion, 0)
// Start with the initial package version to resolve
inputQueue = append(inputQueue, packageVersion)
resolutionDepth := 0
visitedPackages := make(map[string]bool)
for {
if len(inputQueue) == 0 {
break
}
if resolutionDepth > r.config.TransitiveDepth {
return nil, fmt.Errorf("exceeded maximum transitive depth of %d", r.config.TransitiveDepth)
}
packageVersion = inputQueue[0]
inputQueue = inputQueue[1:]
// Skip if we've already visited this package version
// Like npm, we will reuse an existing version instead of considering
// every single version of a dependency. This is a heuristic. We are not
// actually checking for compatibility here like npm does
packageKey := fmt.Sprintf("%s@*", packageVersion.Package.Name)
if visitedPackages[packageKey] {
continue
}
// Mark the package version as visited
visitedPackages[packageKey] = true
dependencyList, err := pd.GetPackageDependencies(packageVersion.Package.Name, packageVersion.Version)
if err != nil {
if r.config.FailFast {
return nil, fmt.Errorf("failed to get package dependencies: %w", err)
}
log.Warnf("failed to get package dependencies: %s", err)
continue
}
dependencies := dependencyList.Dependencies
if r.config.IncludeDevDependencies {
dependencies = append(dependencies, dependencyList.DevDependencies...)
}
resolvedPackageVersionDependencies := make([]*packagev1.PackageVersion, 0)
for _, dependency := range dependencies {
resolvedPackageVersionDependencies = append(resolvedPackageVersionDependencies, &packagev1.PackageVersion{
Package: &packagev1.Package{
Name: dependency.Name,
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
},
Version: npmCleanVersion(dependency.VersionSpec),
})
}
outputQueue = append(outputQueue, resolvedPackageVersionDependencies...)
if r.config.IncludeTransitiveDependencies {
inputQueue = append(inputQueue, resolvedPackageVersionDependencies...)
}
resolutionDepth++
}
return outputQueue, nil
}
+68
View File
@@ -0,0 +1,68 @@
package packagemanager
import (
"context"
"testing"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/stretchr/testify/require"
)
func TestNpmDependencyResolver_ResolveDependencies(t *testing.T) {
cases := []struct {
name string
pkg *packagev1.PackageVersion
includeTransitiveDependencies bool
transitiveDepth int
assertFn func(t *testing.T, dependencies []*packagev1.PackageVersion, err error)
}{
{
name: "should resolve dependencies for a package when transitive dependencies are not included",
pkg: &packagev1.PackageVersion{
Package: &packagev1.Package{
Name: "react",
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
},
Version: "18.2.0",
},
includeTransitiveDependencies: false,
transitiveDepth: 1,
assertFn: func(t *testing.T, dependencies []*packagev1.PackageVersion, err error) {
require.NoError(t, err)
require.Equal(t, 1, len(dependencies))
require.Equal(t, "loose-envify", dependencies[0].Package.Name)
require.Equal(t, "1.1.0", dependencies[0].Version)
},
},
{
name: "should resolve all dependencies for a package when transitive dependencies are included",
pkg: &packagev1.PackageVersion{
Package: &packagev1.Package{
Name: "react",
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
},
Version: "18.2.0",
},
includeTransitiveDependencies: true,
transitiveDepth: 250,
assertFn: func(t *testing.T, dependencies []*packagev1.PackageVersion, err error) {
require.NoError(t, err)
require.Equal(t, 1, len(dependencies))
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
config := NewDefaultNpmDependencyResolverConfig()
config.IncludeTransitiveDependencies = tc.includeTransitiveDependencies
config.TransitiveDepth = tc.transitiveDepth
resolver, err := NewNpmDependencyResolver(config)
require.NoError(t, err)
dependencies, err := resolver.ResolveDependencies(context.Background(), tc.pkg)
tc.assertFn(t, dependencies, err)
})
}
}
+63
View File
@@ -0,0 +1,63 @@
package packagemanager
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestNpmParseCommand(t *testing.T) {
cases := []struct {
name string
command string
assert func(t *testing.T, parsedCommand *ParsedCommand, err error)
}{
{
name: "install a single package",
command: "npm install @types/node",
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
assert.NoError(t, err)
assert.Equal(t, 1, len(parsedCommand.InstallTargets))
assert.Equal(t, "@types/node", parsedCommand.InstallTargets[0].PackageVersion.Package.Name)
},
},
{
name: "no install target",
command: "npm install",
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
assert.NoError(t, err)
assert.Equal(t, 0, len(parsedCommand.InstallTargets))
},
},
{
name: "multiple package installations",
command: "npm install @types/node @types/react",
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
assert.NoError(t, err)
assert.Equal(t, 2, len(parsedCommand.InstallTargets))
assert.Equal(t, "@types/node", parsedCommand.InstallTargets[0].PackageVersion.Package.Name)
assert.Equal(t, "@types/react", parsedCommand.InstallTargets[1].PackageVersion.Package.Name)
},
},
{
name: "not an installation command",
command: "npm update @types/node",
assert: func(t *testing.T, parsedCommand *ParsedCommand, err error) {
assert.NoError(t, err)
assert.NotNil(t, parsedCommand)
assert.Equal(t, 0, len(parsedCommand.InstallTargets))
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
npm, err := NewNpmPackageManager(DefaultNpmPackageManagerConfig())
assert.NoError(t, err)
parsedCommand, err := npm.ParseCommand(strings.Split(tc.command, " "))
tc.assert(t, parsedCommand, err)
})
}
}
+53
View File
@@ -0,0 +1,53 @@
package packagemanager
import (
"context"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
)
type Command struct {
Args []string
}
type PackageInstallTarget struct {
PackageVersion *packagev1.PackageVersion
}
func (pit *PackageInstallTarget) HasVersion() bool {
return pit.PackageVersion != nil && pit.PackageVersion.GetVersion() != ""
}
type ParsedCommand struct {
// Original command
Command Command
// Parsed install target if this is an install command
InstallTargets []*PackageInstallTarget
}
func (pc *ParsedCommand) HasInstallTarget() bool {
return len(pc.InstallTargets) > 0
}
// PackageManager is the contract for implementing a package manager
type PackageManager interface {
// Name of the package manager implementation
Name() string
// ParseCommand parses the command and returns a parsed command
// specific to the package manager implementation
ParseCommand(args []string) (*ParsedCommand, error)
}
// PackageResolver is the contract for resolving package info
type PackageResolver interface {
// ResolveLatestVersion resolves the latest version for a given package
ResolveLatestVersion(context.Context, *packagev1.Package) (*packagev1.PackageVersion, error)
// ResolveDependencies resolves the dependencies for a given package version
// It returns a flattened list of all the dependencies based on implementation
// specific config. The version resolution is based on minimum version selection
// for a given version range.
ResolveDependencies(context.Context, *packagev1.PackageVersion) ([]*packagev1.PackageVersion, error)
}