Skip to content

Commit 8e3e327

Browse files
authored
feat: jh package dependents <package-name> lists reverse dependencies (#67)
1 parent f797739 commit 8e3e327

5 files changed

Lines changed: 188 additions & 10 deletions

File tree

‎CLAUDE.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ go run . package info Plots --registries General
124124
go run . package dependency DataFrames
125125
go run . package dependency DataFrames --indirect
126126
go run . package dependency CSV --registry General
127+
128+
# Get package dependents (reverse dependencies)
129+
go run . package dependents JuMP
130+
go run . package dependents JuMP --indirect
127131
```
128132

129133
### Test registry operations
@@ -538,7 +542,8 @@ jh run setup
538542
- GraphQL fallback uses `package_search.gql` (`FilteredPackages`) for the package list and `package_search_count.gql` (`FilteredPackagesCount`) for the aggregate count as separate requests
539543
- `executeGraphQL(server, token, req)` in `packages.go` is a shared helper for GraphQL POST requests (sets Authorization, Content-Type, Accept, X-Hasura-Role headers)
540544
- `getPackageInfo` in `packages.go` implements exact name-match lookup using REST-first (`getPackageInfoREST`), GraphQL fallback (`getPackageInfoGraphQL`); `packageInfoCmd` in `main.go` resolves registries via `fetchRegistries`
541-
- `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)
545+
- `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
546+
- `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)
542547
- `jh vuln` uses two REST endpoints: vulnerabilities at `/api/v1/ui/vulnerabilities/packages/{name}?version=<ver>` and latest version at `/docs/<registry>/<package>/versions.json` (first entry is latest); no GraphQL fallback
543548
- When no `--version` is given, `fetchLatestVersion` calls the versions.json endpoint (registry defaults to `General`, overridable with `--registry`)
544549
- By default only advisories where `is_affected == true` are shown; `--all` overrides this

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ go build -o jh .
170170
- Default: Shows up to 10 direct dependencies (NAME, REGISTRY, UUID, VERSIONS)
171171
- `jh package dependency --indirect` - Include both direct and indirect dependencies
172172
- `jh package dependency --registry General` - Specify registry to use
173+
- `jh package dependents <package-name>` - List packages that depend on a package (reverse dependencies)
174+
- Default: Prints direct/indirect/total counts and lists the direct dependents (NAME, REGISTRY, UUID)
175+
- `jh package dependents --indirect` - Also list indirect dependents
176+
- `jh package dependents --registry General` - Specify registry to use
173177

174178
### Registry Management (`jh registry`)
175179

@@ -374,6 +378,10 @@ jh package info Plots --registries General
374378
jh package dependency DataFrames # Shows up to 10 direct dependencies
375379
jh package dependency DataFrames --indirect # Includes indirect dependencies
376380
jh package dependency CSV --registry General # Use specific registry
381+
382+
# List reverse dependencies (dependents)
383+
jh package dependents JuMP # Counts, plus the direct dependents
384+
jh package dependents JuMP --indirect # Also lists indirect dependents
377385
```
378386

379387
### Registry Operations

‎main.go‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1058,6 +1058,39 @@ the --registry flag.`,
10581058
},
10591059
}
10601060

1061+
var packageDependentsCmd = &cobra.Command{
1062+
Use: "dependents <package-name>",
1063+
Short: "List packages that depend on a package",
1064+
Long: `List the reverse dependencies of a Julia package: the packages that
1065+
depend on it.
1066+
1067+
Prints the number of direct and indirect dependents and lists the direct
1068+
ones. Use --indirect to list the indirect dependents as well.
1069+
1070+
The data comes from the same package documentation JSON endpoint as
1071+
jh package dependency, for the package's stable version. If a package
1072+
exists in multiple registries, it uses the first registry by default.
1073+
You can specify a different registry using the --registry flag.`,
1074+
Example: " jh package dependents JuMP\n jh package dependents --indirect DataFrames\n jh package dependents --registry General CSV",
1075+
Args: cobra.ExactArgs(1),
1076+
Run: func(cmd *cobra.Command, args []string) {
1077+
server, err := getServerFromFlagOrConfig(cmd)
1078+
if err != nil {
1079+
fmt.Printf("Failed to get server config: %v\n", err)
1080+
os.Exit(1)
1081+
}
1082+
1083+
packageName := args[0]
1084+
registryName, _ := cmd.Flags().GetString("registry")
1085+
showIndirect, _ := cmd.Flags().GetBool("indirect")
1086+
1087+
if err := getPackageDependents(server, packageName, registryName, showIndirect); err != nil {
1088+
fmt.Printf("Failed to get package dependents: %v\n", err)
1089+
os.Exit(1)
1090+
}
1091+
},
1092+
}
1093+
10611094
var searchCmd = &cobra.Command{
10621095
Use: "search",
10631096
Short: "Search code, symbols, documentation and packages",
@@ -2619,6 +2652,9 @@ func init() {
26192652
packageDependencyCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server")
26202653
packageDependencyCmd.Flags().String("registry", "", "Specify registry name (uses first registry if not specified)")
26212654
packageDependencyCmd.Flags().Bool("indirect", false, "Include indirect dependencies")
2655+
packageDependentsCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server")
2656+
packageDependentsCmd.Flags().String("registry", "", "Specify registry name (uses first registry if not specified)")
2657+
packageDependentsCmd.Flags().Bool("indirect", false, "Also list indirect dependents")
26222658
registryListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server")
26232659
registryListCmd.Flags().Bool("verbose", false, "Show detailed registry information")
26242660
projectListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server")
@@ -2681,7 +2717,7 @@ func init() {
26812717
authCmd.AddCommand(authLoginCmd, authRefreshCmd, authStatusCmd, authEnvCmd)
26822718
jobCmd.AddCommand(jobListCmd, jobStartCmd)
26832719
datasetCmd.AddCommand(datasetListCmd, datasetDownloadCmd, datasetUploadCmd, datasetStatusCmd)
2684-
packageCmd.AddCommand(packageSearchCmd, packageInfoCmd, packageDependencyCmd)
2720+
packageCmd.AddCommand(packageSearchCmd, packageInfoCmd, packageDependencyCmd, packageDependentsCmd)
26852721
registryConfigCmd.Flags().StringP("server", "s", "", "JuliaHub server")
26862722
registryConfigAddCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server")
26872723
registryConfigAddCmd.Flags().StringP("file", "f", "", "Path to JSON config file (reads from stdin if omitted)")

‎packages.go‎

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/http"
1010
"net/url"
1111
"os"
12+
"sort"
1213
"strings"
1314
"time"
1415
)
@@ -62,10 +63,21 @@ type PackageDependency struct {
6263
Slug string `json:"slug"`
6364
}
6465

66+
// PackageDependent is one entry of pkg.json's `reversedeps`. A dependent can
67+
// appear twice, once with direct=true and once with direct=false.
68+
type PackageDependent struct {
69+
Direct bool `json:"direct"`
70+
Name string `json:"name"`
71+
UUID string `json:"uuid"`
72+
Registry string `json:"registry"`
73+
Slug string `json:"slug"`
74+
}
75+
6576
type PackageDocsResponse struct {
6677
Name string `json:"name"`
6778
Version string `json:"version"`
6879
Dependencies []PackageDependency `json:"deps"`
80+
Dependents []PackageDependent `json:"reversedeps"`
6981
}
7082

7183
type Package struct {
@@ -733,25 +745,27 @@ func findPackageRegistry(server, packageName string) (string, error) {
733745
return "", fmt.Errorf("package not found: %s", packageName)
734746
}
735747

736-
func getPackageDependencies(server string, packageName string, registryName string, showIndirect bool) error {
748+
// fetchPackageDocs returns the stable version's pkg.json of packageName in
749+
// registryName, or in the registry findPackageRegistry picks when it is empty.
750+
func fetchPackageDocs(server, packageName, registryName string) (*PackageDocsResponse, string, error) {
737751
targetRegistry := registryName
738752
if targetRegistry == "" {
739753
var err error
740754
if targetRegistry, err = findPackageRegistry(server, packageName); err != nil {
741-
return err
755+
return nil, "", err
742756
}
743757
}
744758

745759
docsURL := fmt.Sprintf("https://%s/docs/%s/%s/stable/pkg.json", server, targetRegistry, packageName)
746760

747761
token, err := ensureValidToken()
748762
if err != nil {
749-
return fmt.Errorf("authentication required: %w", err)
763+
return nil, "", fmt.Errorf("authentication required: %w", err)
750764
}
751765

752766
req, err := http.NewRequest("GET", docsURL, nil)
753767
if err != nil {
754-
return fmt.Errorf("failed to create request: %w", err)
768+
return nil, "", fmt.Errorf("failed to create request: %w", err)
755769
}
756770

757771
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken))
@@ -760,23 +774,31 @@ func getPackageDependencies(server string, packageName string, registryName stri
760774
client := &http.Client{Timeout: 30 * time.Second}
761775
resp, err := client.Do(req)
762776
if err != nil {
763-
return fmt.Errorf("failed to fetch package documentation: %w", err)
777+
return nil, "", fmt.Errorf("failed to fetch package documentation: %w", err)
764778
}
765779
defer resp.Body.Close()
766780

767781
if resp.StatusCode != http.StatusOK {
768782
body, _ := io.ReadAll(resp.Body)
769-
return fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body))
783+
return nil, "", fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body))
770784
}
771785

772786
body, err := io.ReadAll(resp.Body)
773787
if err != nil {
774-
return fmt.Errorf("failed to read response: %w", err)
788+
return nil, "", fmt.Errorf("failed to read response: %w", err)
775789
}
776790

777791
var docsResp PackageDocsResponse
778792
if err := json.Unmarshal(body, &docsResp); err != nil {
779-
return fmt.Errorf("failed to parse response: %w", err)
793+
return nil, "", fmt.Errorf("failed to parse response: %w", err)
794+
}
795+
return &docsResp, targetRegistry, nil
796+
}
797+
798+
func getPackageDependencies(server string, packageName string, registryName string, showIndirect bool) error {
799+
docsResp, targetRegistry, err := fetchPackageDocs(server, packageName, registryName)
800+
if err != nil {
801+
return err
780802
}
781803

782804
var deps []PackageDependency
@@ -839,3 +861,70 @@ func getPackageDependencies(server string, packageName string, registryName stri
839861

840862
return nil
841863
}
864+
865+
// splitDependents collapses entries to one per UUID, sorted by name. A
866+
// dependent listed as direct in any entry is direct; the rest are indirect.
867+
func splitDependents(entries []PackageDependent) (direct, indirect []PackageDependent) {
868+
byUUID := make(map[string]PackageDependent)
869+
for _, e := range entries {
870+
if prev, ok := byUUID[e.UUID]; ok {
871+
e.Direct = e.Direct || prev.Direct
872+
}
873+
byUUID[e.UUID] = e
874+
}
875+
for _, e := range byUUID {
876+
if e.Direct {
877+
direct = append(direct, e)
878+
} else {
879+
indirect = append(indirect, e)
880+
}
881+
}
882+
byName := func(s []PackageDependent) {
883+
sort.Slice(s, func(i, j int) bool {
884+
if a, b := strings.ToLower(s[i].Name), strings.ToLower(s[j].Name); a != b {
885+
return a < b
886+
}
887+
return s[i].UUID < s[j].UUID
888+
})
889+
}
890+
byName(direct)
891+
byName(indirect)
892+
return direct, indirect
893+
}
894+
895+
func printDependentsTable(title string, deps []PackageDependent) {
896+
fmt.Printf("%s (%d):\n", title, len(deps))
897+
fmt.Printf("%-35s %-15s %s\n", "NAME", "REGISTRY", "UUID")
898+
fmt.Printf("%-35s %-15s %s\n", strings.Repeat("-", 35), strings.Repeat("-", 15), strings.Repeat("-", 36))
899+
for _, d := range deps {
900+
fmt.Printf("%-35s %-15s %s\n", d.Name, d.Registry, d.UUID)
901+
}
902+
}
903+
904+
func getPackageDependents(server, packageName, registryName string, showIndirect bool) error {
905+
docsResp, targetRegistry, err := fetchPackageDocs(server, packageName, registryName)
906+
if err != nil {
907+
return err
908+
}
909+
910+
direct, indirect := splitDependents(docsResp.Dependents)
911+
if len(direct)+len(indirect) == 0 {
912+
fmt.Printf("Package %s (v%s) has no dependents\n", docsResp.Name, docsResp.Version)
913+
return nil
914+
}
915+
916+
fmt.Printf("Dependents of %s (v%s) from registry '%s':\n\n", docsResp.Name, docsResp.Version, targetRegistry)
917+
fmt.Printf("%d direct, %d indirect, %d total\n\n", len(direct), len(indirect), len(direct)+len(indirect))
918+
if len(direct) > 0 {
919+
printDependentsTable("Direct dependents", direct)
920+
}
921+
if showIndirect && len(indirect) > 0 {
922+
if len(direct) > 0 {
923+
fmt.Println()
924+
}
925+
printDependentsTable("Indirect dependents", indirect)
926+
} else if len(indirect) > 0 {
927+
fmt.Printf("\nUse --indirect to list the %d indirect dependents\n", len(indirect))
928+
}
929+
return nil
930+
}

‎packages_test.go‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,43 @@ func TestFetchRESTPackagesFromHTTPError(t *testing.T) {
208208
t.Errorf("want a one-line status 401 error, got %v", err)
209209
}
210210
}
211+
212+
func TestSplitDependents(t *testing.T) {
213+
var docs PackageDocsResponse
214+
body := `{"name":"JuMP","version":"1.32.1","reversedeps":[
215+
{"direct":false,"name":"zeta","uuid":"u3","registry":"General","slug":"c","version":["1.0.0"]},
216+
{"direct":true,"name":"Beta","uuid":"u2","registry":"General","slug":"b","version":["0.1.0"]},
217+
{"direct":false,"name":"Beta","uuid":"u2","registry":"General","slug":"b","version":["0.1.0"]},
218+
{"direct":false,"name":"alpha","uuid":"u1","registry":"General","slug":"a","version":["2.0.0"]},
219+
{"direct":true,"name":"Alpha","uuid":"u0","registry":"DyadRegistry","slug":"d","version":["3.0.0"]}
220+
]}`
221+
if err := json.Unmarshal([]byte(body), &docs); err != nil {
222+
t.Fatal(err)
223+
}
224+
225+
direct, indirect := splitDependents(docs.Dependents)
226+
227+
names := func(ds []PackageDependent) []string {
228+
var out []string
229+
for _, d := range ds {
230+
out = append(out, d.Name+"/"+d.UUID)
231+
}
232+
return out
233+
}
234+
if got, want := names(direct), []string{"Alpha/u0", "Beta/u2"}; !reflect.DeepEqual(got, want) {
235+
t.Errorf("direct = %v, want %v", got, want)
236+
}
237+
if got, want := names(indirect), []string{"alpha/u1", "zeta/u3"}; !reflect.DeepEqual(got, want) {
238+
t.Errorf("indirect = %v, want %v", got, want)
239+
}
240+
if direct[0].Registry != "DyadRegistry" {
241+
t.Errorf("direct[0].Registry = %q, want DyadRegistry", direct[0].Registry)
242+
}
243+
}
244+
245+
func TestSplitDependentsEmpty(t *testing.T) {
246+
direct, indirect := splitDependents(nil)
247+
if len(direct) != 0 || len(indirect) != 0 {
248+
t.Errorf("splitDependents(nil) = %v, %v; want empty", direct, indirect)
249+
}
250+
}

0 commit comments

Comments
 (0)