From ed22a51bef1fa5462ac5417cffd093c7a87567d0 Mon Sep 17 00:00:00 2001 From: Panagiotis Georgakopoulos Date: Wed, 14 Oct 2026 18:54:24 +0300 Subject: [PATCH] feat: `jh package dependents ` lists reverse dependencies The docs pkg.json that `jh package dependency` already reads also carries `reversedeps`. `dependents` prints the direct, indirect and total counts and lists the direct dependents; `--indirect` lists the indirect ones too. `--registry` and `--server` work as for `dependency`. A dependent can appear in `reversedeps` once as direct and once as indirect, so entries are collapsed to one per UUID (direct wins), which matches the count on JuliaHub's "Dependents" badge. The pkg.json fetch moves into `fetchPackageDocs`, shared by both commands. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 7 +++- README.md | 8 ++++ main.go | 38 ++++++++++++++++- packages.go | 105 +++++++++++++++++++++++++++++++++++++++++++---- packages_test.go | 40 ++++++++++++++++++ 5 files changed, 188 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e2d19a3..503268f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,10 @@ go run . package info Plots --registries General go run . package dependency DataFrames go run . package dependency DataFrames --indirect go run . package dependency CSV --registry General + +# Get package dependents (reverse dependencies) +go run . package dependents JuMP +go run . package dependents JuMP --indirect ``` ### Test registry operations @@ -538,7 +542,8 @@ jh run setup - GraphQL fallback uses `package_search.gql` (`FilteredPackages`) for the package list and `package_search_count.gql` (`FilteredPackagesCount`) for the aggregate count as separate requests - `executeGraphQL(server, token, req)` in `packages.go` is a shared helper for GraphQL POST requests (sets Authorization, Content-Type, Accept, X-Hasura-Role headers) - `getPackageInfo` in `packages.go` implements exact name-match lookup using REST-first (`getPackageInfoREST`), GraphQL fallback (`getPackageInfoGraphQL`); `packageInfoCmd` in `main.go` resolves registries via `fetchRegistries` -- `getPackageDependencies` uses GraphQL (`fetchGraphQLPackages`) to locate the package, then fetches `/docs/{registry}/{package}/stable/pkg.json` for dependency data; no REST fallback (docs endpoint is authoritative) +- `jh package dependents` reads `reversedeps` from the same `pkg.json` (`fetchPackageDocs`). Entries carry `version` (singular), and a dependent can appear once as direct and once as indirect; `splitDependents` collapses them to one per UUID (direct wins), sorted by name +- `getPackageDependencies` locates the package's registry with `findPackageRegistry` (REST listing, GraphQL fallback), then fetches `/docs/{registry}/{package}/stable/pkg.json` for dependency data; no REST fallback (docs endpoint is authoritative) - `jh vuln` uses two REST endpoints: vulnerabilities at `/api/v1/ui/vulnerabilities/packages/{name}?version=` and latest version at `/docs///versions.json` (first entry is latest); no GraphQL fallback - When no `--version` is given, `fetchLatestVersion` calls the versions.json endpoint (registry defaults to `General`, overridable with `--registry`) - By default only advisories where `is_affected == true` are shown; `--all` overrides this diff --git a/README.md b/README.md index c98c1dd..36c873e 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,10 @@ go build -o jh . - Default: Shows up to 10 direct dependencies (NAME, REGISTRY, UUID, VERSIONS) - `jh package dependency --indirect` - Include both direct and indirect dependencies - `jh package dependency --registry General` - Specify registry to use +- `jh package dependents ` - List packages that depend on a package (reverse dependencies) + - Default: Prints direct/indirect/total counts and lists the direct dependents (NAME, REGISTRY, UUID) + - `jh package dependents --indirect` - Also list indirect dependents + - `jh package dependents --registry General` - Specify registry to use ### Registry Management (`jh registry`) @@ -374,6 +378,10 @@ jh package info Plots --registries General jh package dependency DataFrames # Shows up to 10 direct dependencies jh package dependency DataFrames --indirect # Includes indirect dependencies jh package dependency CSV --registry General # Use specific registry + +# List reverse dependencies (dependents) +jh package dependents JuMP # Counts, plus the direct dependents +jh package dependents JuMP --indirect # Also lists indirect dependents ``` ### Registry Operations diff --git a/main.go b/main.go index 7bc896f..e4b25a7 100644 --- a/main.go +++ b/main.go @@ -1058,6 +1058,39 @@ the --registry flag.`, }, } +var packageDependentsCmd = &cobra.Command{ + Use: "dependents ", + Short: "List packages that depend on a package", + Long: `List the reverse dependencies of a Julia package: the packages that +depend on it. + +Prints the number of direct and indirect dependents and lists the direct +ones. Use --indirect to list the indirect dependents as well. + +The data comes from the same package documentation JSON endpoint as +jh package dependency, for the package's stable version. If a package +exists in multiple registries, it uses the first registry by default. +You can specify a different registry using the --registry flag.`, + Example: " jh package dependents JuMP\n jh package dependents --indirect DataFrames\n jh package dependents --registry General CSV", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + + packageName := args[0] + registryName, _ := cmd.Flags().GetString("registry") + showIndirect, _ := cmd.Flags().GetBool("indirect") + + if err := getPackageDependents(server, packageName, registryName, showIndirect); err != nil { + fmt.Printf("Failed to get package dependents: %v\n", err) + os.Exit(1) + } + }, +} + var searchCmd = &cobra.Command{ Use: "search", Short: "Search code, symbols, documentation and packages", @@ -2619,6 +2652,9 @@ func init() { packageDependencyCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") packageDependencyCmd.Flags().String("registry", "", "Specify registry name (uses first registry if not specified)") packageDependencyCmd.Flags().Bool("indirect", false, "Include indirect dependencies") + packageDependentsCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + packageDependentsCmd.Flags().String("registry", "", "Specify registry name (uses first registry if not specified)") + packageDependentsCmd.Flags().Bool("indirect", false, "Also list indirect dependents") registryListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") registryListCmd.Flags().Bool("verbose", false, "Show detailed registry information") projectListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") @@ -2681,7 +2717,7 @@ func init() { authCmd.AddCommand(authLoginCmd, authRefreshCmd, authStatusCmd, authEnvCmd) jobCmd.AddCommand(jobListCmd, jobStartCmd) datasetCmd.AddCommand(datasetListCmd, datasetDownloadCmd, datasetUploadCmd, datasetStatusCmd) - packageCmd.AddCommand(packageSearchCmd, packageInfoCmd, packageDependencyCmd) + packageCmd.AddCommand(packageSearchCmd, packageInfoCmd, packageDependencyCmd, packageDependentsCmd) registryConfigCmd.Flags().StringP("server", "s", "", "JuliaHub server") registryConfigAddCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") registryConfigAddCmd.Flags().StringP("file", "f", "", "Path to JSON config file (reads from stdin if omitted)") diff --git a/packages.go b/packages.go index c1f1363..1d99798 100644 --- a/packages.go +++ b/packages.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "os" + "sort" "strings" "time" ) @@ -62,10 +63,21 @@ type PackageDependency struct { Slug string `json:"slug"` } +// PackageDependent is one entry of pkg.json's `reversedeps`. A dependent can +// appear twice, once with direct=true and once with direct=false. +type PackageDependent struct { + Direct bool `json:"direct"` + Name string `json:"name"` + UUID string `json:"uuid"` + Registry string `json:"registry"` + Slug string `json:"slug"` +} + type PackageDocsResponse struct { Name string `json:"name"` Version string `json:"version"` Dependencies []PackageDependency `json:"deps"` + Dependents []PackageDependent `json:"reversedeps"` } type Package struct { @@ -733,12 +745,14 @@ func findPackageRegistry(server, packageName string) (string, error) { return "", fmt.Errorf("package not found: %s", packageName) } -func getPackageDependencies(server string, packageName string, registryName string, showIndirect bool) error { +// fetchPackageDocs returns the stable version's pkg.json of packageName in +// registryName, or in the registry findPackageRegistry picks when it is empty. +func fetchPackageDocs(server, packageName, registryName string) (*PackageDocsResponse, string, error) { targetRegistry := registryName if targetRegistry == "" { var err error if targetRegistry, err = findPackageRegistry(server, packageName); err != nil { - return err + return nil, "", err } } @@ -746,12 +760,12 @@ func getPackageDependencies(server string, packageName string, registryName stri token, err := ensureValidToken() if err != nil { - return fmt.Errorf("authentication required: %w", err) + return nil, "", fmt.Errorf("authentication required: %w", err) } req, err := http.NewRequest("GET", docsURL, nil) if err != nil { - return fmt.Errorf("failed to create request: %w", err) + return nil, "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) @@ -760,23 +774,31 @@ func getPackageDependencies(server string, packageName string, registryName stri client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("failed to fetch package documentation: %w", err) + return nil, "", fmt.Errorf("failed to fetch package documentation: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body)) + return nil, "", fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body)) } body, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("failed to read response: %w", err) + return nil, "", fmt.Errorf("failed to read response: %w", err) } var docsResp PackageDocsResponse if err := json.Unmarshal(body, &docsResp); err != nil { - return fmt.Errorf("failed to parse response: %w", err) + return nil, "", fmt.Errorf("failed to parse response: %w", err) + } + return &docsResp, targetRegistry, nil +} + +func getPackageDependencies(server string, packageName string, registryName string, showIndirect bool) error { + docsResp, targetRegistry, err := fetchPackageDocs(server, packageName, registryName) + if err != nil { + return err } var deps []PackageDependency @@ -839,3 +861,70 @@ func getPackageDependencies(server string, packageName string, registryName stri return nil } + +// splitDependents collapses entries to one per UUID, sorted by name. A +// dependent listed as direct in any entry is direct; the rest are indirect. +func splitDependents(entries []PackageDependent) (direct, indirect []PackageDependent) { + byUUID := make(map[string]PackageDependent) + for _, e := range entries { + if prev, ok := byUUID[e.UUID]; ok { + e.Direct = e.Direct || prev.Direct + } + byUUID[e.UUID] = e + } + for _, e := range byUUID { + if e.Direct { + direct = append(direct, e) + } else { + indirect = append(indirect, e) + } + } + byName := func(s []PackageDependent) { + sort.Slice(s, func(i, j int) bool { + if a, b := strings.ToLower(s[i].Name), strings.ToLower(s[j].Name); a != b { + return a < b + } + return s[i].UUID < s[j].UUID + }) + } + byName(direct) + byName(indirect) + return direct, indirect +} + +func printDependentsTable(title string, deps []PackageDependent) { + fmt.Printf("%s (%d):\n", title, len(deps)) + fmt.Printf("%-35s %-15s %s\n", "NAME", "REGISTRY", "UUID") + fmt.Printf("%-35s %-15s %s\n", strings.Repeat("-", 35), strings.Repeat("-", 15), strings.Repeat("-", 36)) + for _, d := range deps { + fmt.Printf("%-35s %-15s %s\n", d.Name, d.Registry, d.UUID) + } +} + +func getPackageDependents(server, packageName, registryName string, showIndirect bool) error { + docsResp, targetRegistry, err := fetchPackageDocs(server, packageName, registryName) + if err != nil { + return err + } + + direct, indirect := splitDependents(docsResp.Dependents) + if len(direct)+len(indirect) == 0 { + fmt.Printf("Package %s (v%s) has no dependents\n", docsResp.Name, docsResp.Version) + return nil + } + + fmt.Printf("Dependents of %s (v%s) from registry '%s':\n\n", docsResp.Name, docsResp.Version, targetRegistry) + fmt.Printf("%d direct, %d indirect, %d total\n\n", len(direct), len(indirect), len(direct)+len(indirect)) + if len(direct) > 0 { + printDependentsTable("Direct dependents", direct) + } + if showIndirect && len(indirect) > 0 { + if len(direct) > 0 { + fmt.Println() + } + printDependentsTable("Indirect dependents", indirect) + } else if len(indirect) > 0 { + fmt.Printf("\nUse --indirect to list the %d indirect dependents\n", len(indirect)) + } + return nil +} diff --git a/packages_test.go b/packages_test.go index 1ac45aa..17f80ed 100644 --- a/packages_test.go +++ b/packages_test.go @@ -208,3 +208,43 @@ func TestFetchRESTPackagesFromHTTPError(t *testing.T) { t.Errorf("want a one-line status 401 error, got %v", err) } } + +func TestSplitDependents(t *testing.T) { + var docs PackageDocsResponse + body := `{"name":"JuMP","version":"1.32.1","reversedeps":[ + {"direct":false,"name":"zeta","uuid":"u3","registry":"General","slug":"c","version":["1.0.0"]}, + {"direct":true,"name":"Beta","uuid":"u2","registry":"General","slug":"b","version":["0.1.0"]}, + {"direct":false,"name":"Beta","uuid":"u2","registry":"General","slug":"b","version":["0.1.0"]}, + {"direct":false,"name":"alpha","uuid":"u1","registry":"General","slug":"a","version":["2.0.0"]}, + {"direct":true,"name":"Alpha","uuid":"u0","registry":"DyadRegistry","slug":"d","version":["3.0.0"]} + ]}` + if err := json.Unmarshal([]byte(body), &docs); err != nil { + t.Fatal(err) + } + + direct, indirect := splitDependents(docs.Dependents) + + names := func(ds []PackageDependent) []string { + var out []string + for _, d := range ds { + out = append(out, d.Name+"/"+d.UUID) + } + return out + } + if got, want := names(direct), []string{"Alpha/u0", "Beta/u2"}; !reflect.DeepEqual(got, want) { + t.Errorf("direct = %v, want %v", got, want) + } + if got, want := names(indirect), []string{"alpha/u1", "zeta/u3"}; !reflect.DeepEqual(got, want) { + t.Errorf("indirect = %v, want %v", got, want) + } + if direct[0].Registry != "DyadRegistry" { + t.Errorf("direct[0].Registry = %q, want DyadRegistry", direct[0].Registry) + } +} + +func TestSplitDependentsEmpty(t *testing.T) { + direct, indirect := splitDependents(nil) + if len(direct) != 0 || len(indirect) != 0 { + t.Errorf("splitDependents(nil) = %v, %v; want empty", direct, indirect) + } +}