diff --git a/cmd/whatsun/cmd_analyze.go b/cmd/whatsun/cmd_analyze.go index 78f55be..074de2a 100644 --- a/cmd/whatsun/cmd_analyze.go +++ b/cmd/whatsun/cmd_analyze.go @@ -16,6 +16,7 @@ import ( func analyzeCmd() *cobra.Command { var ignore []string + var plain bool cmd := &cobra.Command{ Use: "analyze [path]", Short: "Analyze a code repository and show results", @@ -30,16 +31,18 @@ func analyzeCmd() *cobra.Command { if len(args) > 0 { path = args[0] } - return runAnalyze(cmd.Context(), path, ignore, cmd.OutOrStdout(), cmd.ErrOrStderr()) + return runAnalyze(cmd.Context(), path, ignore, plain, cmd.OutOrStdout(), cmd.ErrOrStderr()) }, } cmd.Flags().StringSliceVar(&ignore, "ignore", []string{}, "Paths (or patterns) to ignore, adding to defaults.") + cmd.Flags().BoolVar(&plain, "plain", false, + "Output plain tab-separated values with header row.") return cmd } -func runAnalyze(ctx context.Context, path string, ignore []string, stdout, stderr io.Writer) error { +func runAnalyze(ctx context.Context, path string, ignore []string, plain bool, stdout, stderr io.Writer) error { fsys, disableGitIgnore, err := setupFileSystem(ctx, path, stderr) if err != nil { return err @@ -76,26 +79,30 @@ func runAnalyze(ctx context.Context, path string, ignore []string, stdout, stder return nil } - tbl := table.NewWriter() - tbl.AppendHeader(table.Row{"Path", "Ruleset", "Result", "Groups", "With"}) + if plain { + outputAnalyzePlain(reports, stdout) + } else { + tbl := table.NewWriter() + tbl.AppendHeader(table.Row{"Path", "Ruleset", "Result", "Groups", "With"}) - for _, report := range reports { - if report.Maybe { - continue - } - var with string - if len(report.With) > 0 { - for k, v := range report.With { - if v.Error == "" && !isEmpty(v.Value) { - with += fmt.Sprintf("%s: %s\n", k, v.Value) + for _, report := range reports { + if report.Maybe { + continue + } + var with string + if len(report.With) > 0 { + for k, v := range report.With { + if v.Error == "" && !isEmpty(v.Value) { + with += fmt.Sprintf("%s: %s\n", k, v.Value) + } } + with = strings.TrimSpace(with) } - with = strings.TrimSpace(with) + tbl.AppendRow(table.Row{report.Path, report.Ruleset, report.Result, strings.Join(report.Groups, ", "), with}) } - tbl.AppendRow(table.Row{report.Path, report.Ruleset, report.Result, strings.Join(report.Groups, ", "), with}) - } - fmt.Fprintln(stdout, tbl.Render()) + fmt.Fprintln(stdout, tbl.Render()) + } return nil } diff --git a/cmd/whatsun/cmd_deps.go b/cmd/whatsun/cmd_deps.go new file mode 100644 index 0000000..6838534 --- /dev/null +++ b/cmd/whatsun/cmd_deps.go @@ -0,0 +1,207 @@ +package main + +import ( + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/go-git/go-git/v5/plumbing/format/gitignore" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/spf13/cobra" + + "github.com/upsun/whatsun/internal/fsgitignore" + "github.com/upsun/whatsun/pkg/dep" +) + +func depsCmd() *cobra.Command { + var ignore []string + var includeIndirect bool + var includeDev bool + var plain bool + cmd := &cobra.Command{ + Use: "deps [path]", + Short: "List dependencies found in the repository", + Args: cobra.RangeArgs(0, 1), + ValidArgsFunction: func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveFilterDirs + }, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + path := "." + if len(args) > 0 { + path = args[0] + } + return runDeps(cmd.Context(), path, ignore, includeIndirect, includeDev, plain, cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + } + cmd.Flags().StringSliceVar(&ignore, "ignore", []string{}, + "Paths (or patterns) to ignore, adding to defaults.") + cmd.Flags().BoolVar(&includeIndirect, "include-indirect", false, + "Include indirect/transitive dependencies in addition to direct dependencies.") + cmd.Flags().BoolVar(&includeDev, "include-dev", false, + "Include development-only dependencies.") + cmd.Flags().BoolVar(&plain, "plain", false, + "Output plain tab-separated values with header row.") + + return cmd +} + +func runDeps( + ctx context.Context, + path string, + ignore []string, + includeIndirect, includeDev, plain bool, + stdout, stderr io.Writer, +) error { + fsys, disableGitIgnore, err := setupFileSystem(ctx, path, stderr) + if err != nil { + return err + } + + dependencies, err := collectAllDependencies(ctx, fsys, ignore, disableGitIgnore) + if err != nil { + return fmt.Errorf("failed to collect dependencies: %w", err) + } + + // Filter dependencies based on flags + var filteredDeps []dependencyInfo + for _, depInfo := range dependencies { + // Skip indirect dependencies if not requested + if !includeIndirect && !depInfo.Dependency.IsDirect { + continue + } + // Skip dev dependencies unless --include-dev is specified + if !includeDev && depInfo.Dependency.IsDevOnly { + continue + } + filteredDeps = append(filteredDeps, depInfo) + } + + if len(filteredDeps) == 0 { + if !includeIndirect { + fmt.Fprintln(stderr, "No direct dependencies found.") + } else { + fmt.Fprintln(stderr, "No dependencies found.") + } + return nil + } + + if plain { + outputDepsPlain(filteredDeps, stdout) + } else { + tbl := table.NewWriter() + tbl.AppendHeader(table.Row{"Path", "Tool", "Name", "Constraint", "Version"}) + + for _, depInfo := range filteredDeps { + tbl.AppendRow(table.Row{ + depInfo.Path, + depInfo.Dependency.ToolName, + depInfo.Dependency.Name, + depInfo.Dependency.Constraint, + depInfo.Dependency.Version, + }) + } + + fmt.Fprintln(stdout, tbl.Render()) + } + return nil +} + +type dependencyInfo struct { + Path string + Manager string + Dependency dep.Dependency +} + +func collectAllDependencies( + ctx context.Context, + fsys fs.FS, + ignore []string, + disableGitIgnore bool, +) ([]dependencyInfo, error) { + var allDeps []dependencyInfo + + var ignorePatterns = fsgitignore.GetDefaultIgnorePatterns() + if len(ignore) > 0 { + ignorePatterns = append(ignorePatterns, fsgitignore.ParsePatterns(ignore, fsgitignore.Split("."))...) + } + + err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if err != nil { + return err + } + + if !d.IsDir() { + return nil + } + + // Hard-limit the directory depth to 16. + if strings.Count(path, string(os.PathSeparator)) >= 16 { + return filepath.SkipDir + } + + // Skip .git and node_modules + if d.Name() == ".git" || d.Name() == "node_modules" { + return fs.SkipDir + } + + // Apply gitignore patterns + if gitignore.NewMatcher(ignorePatterns).Match(fsgitignore.Split(path), true) { + return fs.SkipDir + } + + // Parse additional .gitignore files if not disabled + if !disableGitIgnore { + patterns, err := fsgitignore.ParseIgnoreFiles(fsys, path) + if err != nil { + return err + } + ignorePatterns = append(ignorePatterns, patterns...) + } + + // Try each manager type for this directory + for _, managerType := range dep.AllManagerTypes { + manager, err := dep.GetManager(managerType, fsys, path) + if err != nil { + return err + } + + if err := manager.Init(); err != nil { + return err + } + + // Get all dependencies by using a wildcard pattern + deps := manager.Find("*") + slices.SortStableFunc(deps, func(a, b dep.Dependency) int { + return strings.Compare(a.Name, b.Name) + }) + for _, dependency := range deps { + allDeps = append(allDeps, dependencyInfo{ + Path: path, + Manager: managerType, + Dependency: dependency, + }) + } + } + + return nil + }) + + if err != nil { + return nil, err + } + + return allDeps, nil +} diff --git a/cmd/whatsun/main.go b/cmd/whatsun/main.go index 606bed2..7e32d79 100644 --- a/cmd/whatsun/main.go +++ b/cmd/whatsun/main.go @@ -21,7 +21,7 @@ func main() { Short: "Analyze a code repository", } - rootCmd.AddCommand(analyzeCmd(), digestCmd(), treeCmd()) + rootCmd.AddCommand(analyzeCmd(), digestCmd(), treeCmd(), depsCmd()) if err := rootCmd.Execute(); err != nil { fmt.Println(color.RedString(err.Error())) diff --git a/cmd/whatsun/plain_output.go b/cmd/whatsun/plain_output.go new file mode 100644 index 0000000..8064fae --- /dev/null +++ b/cmd/whatsun/plain_output.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "io" + "strings" + + "github.com/upsun/whatsun/pkg/rules" +) + +// outputDepsPlain outputs dependencies in plain tab-separated format +func outputDepsPlain(deps []dependencyInfo, stdout io.Writer) { + fmt.Fprintln(stdout, "Path\tTool\tName\tConstraint\tVersion") + for _, depInfo := range deps { + fmt.Fprintf(stdout, "%s\t%s\t%s\t%s\t%s\n", + depInfo.Path, + depInfo.Dependency.ToolName, + depInfo.Dependency.Name, + depInfo.Dependency.Constraint, + depInfo.Dependency.Version, + ) + } +} + +// outputAnalyzePlain outputs analysis reports in plain tab-separated format +func outputAnalyzePlain(reports []rules.Report, stdout io.Writer) { + fmt.Fprintln(stdout, "Path\tRuleset\tResult\tGroups\tWith") + for _, report := range reports { + if report.Maybe { + continue + } + var with string + if len(report.With) > 0 { + var parts []string + for k, v := range report.With { + if v.Error == "" && !isEmpty(v.Value) { + parts = append(parts, fmt.Sprintf("%s: %s", k, v.Value)) + } + } + with = strings.Join(parts, "; ") + } + fmt.Fprintf(stdout, "%s\t%s\t%s\t%s\t%s\n", + report.Path, + report.Ruleset, + report.Result, + strings.Join(report.Groups, ", "), + with, + ) + } +} diff --git a/pkg/dep/dep.go b/pkg/dep/dep.go index de26c58..7f9a3d6 100644 --- a/pkg/dep/dep.go +++ b/pkg/dep/dep.go @@ -43,6 +43,9 @@ type Dependency struct { Name string // The standard package name, which may include the vendor name. Constraint string // The version constraint. Version string // The resolved version (e.g. from a lock file). + IsDirect bool // True if explicitly specified in manifest, false if transitive/from lock file only. + IsDevOnly bool // True if this is a development-only dependency. + ToolName string // The external name of the tool that manages this dependency (e.g. "uv", "poetry", "composer"). } type Manager interface { diff --git a/pkg/dep/dotnet.go b/pkg/dep/dotnet.go index 7f4e4ba..ab264b4 100644 --- a/pkg/dep/dotnet.go +++ b/pkg/dep/dotnet.go @@ -99,6 +99,7 @@ func (m *dotnetManager) parseCSProj(filename string, dest *csprojFile) error { } func (m *dotnetManager) Get(name string) (Dependency, bool) { + // First check direct dependencies from .csproj files for _, csproj := range m.csprojFiles { for _, itemGroup := range csproj.ItemGroups { for _, pkgRef := range itemGroup.PackageReferences { @@ -113,11 +114,31 @@ func (m *dotnetManager) Get(name string) (Dependency, bool) { Name: pkgRef.Include, Constraint: pkgRef.Version, Version: version, + IsDirect: true, + ToolName: "dotnet", }, true } } } } + + // Then check indirect dependencies from lock file + for _, target := range m.lockFile.Targets { + for key := range target { + if strings.Contains(key, "/") { + parts := strings.SplitN(key, "/", 2) + if len(parts) == 2 && parts[0] == name { + return Dependency{ + Name: name, + Version: parts[1], + IsDirect: false, + ToolName: "dotnet", + }, true + } + } + } + } + return Dependency{}, false } @@ -139,6 +160,7 @@ func (m *dotnetManager) Find(pattern string) []Dependency { var deps []Dependency seen := make(map[string]bool) + // First, add direct dependencies from .csproj files for _, csproj := range m.csprojFiles { for _, itemGroup := range csproj.ItemGroups { for _, pkgRef := range itemGroup.PackageReferences { @@ -148,11 +170,36 @@ func (m *dotnetManager) Find(pattern string) []Dependency { Name: pkgRef.Include, Constraint: pkgRef.Version, Version: m.getLockedVersion(pkgRef.Include), + IsDirect: true, + ToolName: "dotnet", }) } } } } + + // Then, add indirect dependencies from lock file + for _, target := range m.lockFile.Targets { + for key := range target { + if strings.Contains(key, "/") { + parts := strings.SplitN(key, "/", 2) + if len(parts) == 2 { + packageName := parts[0] + version := parts[1] + if wildcard.Match(pattern, packageName) && !seen[packageName] { + seen[packageName] = true + deps = append(deps, Dependency{ + Name: packageName, + Version: version, + IsDirect: false, + ToolName: "dotnet", + }) + } + } + } + } + } + return deps } diff --git a/pkg/dep/elixir.go b/pkg/dep/elixir.go index d271ad1..5bbb479 100644 --- a/pkg/dep/elixir.go +++ b/pkg/dep/elixir.go @@ -69,6 +69,8 @@ func (m *elixirManager) Get(name string) (Dependency, bool) { Name: name, Constraint: req, Version: m.resolved[name], + IsDirect: true, // Dependencies from mix.exs are direct + ToolName: "mix", }, true } @@ -80,6 +82,8 @@ func (m *elixirManager) Find(pattern string) []Dependency { Name: name, Constraint: constraint, Version: m.resolved[name], + IsDirect: true, // Dependencies from mix.exs are direct + ToolName: "mix", }) } } diff --git a/pkg/dep/elixir_test.go b/pkg/dep/elixir_test.go index d74e423..9cb5ca8 100644 --- a/pkg/dep/elixir_test.go +++ b/pkg/dep/elixir_test.go @@ -75,6 +75,8 @@ end Name: "phoenix", Version: "1.6.5", Constraint: "~> 1.6.5", + IsDirect: true, + ToolName: "mix", }}}, } for _, c := range toFind { @@ -91,6 +93,8 @@ end Name: "phoenix", Version: "1.6.5", Constraint: "~> 1.6.5", + IsDirect: true, + ToolName: "mix", }, found: true}, } for _, c := range toGet { diff --git a/pkg/dep/go.go b/pkg/dep/go.go index 77d08e2..df50eb2 100644 --- a/pkg/dep/go.go +++ b/pkg/dep/go.go @@ -50,8 +50,10 @@ func (m *goManager) Get(name string) (Dependency, bool) { for _, v := range m.file.Require { if v.Mod.Path == name && !v.Indirect { return Dependency{ - Name: v.Mod.Path, - Version: v.Mod.Version, + Name: v.Mod.Path, + Version: v.Mod.Version, + IsDirect: !v.Indirect, + ToolName: "go", }, true } } @@ -61,10 +63,12 @@ func (m *goManager) Get(name string) (Dependency, bool) { func (m *goManager) Find(pattern string) []Dependency { var deps []Dependency for _, v := range m.file.Require { - if !v.Indirect && wildcard.Match(pattern, v.Mod.Path) { + if wildcard.Match(pattern, v.Mod.Path) { deps = append(deps, Dependency{ - Name: v.Mod.Path, - Version: v.Mod.Version, + Name: v.Mod.Path, + Version: v.Mod.Version, + IsDirect: !v.Indirect, + ToolName: "go", }) } } diff --git a/pkg/dep/go_test.go b/pkg/dep/go_test.go index 5b8f204..4893d1a 100644 --- a/pkg/dep/go_test.go +++ b/pkg/dep/go_test.go @@ -32,8 +32,10 @@ func TestGoModules(t *testing.T) { dependencies []dep.Dependency }{ {"github.com/gofiber/fiber*", []dep.Dependency{{ - Name: "github.com/gofiber/fiber/v2", - Version: "v2.52.6", + Name: "github.com/gofiber/fiber/v2", + Version: "v2.52.6", + IsDirect: true, + ToolName: "go", }}}, } for _, c := range toFind { @@ -46,8 +48,10 @@ func TestGoModules(t *testing.T) { found bool }{ {"github.com/gofiber/fiber/v2", dep.Dependency{ - Name: "github.com/gofiber/fiber/v2", - Version: "v2.52.6", + Name: "github.com/gofiber/fiber/v2", + Version: "v2.52.6", + IsDirect: true, + ToolName: "go", }, true}, } for _, c := range toGet { diff --git a/pkg/dep/java.go b/pkg/dep/java.go index 1b71a28..5331abe 100644 --- a/pkg/dep/java.go +++ b/pkg/dep/java.go @@ -115,22 +115,26 @@ func parsePomXML(fsys fs.FS, path string) ([]Dependency, error) { var deps = make([]Dependency, 0, len(project.Dependencies.Dependency)+1) if project.Parent.GroupID != "" { deps = append(deps, Dependency{ - Vendor: project.Parent.GroupID, - Name: project.Parent.GroupID + ":" + project.Parent.ArtifactID, - Version: project.Parent.Version, + Vendor: project.Parent.GroupID, + Name: project.Parent.GroupID + ":" + project.Parent.ArtifactID, + Version: project.Parent.Version, + IsDirect: true, // Parent dependencies from pom.xml are direct + ToolName: "maven", }) } for _, dep := range project.Dependencies.Dependency { deps = append(deps, Dependency{ - Vendor: dep.GroupID, - Name: dep.GroupID + ":" + dep.ArtifactID, - Version: dep.Version, + Vendor: dep.GroupID, + Name: dep.GroupID + ":" + dep.ArtifactID, + Version: dep.Version, + IsDirect: true, // Dependencies from pom.xml are direct + ToolName: "maven", }) } return deps, nil } -func parseBuildGradle(fsys fs.FS, path, filename string, patt *regexp.Regexp) ([]Dependency, error) { +func parseBuildGradle(fsys fs.FS, path, filename string, patt *regexp.Regexp, toolName string) ([]Dependency, error) { f, err := fsys.Open(filepath.Join(path, filename)) if err != nil { if errors.Is(err, fs.ErrNotExist) { @@ -144,7 +148,13 @@ func parseBuildGradle(fsys fs.FS, path, filename string, patt *regexp.Regexp) ([ for scanner.Scan() { matches := patt.FindStringSubmatch(strings.TrimSpace(scanner.Text())) if len(matches) > 3 { - deps = append(deps, Dependency{Vendor: matches[1], Name: matches[1] + ":" + matches[2], Version: matches[3]}) + deps = append(deps, Dependency{ + Vendor: matches[1], + Name: matches[1] + ":" + matches[2], + Version: matches[3], + IsDirect: true, // Dependencies from build.gradle files are direct + ToolName: toolName, + }) } } if err := scanner.Err(); err != nil { @@ -154,11 +164,11 @@ func parseBuildGradle(fsys fs.FS, path, filename string, patt *regexp.Regexp) ([ } func parseBuildGradleGroovy(fsys fs.FS, path string) ([]Dependency, error) { - return parseBuildGradle(fsys, path, "build.gradle", gradleGroovyPatt) + return parseBuildGradle(fsys, path, "build.gradle", gradleGroovyPatt, "gradle") } func parseBuildGradleKotlin(fsys fs.FS, path string) ([]Dependency, error) { - return parseBuildGradle(fsys, path, "build.gradle.kts", gradleKotlinPatt) + return parseBuildGradle(fsys, path, "build.gradle.kts", gradleKotlinPatt, "gradle") } func parseBuildSBT(fsys fs.FS, path string) ([]Dependency, error) { @@ -220,9 +230,11 @@ func parseSBTDependencies(line string) []Dependency { for _, match := range sbtMatches { if len(match) == 4 { deps = append(deps, Dependency{ - Vendor: match[1], - Name: match[1] + ":" + match[2], - Version: match[3], + Vendor: match[1], + Name: match[1] + ":" + match[2], + Version: match[3], + IsDirect: true, // Dependencies from build.sbt are direct + ToolName: "sbt", }) } } diff --git a/pkg/dep/java_test.go b/pkg/dep/java_test.go index da02b1c..10999ad 100644 --- a/pkg/dep/java_test.go +++ b/pkg/dep/java_test.go @@ -33,9 +33,11 @@ implementation 'com.fasterxml.jackson.core:jackson-databind:2.12.5' dependencies []dep.Dependency }{ {"org.apache.commons:*", []dep.Dependency{{ - Vendor: "org.apache.commons", - Name: "org.apache.commons:commons-lang3", - Version: "3.12.0", + Vendor: "org.apache.commons", + Name: "org.apache.commons:commons-lang3", + Version: "3.12.0", + IsDirect: true, + ToolName: "gradle", }}}, } for _, c := range toFind { @@ -48,9 +50,11 @@ implementation 'com.fasterxml.jackson.core:jackson-databind:2.12.5' found bool }{ {"org.apache.commons:commons-lang3", dep.Dependency{ - Vendor: "org.apache.commons", - Name: "org.apache.commons:commons-lang3", - Version: "3.12.0", + Vendor: "org.apache.commons", + Name: "org.apache.commons:commons-lang3", + Version: "3.12.0", + IsDirect: true, + ToolName: "gradle", }, true}, {"org.springframework.boot:spring-boot-maven-plugin", dep.Dependency{}, false}, } @@ -81,19 +85,25 @@ func TestGradleKTS(t *testing.T) { }{ {"org.codehaus.groovy:*", []dep.Dependency{ { - Vendor: "org.codehaus.groovy", - Name: "org.codehaus.groovy:groovy", - Version: "3.0.5", + Vendor: "org.codehaus.groovy", + Name: "org.codehaus.groovy:groovy", + Version: "3.0.5", + IsDirect: true, + ToolName: "gradle", }, { - Vendor: "org.codehaus.groovy", - Name: "org.codehaus.groovy:groovy-json", - Version: "3.0.5", + Vendor: "org.codehaus.groovy", + Name: "org.codehaus.groovy:groovy-json", + Version: "3.0.5", + IsDirect: true, + ToolName: "gradle", }, { - Vendor: "org.codehaus.groovy", - Name: "org.codehaus.groovy:groovy-nio", - Version: "3.0.5", + Vendor: "org.codehaus.groovy", + Name: "org.codehaus.groovy:groovy-nio", + Version: "3.0.5", + IsDirect: true, + ToolName: "gradle", }, }}, } @@ -112,9 +122,11 @@ func TestGradleKTS(t *testing.T) { }{ {name: "org.apache.commons:commons-lang3"}, {"org.codehaus.groovy:groovy-nio", dep.Dependency{ - Vendor: "org.codehaus.groovy", - Name: "org.codehaus.groovy:groovy-nio", - Version: "3.0.5", + Vendor: "org.codehaus.groovy", + Name: "org.codehaus.groovy:groovy-nio", + Version: "3.0.5", + IsDirect: true, + ToolName: "gradle", }, true}, } for _, c := range toGet { @@ -197,21 +209,29 @@ func TestMaven(t *testing.T) { }{ {"org.springframework.boot*", []dep.Dependency{ { - Vendor: "org.springframework.boot", - Name: "org.springframework.boot:spring-boot-starter-data-jpa", + Vendor: "org.springframework.boot", + Name: "org.springframework.boot:spring-boot-starter-data-jpa", + IsDirect: true, + ToolName: "maven", }, { - Vendor: "org.springframework.boot", - Name: "org.springframework.boot:spring-boot-starter-parent", - Version: "2.4.1", + Vendor: "org.springframework.boot", + Name: "org.springframework.boot:spring-boot-starter-parent", + Version: "2.4.1", + IsDirect: true, + ToolName: "maven", }, { - Vendor: "org.springframework.boot", - Name: "org.springframework.boot:spring-boot-starter-test", + Vendor: "org.springframework.boot", + Name: "org.springframework.boot:spring-boot-starter-test", + IsDirect: true, + ToolName: "maven", }, { - Vendor: "org.springframework.boot", - Name: "org.springframework.boot:spring-boot-starter-web", + Vendor: "org.springframework.boot", + Name: "org.springframework.boot:spring-boot-starter-web", + IsDirect: true, + ToolName: "maven", }, }}, } @@ -253,31 +273,41 @@ func TestSBT(t *testing.T) { }{ {"com.typesafe.play*", []dep.Dependency{ { - Vendor: "com.typesafe.play", - Name: "com.typesafe.play:play-akka-http-server", - Version: "2.8.20", + Vendor: "com.typesafe.play", + Name: "com.typesafe.play:play-akka-http-server", + Version: "2.8.20", + IsDirect: true, + ToolName: "sbt", }, { - Vendor: "com.typesafe.play", - Name: "com.typesafe.play:play-json", - Version: "2.9.4", + Vendor: "com.typesafe.play", + Name: "com.typesafe.play:play-json", + Version: "2.9.4", + IsDirect: true, + ToolName: "sbt", }, { - Vendor: "com.typesafe.play", - Name: "com.typesafe.play:play-slick", - Version: "5.1.0", + Vendor: "com.typesafe.play", + Name: "com.typesafe.play:play-slick", + Version: "5.1.0", + IsDirect: true, + ToolName: "sbt", }, { - Vendor: "com.typesafe.play", - Name: "com.typesafe.play:play-slick-evolutions", - Version: "5.1.0", + Vendor: "com.typesafe.play", + Name: "com.typesafe.play:play-slick-evolutions", + Version: "5.1.0", + IsDirect: true, + ToolName: "sbt", }, }}, {"org.postgresql*", []dep.Dependency{ { - Vendor: "org.postgresql", - Name: "org.postgresql:postgresql", - Version: "42.6.0", + Vendor: "org.postgresql", + Name: "org.postgresql:postgresql", + Version: "42.6.0", + IsDirect: true, + ToolName: "sbt", }, }}, } @@ -295,14 +325,18 @@ func TestSBT(t *testing.T) { found bool }{ {"com.typesafe.play:play-json", dep.Dependency{ - Vendor: "com.typesafe.play", - Name: "com.typesafe.play:play-json", - Version: "2.9.4", + Vendor: "com.typesafe.play", + Name: "com.typesafe.play:play-json", + Version: "2.9.4", + IsDirect: true, + ToolName: "sbt", }, true}, {"org.postgresql:postgresql", dep.Dependency{ - Vendor: "org.postgresql", - Name: "org.postgresql:postgresql", - Version: "42.6.0", + Vendor: "org.postgresql", + Name: "org.postgresql:postgresql", + Version: "42.6.0", + IsDirect: true, + ToolName: "sbt", }, true}, {"org.apache.commons:commons-lang3", dep.Dependency{}, false}, } diff --git a/pkg/dep/js.go b/pkg/dep/js.go index 85ea342..d3ad323 100644 --- a/pkg/dep/js.go +++ b/pkg/dep/js.go @@ -18,6 +18,7 @@ type jsManager struct { initOnce sync.Once deps map[string]Dependency + toolName string } func newJSManager(fsys fs.FS, path string) Manager { @@ -73,51 +74,65 @@ func (m *jsManager) parse() error { // Handle Meteor dependencies. if _, ok := files[".meteor"]; ok { + m.toolName = "meteor" meteorDeps, err := parseMeteorDeps(m.fsys, m.path) if err != nil { return err } for name, dep := range meteorDeps { + dep.ToolName = m.toolName m.deps[name] = dep } } // Handle Deno dependencies. if _, ok := files["deno.json"]; ok { + m.toolName = "deno" denoDeps, err := parseDenoDeps(m.fsys, m.path) if err != nil { return err } for name, dep := range denoDeps { + dep.ToolName = m.toolName m.deps[name] = dep } } // For npm, pnpm, bun, and yarn, always parse package.json first for constraints. if _, ok := files["package.json"]; ok { + // Detect tool based on lock files + if _, ok := files["pnpm-lock.yaml"]; ok { + m.toolName = "pnpm" + } else if _, ok := files["bun.lock"]; ok { + m.toolName = "bun" + } else { + m.toolName = "npm" // default for package.json + } + pkgJsonDeps, err := parsePackageDotJsonDeps(m.fsys, m.path, m.vendorName) if err != nil { return err } for name, dep := range pkgJsonDeps { + dep.ToolName = m.toolName m.deps[name] = dep } - } - // Then update Version fields from lock files if present. - if _, ok := files["package-lock.json"]; ok { - if err := parseNpmLockDeps(m.fsys, m.path, m.deps, m.vendorName); err != nil { - return err + // Then update Version fields from lock files if present. + if _, ok := files["package-lock.json"]; ok { + if err := parseNpmLockDeps(m.fsys, m.path, m.deps, m.vendorName, m.toolName); err != nil { + return err + } } - } - if _, ok := files["pnpm-lock.yaml"]; ok { - if err := parsePnpmLockDeps(m.fsys, m.path, m.deps, m.vendorName); err != nil { - return err + if _, ok := files["pnpm-lock.yaml"]; ok { + if err := parsePnpmLockDeps(m.fsys, m.path, m.deps, m.vendorName, m.toolName); err != nil { + return err + } } - } - if _, ok := files["bun.lock"]; ok { - if err := parseBunLockDeps(m.fsys, m.path, m.deps, m.vendorName); err != nil { - return err + if _, ok := files["bun.lock"]; ok { + if err := parseBunLockDeps(m.fsys, m.path, m.deps, m.vendorName, m.toolName); err != nil { + return err + } } } @@ -137,8 +152,24 @@ func parsePackageDotJsonDeps(fsys fs.FS, path string, vendorName func(string) st return nil, err } deps := map[string]Dependency{} + // Add regular dependencies as direct, non-dev for name, constraint := range npmManifest.Dependencies { - deps[name] = Dependency{Constraint: constraint, Name: name, Vendor: vendorName(name)} + deps[name] = Dependency{ + Constraint: constraint, + Name: name, + Vendor: vendorName(name), + IsDirect: true, + } + } + // Add dev dependencies as direct, dev-only + for name, constraint := range npmManifest.DevDependencies { + deps[name] = Dependency{ + Constraint: constraint, + Name: name, + Vendor: vendorName(name), + IsDirect: true, + IsDevOnly: true, + } } return deps, nil } @@ -152,7 +183,7 @@ func parseDenoDeps(fsys fs.FS, path string) (map[string]Dependency, error) { deps := map[string]Dependency{} for _, constraint := range denoManifest.Imports { if strings.HasPrefix(constraint, "jsr:") || strings.HasPrefix(constraint, "npm:") { - addDepVersion(deps, constraint, func(string) string { return "" }) + addDepVersion(deps, constraint, func(string) string { return "" }, true, "deno") continue } if matches := denoPackageURL.FindStringSubmatch(constraint); len(matches) == 3 { @@ -161,7 +192,12 @@ func parseDenoDeps(fsys fs.FS, path string) (map[string]Dependency, error) { d.Version = version deps[name] = d } else { - deps[name] = Dependency{Name: name, Version: version} + deps[name] = Dependency{ + Name: name, + Version: version, + IsDirect: true, + ToolName: "deno", + } } } } @@ -170,54 +206,79 @@ func parseDenoDeps(fsys fs.FS, path string) (map[string]Dependency, error) { return nil, err } for nameVersion := range denoLocked.JSR { - addDepVersion(deps, nameVersion, func(string) string { return "" }) + addDepVersion(deps, nameVersion, func(string) string { return "" }, false, "deno") } for nameVersion := range denoLocked.NPM { - addDepVersion(deps, nameVersion, func(string) string { return "" }) + addDepVersion(deps, nameVersion, func(string) string { return "" }, false, "deno") } return deps, nil } // parseNpmLockDeps updates deps with versions from package-lock.json -func parseNpmLockDeps(fsys fs.FS, path string, deps map[string]Dependency, vendorName func(string) string) error { +func parseNpmLockDeps( + fsys fs.FS, path string, deps map[string]Dependency, + vendorName func(string) string, toolName string, +) error { var locked packageLockJSON if err := parseJSON(fsys, path, "package-lock.json", &locked); err != nil { return err } for name, pkg := range locked.Dependencies { if d, ok := deps[name]; ok { + // Update existing dependency (preserve IsDirect status) d.Version = pkg.Version deps[name] = d } else { - deps[name] = Dependency{Name: name, Version: pkg.Version, Vendor: vendorName(name)} + // New dependency only found in lock file (indirect) + deps[name] = Dependency{ + Name: name, + Version: pkg.Version, + Vendor: vendorName(name), + IsDirect: false, + ToolName: toolName, + } } } for name, pkg := range locked.Packages { name = strings.TrimPrefix(name, "node_modules/") if d, ok := deps[name]; ok { + // Update existing dependency (preserve IsDirect status) d.Version = pkg.Version deps[name] = d } else { - deps[name] = Dependency{Name: name, Version: pkg.Version, Vendor: vendorName(name)} + // New dependency only found in lock file (indirect) + deps[name] = Dependency{ + Name: name, + Version: pkg.Version, + Vendor: vendorName(name), + IsDirect: false, + ToolName: toolName, + } } } return nil } // parsePnpmLockDeps updates deps with versions from pnpm-lock.yaml -func parsePnpmLockDeps(fsys fs.FS, path string, deps map[string]Dependency, vendorName func(string) string) error { +func parsePnpmLockDeps( + fsys fs.FS, path string, deps map[string]Dependency, + vendorName func(string) string, toolName string, +) error { var pnpmLocked pnpmLockYAML if err := parseYAML(fsys, path, "pnpm-lock.yaml", &pnpmLocked); err != nil { return err } for nameVersion := range pnpmLocked.Packages { - addDepVersion(deps, nameVersion, vendorName) + addDepVersion(deps, nameVersion, vendorName, false, toolName) } return nil } // parseBunLockDeps updates deps with versions from bun.lock -func parseBunLockDeps(fsys fs.FS, path string, deps map[string]Dependency, vendorName func(string) string) error { +func parseBunLockDeps( + fsys fs.FS, path string, deps map[string]Dependency, + vendorName func(string) string, toolName string, +) error { var bunLocked bunLock if err := parseJSONC(fsys, path, "bun.lock", &bunLocked); err != nil { return err @@ -230,23 +291,34 @@ func parseBunLockDeps(fsys fs.FS, path string, deps map[string]Dependency, vendo if !ok { continue } - addDepVersion(deps, first, vendorName) + addDepVersion(deps, first, vendorName, false, toolName) } return nil } // addDepVersion updates the deps map with a dependency parsed from nameVersion (e.g. "foo@1.2.3"). -func addDepVersion(deps map[string]Dependency, nameVersion string, vendorName func(string) string) { +func addDepVersion( + deps map[string]Dependency, nameVersion string, + vendorName func(string) string, isDirect bool, toolName string, +) { matches := npmNameVersion.FindStringSubmatch(nameVersion) if len(matches) != 3 { return } name, version := matches[1], matches[2] if d, ok := deps[name]; ok { + // Update existing dependency (preserve IsDirect status from manifest files) d.Version = version deps[name] = d } else { - deps[name] = Dependency{Name: name, Version: version, Vendor: vendorName(name)} + // New dependency (lock file dependencies are not dev-only) + deps[name] = Dependency{ + Name: name, + Version: version, + Vendor: vendorName(name), + IsDirect: isDirect, + ToolName: toolName, + } } } @@ -275,8 +347,11 @@ func parseMeteorDeps(fsys fs.FS, path string) (map[string]Dependency, error) { if line == "" || strings.HasPrefix(line, "#") { continue } - // Only the package name, no version - meteorDeps[line] = Dependency{Name: line} + // Dependencies from .meteor/packages are direct + meteorDeps[line] = Dependency{ + Name: line, + IsDirect: true, + } } } if meteorVersionsExists { @@ -291,10 +366,16 @@ func parseMeteorDeps(fsys fs.FS, path string) (map[string]Dependency, error) { if len(parts) == 2 { name, version := parts[0], parts[1] if dep, ok := meteorDeps[name]; ok { + // Update existing dependency (preserve IsDirect status) dep.Version = version meteorDeps[name] = dep } else { - meteorDeps[name] = Dependency{Name: name, Version: version} + // New dependency only in versions file (indirect) + meteorDeps[name] = Dependency{ + Name: name, + Version: version, + IsDirect: false, + } } } } @@ -303,7 +384,8 @@ func parseMeteorDeps(fsys fs.FS, path string) (map[string]Dependency, error) { } type packageJSON struct { - Dependencies map[string]string `json:"dependencies"` + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` } type denoJSON struct { diff --git a/pkg/dep/js_bun_test.go b/pkg/dep/js_bun_test.go index 8bd7ce7..5359cd1 100644 --- a/pkg/dep/js_bun_test.go +++ b/pkg/dep/js_bun_test.go @@ -38,6 +38,8 @@ func TestBun(t *testing.T) { Name: "vue", Constraint: "^3.5.13", Version: "3.5.13", + IsDirect: true, + ToolName: "bun", }}}, } for _, c := range cases { diff --git a/pkg/dep/js_deno_test.go b/pkg/dep/js_deno_test.go index 73f18d0..4602d7b 100644 --- a/pkg/dep/js_deno_test.go +++ b/pkg/dep/js_deno_test.go @@ -31,12 +31,16 @@ func TestDeno(t *testing.T) { dependencies []dep.Dependency }{ {"https://deno.land/x/fresh", []dep.Dependency{{ - Name: "https://deno.land/x/fresh", - Version: "1.7.3", + Name: "https://deno.land/x/fresh", + Version: "1.7.3", + IsDirect: true, + ToolName: "deno", }}}, {"*preact", []dep.Dependency{{ - Name: "https://esm.sh/preact", - Version: "10.22.0", + Name: "https://esm.sh/preact", + Version: "10.22.0", + IsDirect: true, + ToolName: "deno", }}}, } for _, c := range cases { diff --git a/pkg/dep/js_meteor_test.go b/pkg/dep/js_meteor_test.go index acc2f95..7844977 100644 --- a/pkg/dep/js_meteor_test.go +++ b/pkg/dep/js_meteor_test.go @@ -33,9 +33,9 @@ func TestMeteor(t *testing.T) { pattern string dependencies []dep.Dependency }{ - {"meteor-base", []dep.Dependency{{Name: "meteor-base", Version: "1.5.1"}}}, - {"ecmascript", []dep.Dependency{{Name: "ecmascript", Version: "0.16.7"}}}, - {"random", []dep.Dependency{{Name: "random", Version: "1.3.2"}}}, + {"meteor-base", []dep.Dependency{{Name: "meteor-base", Version: "1.5.1", IsDirect: true, ToolName: "meteor"}}}, + {"ecmascript", []dep.Dependency{{Name: "ecmascript", Version: "0.16.7", IsDirect: true, ToolName: "meteor"}}}, + {"random", []dep.Dependency{{Name: "random", Version: "1.3.2", IsDirect: true, ToolName: "meteor"}}}, } for _, c := range cases { deps := m.Find(c.pattern) diff --git a/pkg/dep/js_npm_test.go b/pkg/dep/js_npm_test.go index 21d148c..a6cfce1 100644 --- a/pkg/dep/js_npm_test.go +++ b/pkg/dep/js_npm_test.go @@ -38,6 +38,8 @@ func TestNPM(t *testing.T) { Name: "gatsby", Constraint: "^5.14.1", Version: "5.14.1", + IsDirect: true, + ToolName: "npm", }}}, } for _, c := range cases { diff --git a/pkg/dep/js_pnpm_test.go b/pkg/dep/js_pnpm_test.go index 89d8735..e5f8075 100644 --- a/pkg/dep/js_pnpm_test.go +++ b/pkg/dep/js_pnpm_test.go @@ -39,6 +39,8 @@ func TestPNPM(t *testing.T) { Name: "@strapi/strapi", Constraint: "^5.10.2", Version: "5.10.2", + IsDirect: true, + ToolName: "pnpm", }}}, } for _, c := range cases { diff --git a/pkg/dep/php.go b/pkg/dep/php.go index fa9c0e4..df1ead2 100644 --- a/pkg/dep/php.go +++ b/pkg/dep/php.go @@ -20,7 +20,8 @@ type phpManager struct { } type composerJSON struct { - Require map[string]string `json:"require"` + Require map[string]string `json:"require"` + RequireDev map[string]string `json:"require-dev"` } type composerLock struct { @@ -60,6 +61,7 @@ func (m *phpManager) Init() error { func (m *phpManager) Find(pattern string) []Dependency { var deps []Dependency + // Add regular dependencies (non-dev) for name, constraint := range m.composerJSON.Require { if wildcard.Match(pattern, name) { parts := strings.SplitN(name, "/", 2) @@ -72,6 +74,27 @@ func (m *phpManager) Find(pattern string) []Dependency { Name: name, Constraint: constraint, Version: m.getLockedVersion(name), + IsDirect: true, // Dependencies from composer.json are direct + ToolName: "composer", + }) + } + } + // Add dev dependencies + for name, constraint := range m.composerJSON.RequireDev { + if wildcard.Match(pattern, name) { + parts := strings.SplitN(name, "/", 2) + var vendor string + if len(parts) == 2 { + vendor = parts[0] + } + deps = append(deps, Dependency{ + Vendor: vendor, + Name: name, + Constraint: constraint, + Version: m.getLockedVersion(name), + IsDirect: true, // Dependencies from composer.json are direct + IsDevOnly: true, + ToolName: "composer", }) } } @@ -89,19 +112,32 @@ func (m *phpManager) getLockedVersion(packageName string) string { func (m *phpManager) Get(name string) (Dependency, bool) { packageName := name - constraint, ok := m.composerJSON.Require[name] - if !ok && m.getLockedVersion(packageName) == "" { + constraint, inRequire := m.composerJSON.Require[name] + constraintDev, inRequireDev := m.composerJSON.RequireDev[name] + + // Use dev constraint if not in regular require + if !inRequire && inRequireDev { + constraint = constraintDev + } + + // Must be in either require, require-dev, or lock file + if !inRequire && !inRequireDev && m.getLockedVersion(packageName) == "" { return Dependency{}, false } + var vendor string if strings.Contains(name, "/") { parts := strings.SplitN(name, "/", 2) vendor = parts[0] } + return Dependency{ Vendor: vendor, Name: name, Constraint: constraint, Version: m.getLockedVersion(packageName), + IsDirect: inRequire || inRequireDev, // Direct if found in composer.json + IsDevOnly: inRequireDev && !inRequire, // Dev-only if only in require-dev + ToolName: "composer", }, true } diff --git a/pkg/dep/php_test.go b/pkg/dep/php_test.go index 7ddcadc..e2be99b 100644 --- a/pkg/dep/php_test.go +++ b/pkg/dep/php_test.go @@ -42,6 +42,8 @@ func TestPHP(t *testing.T) { Name: "symfony/framework-bundle", Constraint: "^7.2", Version: "v7.2.3", + IsDirect: true, + ToolName: "composer", }}}, } for _, c := range toFind { @@ -60,10 +62,14 @@ func TestPHP(t *testing.T) { Name: "symfony/framework-bundle", Constraint: "^7.2", Version: "v7.2.3", + IsDirect: true, + ToolName: "composer", }, found: true}, {name: "php", dependency: dep.Dependency{ Name: "php", Constraint: ">=8.2", + IsDirect: true, + ToolName: "composer", }, found: true}, } for _, c := range toGet { diff --git a/pkg/dep/python.go b/pkg/dep/python.go index 6ea57f7..a63f709 100644 --- a/pkg/dep/python.go +++ b/pkg/dep/python.go @@ -20,8 +20,7 @@ type pythonManager struct { path string initOnce sync.Once - requirements map[string]string // constraints from pyproject.toml, requirements.txt, etc. - resolved map[string]string // resolved versions from uv.lock + dependencies []Dependency } func newPythonManager(fsys fs.FS, path string) Manager { @@ -39,10 +38,15 @@ func (m *pythonManager) Init() error { return err } +func (m *pythonManager) hasFile(filename string) bool { + _, err := m.fsys.Open(filepath.Join(m.path, filename)) + return err == nil +} + func (m *pythonManager) parseFile( filename string, - parseFunc func(io.Reader) (map[string]string, error), - targetMap *map[string]string, + parseFunc func(io.Reader, string) ([]Dependency, error), + toolName string, ) error { f, err := m.fsys.Open(filepath.Join(m.path, filename)) if err != nil { @@ -52,83 +56,154 @@ func (m *pythonManager) parseFile( return err } defer f.Close() - reqs, err := parseFunc(f) + deps, err := parseFunc(f, toolName) if err != nil { return err } - if *targetMap == nil { - *targetMap = make(map[string]string) + m.dependencies = append(m.dependencies, deps...) + return nil +} + +func (m *pythonManager) mergeResolvedVersions() { + var merged = make(map[string]Dependency) + for _, dep := range m.dependencies { + if existing, found := merged[dep.Name]; found { + // Merge: prefer constraint info from manifest files, version from lock files + if dep.Constraint != "" { + existing.Constraint = dep.Constraint + existing.IsDirect = dep.IsDirect + existing.IsDevOnly = dep.IsDevOnly + } + if dep.Version != "" { + existing.Version = dep.Version + } + merged[dep.Name] = existing + } else { + merged[dep.Name] = dep + } } - for k, v := range reqs { - (*targetMap)[k] = v + + // Convert back to slice + var i int + m.dependencies = make([]Dependency, len(merged)) + for _, dep := range merged { + m.dependencies[i] = dep + i++ } - return nil } -func (m *pythonManager) parse() error { - // Always try to parse both pyproject.toml and uv.lock if present - if err := m.parseFile("pyproject.toml", parsePyprojectTOML, &m.requirements); err != nil { - return err +func (m *pythonManager) determineTool() string { + switch { + case m.hasFile("uv.lock"): + return "uv" + case m.hasFile("poetry.lock"): + return "poetry" + case m.hasFile("pyproject.toml"): + // Check if pyproject.toml has specific tool configuration + if tool := m.detectPyprojectTool(); tool != "" { + return tool + } + return "python" // generic when tool can't be determined + case m.hasFile("requirements.txt"): + return "pip" + case m.hasFile("Pipfile"): + return "pipenv" + default: + return "" } - if err := m.parseFile("uv.lock", parseUvLock, &m.resolved); err != nil { - return err +} + +func (m *pythonManager) detectPyprojectTool() string { + f, err := m.fsys.Open(filepath.Join(m.path, "pyproject.toml")) + if err != nil { + return "" + } + defer f.Close() + + type PyProjectToolDetect struct { + Tool map[string]toml.Primitive `toml:"tool"` + } + + var pyProject PyProjectToolDetect + _, err = toml.NewDecoder(f).Decode(&pyProject) + if err != nil { + return "" } - // Fallbacks for constraints if pyproject.toml is missing - if len(m.requirements) == 0 { - if err := m.parseFile("requirements.txt", parseRequirementsTXT, &m.requirements); err != nil { + + // Check for specific tool sections + if _, exists := pyProject.Tool["poetry"]; exists { + return "poetry" + } + if _, exists := pyProject.Tool["uv"]; exists { + return "uv" + } + + return "" +} + +func (m *pythonManager) parse() error { + switch tool := m.determineTool(); tool { + case "uv": + if err := m.parseFile("pyproject.toml", parsePyprojectTOML, tool); err != nil { + return err + } + if err := m.parseFile("uv.lock", parseUvLock, tool); err != nil { + return err + } + m.mergeResolvedVersions() + + case "poetry": + if err := m.parseFile("pyproject.toml", parsePyprojectTOML, tool); err != nil { return err } - if err := m.parseFile("Pipfile", parsePipfile, &m.requirements); err != nil { + if err := m.parseFile("poetry.lock", parsePoetryLock, tool); err != nil { + return err + } + m.mergeResolvedVersions() + + case "python": + if err := m.parseFile("pyproject.toml", parsePyprojectTOML, tool); err != nil { + return err + } + + case "pip": + if err := m.parseFile("requirements.txt", parseRequirementsTXT, tool); err != nil { + return err + } + + case "pipenv": + if err := m.parseFile("Pipfile", parsePipfile, tool); err != nil { return err } } + return nil } func (m *pythonManager) Find(pattern string) []Dependency { - seen := make(map[string]struct{}) var deps []Dependency - // First, add all with constraints - for name, constraint := range m.requirements { - if wildcard.Match(pattern, name) { - deps = append(deps, Dependency{ - Name: name, - Constraint: constraint, - Version: m.resolved[name], - }) - seen[name] = struct{}{} - } - } - // Then, add any resolved-only deps not already included - for name, version := range m.resolved { - if _, already := seen[name]; !already && wildcard.Match(pattern, name) { - deps = append(deps, Dependency{ - Name: name, - Version: version, - }) + for _, dep := range m.dependencies { + if wildcard.Match(pattern, dep.Name) { + deps = append(deps, dep) } } return deps } func (m *pythonManager) Get(name string) (Dependency, bool) { - constraint, hasConstraint := m.requirements[name] - version, hasVersion := m.resolved[name] - if !hasConstraint && !hasVersion { - return Dependency{}, false + for _, dep := range m.dependencies { + if dep.Name == name { + return dep, true + } } - return Dependency{ - Name: name, - Constraint: constraint, - Version: version, - }, true + return Dependency{}, false } var pipPattern = regexp.MustCompile(`^([\w\-\.]+(?:\[[^\]]+\])?)(.*)$`) // parseRequirementsTXT parses a requirements.txt file -func parseRequirementsTXT(r io.Reader) (map[string]string, error) { - dependencies := make(map[string]string) +func parseRequirementsTXT(r io.Reader, toolName string) ([]Dependency, error) { + var dependencies []Dependency scanner := bufio.NewScanner(r) for scanner.Scan() { @@ -144,7 +219,12 @@ func parseRequirementsTXT(r io.Reader) (map[string]string, error) { if len(matches) > 2 { versionConstraint = matches[2] } - dependencies[packageName] = versionConstraint + dependencies = append(dependencies, Dependency{ + Name: packageName, + Constraint: versionConstraint, + IsDirect: true, + ToolName: toolName, + }) } } @@ -157,15 +237,20 @@ func parseRequirementsTXT(r io.Reader) (map[string]string, error) { var pipEnvPattern = regexp.MustCompile(`^\s*"?([\w-]+)"?\s*=\s*"([^"]+)"`) // parsePipfile extracts dependencies from a Pipfile -func parsePipfile(r io.Reader) (map[string]string, error) { - dependencies := make(map[string]string) +func parsePipfile(r io.Reader, toolName string) ([]Dependency, error) { + var dependencies []Dependency scanner := bufio.NewScanner(r) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) matches := pipEnvPattern.FindStringSubmatch(line) if len(matches) == 3 { - dependencies[matches[1]] = matches[2] + dependencies = append(dependencies, Dependency{ + Name: matches[1], + Constraint: matches[2], + IsDirect: true, + ToolName: toolName, + }) } } @@ -176,14 +261,18 @@ func parsePipfile(r io.Reader) (map[string]string, error) { } // parsePyprojectTOML parses pyproject.toml dependencies (Poetry and PEP 621) -func parsePyprojectTOML(r io.Reader) (map[string]string, error) { +func parsePyprojectTOML(r io.Reader, toolName string) ([]Dependency, error) { type PyProject struct { Project struct { - Dependencies []string `toml:"dependencies"` + Dependencies []string `toml:"dependencies"` + Optional map[string][]string `toml:"optional-dependencies"` } `toml:"project"` - Tool struct { + DependencyGroups map[string][]string `toml:"dependency-groups"` // PEP 735 + Tool struct { Poetry struct { - Dependencies map[string]any `toml:"dependencies"` + Dependencies map[string]any `toml:"dependencies"` + DevDependencies map[string]any `toml:"dev-dependencies"` // Legacy Poetry + Group map[string]map[string]map[string]any `toml:"group"` // Modern Poetry groups } `toml:"poetry"` } `toml:"tool"` } @@ -194,16 +283,26 @@ func parsePyprojectTOML(r io.Reader) (map[string]string, error) { return nil, err } - dependencies := make(map[string]string) + var dependencies []Dependency // Handle Poetry dependencies for pkg, version := range pyProject.Tool.Poetry.Dependencies { switch v := version.(type) { case string: - dependencies[pkg] = v + dependencies = append(dependencies, Dependency{ + Name: pkg, + Constraint: v, + IsDirect: true, + ToolName: toolName, + }) case map[string]any: if str, ok := v["version"].(string); ok { - dependencies[pkg] = str + dependencies = append(dependencies, Dependency{ + Name: pkg, + Constraint: str, + IsDirect: true, + ToolName: toolName, + }) } default: return nil, fmt.Errorf("unrecognized poetry version type %T: %v", version, version) @@ -223,8 +322,117 @@ func parsePyprojectTOML(r io.Reader) (map[string]string, error) { if len(matches) > 2 { versionConstraint = matches[2] } - if packageName != "" { - dependencies[packageName] = versionConstraint + dependencies = append(dependencies, Dependency{ + Name: packageName, + Constraint: versionConstraint, + IsDirect: true, + ToolName: toolName, + }) + } + } + + // Handle PEP 735 dependency groups (uv standard) + if devGroup, ok := pyProject.DependencyGroups["dev"]; ok { + for _, dep := range devGroup { + dep = strings.TrimSpace(dep) + if dep == "" { + continue + } + matches := pipPattern.FindStringSubmatch(dep) + if len(matches) > 1 { + packageName := matches[1] + versionConstraint := "" + if len(matches) > 2 { + versionConstraint = matches[2] + } + dependencies = append(dependencies, Dependency{ + Name: packageName, + Constraint: versionConstraint, + IsDirect: true, + IsDevOnly: true, + ToolName: toolName, + }) + } + } + } + + // Handle Poetry legacy dev dependencies + for pkg, version := range pyProject.Tool.Poetry.DevDependencies { + switch v := version.(type) { + case string: + dependencies = append(dependencies, Dependency{ + Name: pkg, + Constraint: v, + IsDirect: true, + IsDevOnly: true, + ToolName: toolName, + }) + case map[string]any: + if str, ok := v["version"].(string); ok { + dependencies = append(dependencies, Dependency{ + Name: pkg, + Constraint: str, + IsDirect: true, + IsDevOnly: true, + ToolName: toolName, + }) + } + default: + return nil, fmt.Errorf("unrecognized poetry dev version type %T: %v", version, version) + } + } + + // Handle Poetry groups (modern Poetry) + if devGroup, ok := pyProject.Tool.Poetry.Group["dev"]; ok { + if deps, ok := devGroup["dependencies"]; ok { + for pkg, version := range deps { + switch v := version.(type) { + case string: + dependencies = append(dependencies, Dependency{ + Name: pkg, + Constraint: v, + IsDirect: true, + IsDevOnly: true, + ToolName: toolName, + }) + case map[string]any: + if str, ok := v["version"].(string); ok { + dependencies = append(dependencies, Dependency{ + Name: pkg, + Constraint: str, + IsDirect: true, + IsDevOnly: true, + ToolName: toolName, + }) + } + default: + return nil, fmt.Errorf("unrecognized poetry group dev version type %T: %v", version, version) + } + } + } + } + + // Handle PEP 621 optional dependencies (treat "dev" group as dev dependencies) + if devGroup, ok := pyProject.Project.Optional["dev"]; ok { + for _, dep := range devGroup { + dep = strings.TrimSpace(dep) + if dep == "" { + continue + } + matches := pipPattern.FindStringSubmatch(dep) + if len(matches) > 1 { + packageName := matches[1] + versionConstraint := "" + if len(matches) > 2 { + versionConstraint = matches[2] + } + dependencies = append(dependencies, Dependency{ + Name: packageName, + Constraint: versionConstraint, + IsDirect: true, + IsDevOnly: true, + ToolName: toolName, + }) } } } @@ -233,7 +441,7 @@ func parsePyprojectTOML(r io.Reader) (map[string]string, error) { } // parseUvLock parses a uv.lock TOML file and extracts dependencies -func parseUvLock(r io.Reader) (map[string]string, error) { +func parseUvLock(r io.Reader, toolName string) ([]Dependency, error) { type UvPackage struct { Name string `toml:"name"` Version string `toml:"version"` @@ -247,9 +455,41 @@ func parseUvLock(r io.Reader) (map[string]string, error) { if err != nil { return nil, err } - dependencies := make(map[string]string) + var dependencies []Dependency + for _, pkg := range lock.Packages { + dependencies = append(dependencies, Dependency{ + Name: pkg.Name, + Version: pkg.Version, + IsDirect: false, // Lock files contain both direct and indirect deps + ToolName: toolName, + }) + } + return dependencies, nil +} + +// parsePoetryLock parses a poetry.lock TOML file and extracts dependencies +func parsePoetryLock(r io.Reader, toolName string) ([]Dependency, error) { + type PoetryPackage struct { + Name string `toml:"name"` + Version string `toml:"version"` + } + type PoetryLock struct { + Packages []PoetryPackage `toml:"package"` + } + + var lock PoetryLock + _, err := toml.NewDecoder(r).Decode(&lock) + if err != nil { + return nil, err + } + var dependencies []Dependency for _, pkg := range lock.Packages { - dependencies[pkg.Name] = pkg.Version + dependencies = append(dependencies, Dependency{ + Name: pkg.Name, + Version: pkg.Version, + IsDirect: false, // Lock files contain both direct and indirect deps + ToolName: toolName, + }) } return dependencies, nil } diff --git a/pkg/dep/python_pipfile_test.go b/pkg/dep/python_pipfile_test.go index f622622..067550c 100644 --- a/pkg/dep/python_pipfile_test.go +++ b/pkg/dep/python_pipfile_test.go @@ -26,8 +26,8 @@ numpy = "==1.21.0"`)}, pattern string dependencies []dep.Dependency }{ - {"requests", []dep.Dependency{{Name: "requests", Constraint: ">=2.25.1"}}}, - {"numpy", []dep.Dependency{{Name: "numpy", Constraint: "==1.21.0"}}}, + {"requests", []dep.Dependency{{Name: "requests", Constraint: ">=2.25.1", IsDirect: true, ToolName: "pipenv"}}}, + {"numpy", []dep.Dependency{{Name: "numpy", Constraint: "==1.21.0", IsDirect: true, ToolName: "pipenv"}}}, } for _, c := range cases { assert.Equal(t, c.dependencies, m.Find(c.pattern)) diff --git a/pkg/dep/python_poetry_test.go b/pkg/dep/python_poetry_test.go new file mode 100644 index 0000000..aa59c37 --- /dev/null +++ b/pkg/dep/python_poetry_test.go @@ -0,0 +1,109 @@ +package dep + +import ( + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" +) + +func TestParsePoetryLock(t *testing.T) { + testFS := fstest.MapFS{ + "pyproject.toml": {Data: []byte(` +[tool.poetry] +name = "test-project" +version = "0.1.0" + +[tool.poetry.dependencies] +python = "^3.9" +requests = "^2.28.0" +click = "^8.1.0" + +[tool.poetry.group.dev.dependencies] +pytest = "^7.0.0" +black = "^22.0.0" +`)}, + "poetry.lock": {Data: []byte(` +# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. + +[[package]] +name = "certifi" +version = "2023.7.22" + +[[package]] +name = "charset-normalizer" +version = "3.2.0" + +[[package]] +name = "click" +version = "8.1.7" + +[[package]] +name = "idna" +version = "3.4" + +[[package]] +name = "requests" +version = "2.31.0" + +[[package]] +name = "urllib3" +version = "2.0.4" + +[[package]] +name = "pytest" +version = "7.4.0" + +[[package]] +name = "black" +version = "23.7.0" +`)}, + } + + manager := newPythonManager(testFS, ".") + err := manager.Init() + assert.NoError(t, err) + + // Test finding direct dependencies + requestsDeps := manager.Find("requests") + assert.Len(t, requestsDeps, 1) + requests := requestsDeps[0] + assert.Equal(t, "requests", requests.Name) + assert.Equal(t, "^2.28.0", requests.Constraint) + assert.Equal(t, "2.31.0", requests.Version) + assert.True(t, requests.IsDirect) + assert.False(t, requests.IsDevOnly) + assert.Equal(t, "poetry", requests.ToolName) + + // Test finding dev dependencies + pytestDeps := manager.Find("pytest") + assert.Len(t, pytestDeps, 1) + pytest := pytestDeps[0] + assert.Equal(t, "pytest", pytest.Name) + assert.Equal(t, "^7.0.0", pytest.Constraint) + assert.Equal(t, "7.4.0", pytest.Version) + assert.True(t, pytest.IsDirect) + assert.True(t, pytest.IsDevOnly) + assert.Equal(t, "poetry", pytest.ToolName) + + // Test finding indirect dependencies (only in lock file) + certifiDeps := manager.Find("certifi") + assert.Len(t, certifiDeps, 1) + certifi := certifiDeps[0] + assert.Equal(t, "certifi", certifi.Name) + assert.Equal(t, "", certifi.Constraint) + assert.Equal(t, "2023.7.22", certifi.Version) + assert.False(t, certifi.IsDirect) + assert.False(t, certifi.IsDevOnly) + assert.Equal(t, "poetry", certifi.ToolName) + + // Test Get method + click, found := manager.Get("click") + assert.True(t, found) + assert.Equal(t, "click", click.Name) + assert.Equal(t, "^8.1.0", click.Constraint) + assert.Equal(t, "8.1.7", click.Version) + assert.True(t, click.IsDirect) + assert.False(t, click.IsDevOnly) + assert.Equal(t, "poetry", click.ToolName) +} diff --git a/pkg/dep/python_pyproject_test.go b/pkg/dep/python_pyproject_test.go index 1d0fdb3..6a5daf5 100644 --- a/pkg/dep/python_pyproject_test.go +++ b/pkg/dep/python_pyproject_test.go @@ -30,10 +30,10 @@ func TestParsePyprojectTOML(t *testing.T) { pattern string dependencies []dep.Dependency }{ - {"requests", []dep.Dependency{{Name: "requests", Constraint: ">=2.25.1"}}}, - {"numpy", []dep.Dependency{{Name: "numpy", Constraint: "==1.21.0"}}}, - {"tensorflow", []dep.Dependency{{Name: "tensorflow", Constraint: "^2.6.0"}}}, - {"pydantic", []dep.Dependency{{Name: "pydantic", Constraint: "^2.10.5"}}}, + {"requests", []dep.Dependency{{Name: "requests", Constraint: ">=2.25.1", IsDirect: true, ToolName: "poetry"}}}, + {"numpy", []dep.Dependency{{Name: "numpy", Constraint: "==1.21.0", IsDirect: true, ToolName: "poetry"}}}, + {"tensorflow", []dep.Dependency{{Name: "tensorflow", Constraint: "^2.6.0", IsDirect: true, ToolName: "poetry"}}}, + {"pydantic", []dep.Dependency{{Name: "pydantic", Constraint: "^2.10.5", IsDirect: true, ToolName: "poetry"}}}, } for _, c := range cases { assert.Equal(t, c.dependencies, m.Find(c.pattern)) diff --git a/pkg/dep/python_requirements_test.go b/pkg/dep/python_requirements_test.go index 2558fe2..14d9563 100644 --- a/pkg/dep/python_requirements_test.go +++ b/pkg/dep/python_requirements_test.go @@ -28,9 +28,9 @@ pandas!=1.3.0`)}, pattern string dependencies []dep.Dependency }{ - {"requests", []dep.Dependency{{Name: "requests", Constraint: ">=2.25.1"}}}, - {"numpy", []dep.Dependency{{Name: "numpy", Constraint: "==1.21.0"}}}, - {"p*ndas", []dep.Dependency{{Name: "pandas", Constraint: "!=1.3.0"}}}, + {"requests", []dep.Dependency{{Name: "requests", Constraint: ">=2.25.1", IsDirect: true, ToolName: "pip"}}}, + {"numpy", []dep.Dependency{{Name: "numpy", Constraint: "==1.21.0", IsDirect: true, ToolName: "pip"}}}, + {"p*ndas", []dep.Dependency{{Name: "pandas", Constraint: "!=1.3.0", IsDirect: true, ToolName: "pip"}}}, {"flask", nil}, } for _, c := range toFind { @@ -42,8 +42,8 @@ pandas!=1.3.0`)}, dependency dep.Dependency found bool }{ - {"requests", dep.Dependency{Name: "requests", Constraint: ">=2.25.1"}, true}, - {"numpy", dep.Dependency{Name: "numpy", Constraint: "==1.21.0"}, true}, + {"requests", dep.Dependency{Name: "requests", Constraint: ">=2.25.1", IsDirect: true, ToolName: "pip"}, true}, + {"numpy", dep.Dependency{Name: "numpy", Constraint: "==1.21.0", IsDirect: true, ToolName: "pip"}, true}, {"flask", dep.Dependency{}, false}, } for _, c := range toGet { diff --git a/pkg/dep/python_uv_test.go b/pkg/dep/python_uv_test.go index ba4b368..8241828 100644 --- a/pkg/dep/python_uv_test.go +++ b/pkg/dep/python_uv_test.go @@ -32,14 +32,20 @@ func TestParsePythonUv(t *testing.T) { pattern string dependencies []dep.Dependency }{ - {"pandas", []dep.Dependency{{Name: "pandas", Constraint: ">=2.2.0", Version: "2.3.0"}}}, - {"numpy", []dep.Dependency{{Name: "numpy", Constraint: "==1.26.0", Version: "1.26.0"}}}, + {"pandas", []dep.Dependency{{ + Name: "pandas", Constraint: ">=2.2.0", Version: "2.3.0", IsDirect: true, ToolName: "uv", + }}}, + {"numpy", []dep.Dependency{{ + Name: "numpy", Constraint: "==1.26.0", Version: "1.26.0", IsDirect: true, ToolName: "uv", + }}}, {"python-dateutil", []dep.Dependency{{ Name: "python-dateutil", Constraint: ">=2.8.0,<3.0.0", Version: "2.9.0.post0", + IsDirect: true, + ToolName: "uv", }}}, - {"six", []dep.Dependency{{Name: "six", Constraint: ">=1.15.0", Version: "1.17.0"}}}, + {"six", []dep.Dependency{{Name: "six", Constraint: ">=1.15.0", Version: "1.17.0", IsDirect: true, ToolName: "uv"}}}, } for _, c := range cases { assert.Equal(t, c.dependencies, mgr.Find(c.pattern), c.pattern) @@ -50,14 +56,20 @@ func TestParsePythonUv(t *testing.T) { dependency dep.Dependency found bool }{ - {"pandas", dep.Dependency{Name: "pandas", Constraint: ">=2.2.0", Version: "2.3.0"}, true}, - {"numpy", dep.Dependency{Name: "numpy", Constraint: "==1.26.0", Version: "1.26.0"}, true}, + {"pandas", dep.Dependency{ + Name: "pandas", Constraint: ">=2.2.0", Version: "2.3.0", IsDirect: true, ToolName: "uv", + }, true}, + {"numpy", dep.Dependency{ + Name: "numpy", Constraint: "==1.26.0", Version: "1.26.0", IsDirect: true, ToolName: "uv", + }, true}, {"python-dateutil", dep.Dependency{ Name: "python-dateutil", Constraint: ">=2.8.0,<3.0.0", Version: "2.9.0.post0", + IsDirect: true, + ToolName: "uv", }, true}, - {"six", dep.Dependency{Name: "six", Constraint: ">=1.15.0", Version: "1.17.0"}, true}, + {"six", dep.Dependency{Name: "six", Constraint: ">=1.15.0", Version: "1.17.0", IsDirect: true, ToolName: "uv"}, true}, {"notfound", dep.Dependency{}, false}, } for _, c := range toGet { diff --git a/pkg/dep/ruby.go b/pkg/dep/ruby.go index fe8ead8..29661a3 100644 --- a/pkg/dep/ruby.go +++ b/pkg/dep/ruby.go @@ -61,25 +61,44 @@ func (m *rubyManager) parse() error { } func (m *rubyManager) Get(name string) (Dependency, bool) { - req, ok := m.required[name] - if !ok { + constraint, hasConstraint := m.required[name] + version, hasVersion := m.resolved[name] + if !hasConstraint && !hasVersion { return Dependency{}, false } return Dependency{ Name: name, - Constraint: req, - Version: m.resolved[name], + Constraint: constraint, + Version: version, + IsDirect: hasConstraint, // Direct if it has a constraint from Gemfile + ToolName: "bundler", }, true } func (m *rubyManager) Find(pattern string) []Dependency { + seen := make(map[string]struct{}) var deps []Dependency + // First, add all with constraints (direct dependencies) for name, constraint := range m.required { if wildcard.Match(pattern, name) { deps = append(deps, Dependency{ Name: name, Constraint: constraint, Version: m.resolved[name], + IsDirect: true, + ToolName: "bundler", + }) + seen[name] = struct{}{} + } + } + // Then, add any resolved-only deps not already included (indirect dependencies) + for name, version := range m.resolved { + if _, already := seen[name]; !already && wildcard.Match(pattern, name) { + deps = append(deps, Dependency{ + Name: name, + Version: version, + IsDirect: false, + ToolName: "bundler", }) } } diff --git a/pkg/dep/ruby_test.go b/pkg/dep/ruby_test.go index 6b77fd0..c985715 100644 --- a/pkg/dep/ruby_test.go +++ b/pkg/dep/ruby_test.go @@ -38,6 +38,8 @@ gem 'nokogiri', '>= 1.10', '< 2.0'`), Name: "rails", Version: "6.1.4.1", Constraint: "~> 6.1", + IsDirect: true, + ToolName: "bundler", }}}, } for _, c := range toFind { @@ -55,6 +57,8 @@ gem 'nokogiri', '>= 1.10', '< 2.0'`), Name: "rails", Version: "6.1.4.1", Constraint: "~> 6.1", + IsDirect: true, + ToolName: "bundler", }, found: true}, } for _, c := range toGet { diff --git a/pkg/dep/rust.go b/pkg/dep/rust.go index f596a37..2e34cbf 100644 --- a/pkg/dep/rust.go +++ b/pkg/dep/rust.go @@ -43,14 +43,30 @@ func (m *rustManager) parse() error { } } defer manifestFile.Close() - constraints, err := parseCargoTOML(manifestFile) + regularDeps, devDeps, err := parseCargoTOML(manifestFile) if err != nil { return err } m.deps = make(map[string]Dependency) - for name, constraint := range constraints { - m.deps[name] = Dependency{Name: name, Constraint: constraint} + // Direct regular dependencies from Cargo.toml + for name, constraint := range regularDeps { + m.deps[name] = Dependency{ + Name: name, + Constraint: constraint, + IsDirect: true, + ToolName: "cargo", + } + } + // Direct dev dependencies from Cargo.toml + for name, constraint := range devDeps { + m.deps[name] = Dependency{ + Name: name, + Constraint: constraint, + IsDirect: true, + IsDevOnly: true, + ToolName: "cargo", + } } lockFile, err := m.fsys.Open(filepath.Join(m.path, "Cargo.lock")) @@ -67,10 +83,17 @@ func (m *rustManager) parse() error { } for name, version := range versions { if d, ok := m.deps[name]; ok { + // Update existing dependency (preserve IsDirect status) d.Version = version m.deps[name] = d } else { - m.deps[name] = Dependency{Name: name, Version: version} + // New dependency only in lock file (indirect) + m.deps[name] = Dependency{ + Name: name, + Version: version, + IsDirect: false, + ToolName: "cargo", + } } } @@ -93,7 +116,8 @@ func (m *rustManager) Get(name string) (Dependency, bool) { } type cargoTOML struct { - Dependencies map[string]toml.Primitive `toml:"dependencies"` + Dependencies map[string]toml.Primitive `toml:"dependencies"` + DevDependencies map[string]toml.Primitive `toml:"dev-dependencies"` } type cargoLock struct { @@ -104,28 +128,45 @@ type cargoLock struct { } // parseCargoTOML parses the Cargo.toml manifest (dependency names with version constraints). -func parseCargoTOML(r io.Reader) (map[string]string, error) { +func parseCargoTOML(r io.Reader) (regular map[string]string, dev map[string]string, err error) { var ct cargoTOML md, err := toml.NewDecoder(r).Decode(&ct) if err != nil { - return nil, err + return nil, nil, err } - dependencies := make(map[string]string) + regular = make(map[string]string) + dev = make(map[string]string) + + // Parse regular dependencies for name, spec := range ct.Dependencies { var asStr string if err := md.PrimitiveDecode(spec, &asStr); err == nil { - dependencies[name] = asStr + regular[name] = asStr continue } var asMap map[string]string if err := md.PrimitiveDecode(spec, &asMap); err == nil { b, _ := json.Marshal(asMap) - dependencies[name] = string(b) + regular[name] = string(b) } } - return dependencies, nil + // Parse dev dependencies + for name, spec := range ct.DevDependencies { + var asStr string + if err := md.PrimitiveDecode(spec, &asStr); err == nil { + dev[name] = asStr + continue + } + var asMap map[string]string + if err := md.PrimitiveDecode(spec, &asMap); err == nil { + b, _ := json.Marshal(asMap) + dev[name] = string(b) + } + } + + return regular, dev, nil } // parseCargoLock parses the Cargo.lock file (dependency names with resolved versions). diff --git a/pkg/dep/rust_test.go b/pkg/dep/rust_test.go index ce886d7..5436187 100644 --- a/pkg/dep/rust_test.go +++ b/pkg/dep/rust_test.go @@ -38,15 +38,19 @@ func TestParseCargoTOMLAndLock(t *testing.T) { Name: "rocket", Constraint: "0.5.1", Version: "0.5.1", + IsDirect: true, + ToolName: "cargo", }}}, {"serde", []dep.Dependency{{ - Name: "serde", - Version: "1.0.217", + Name: "serde", + Version: "1.0.217", + IsDirect: false, + ToolName: "cargo", }}}, {"rand*", []dep.Dependency{ - {Name: "rand", Version: "0.9.0", Constraint: "0.9.0"}, - {Name: "rand_chacha", Version: "0.9.0"}, - {Name: "rand_core", Version: "0.9.0"}, + {Name: "rand", Version: "0.9.0", Constraint: "0.9.0", IsDirect: true, ToolName: "cargo"}, + {Name: "rand_chacha", Version: "0.9.0", IsDirect: false, ToolName: "cargo"}, + {Name: "rand_core", Version: "0.9.0", IsDirect: false, ToolName: "cargo"}, }}, } for _, c := range cases {