diff --git a/CLAUDE.md b/CLAUDE.md index b1f0e63f..eb0cdf72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -`yul` is a Claude Code `PreToolUse` hook (Go binary) that keeps dependencies current. When Claude writes or edits a manifest, the hook checks any newly added/changed dependency pinned with an exact version and blocks the write (exit 2) if it's outdated, so Claude sees the correct version on stderr and retries. Other files and untouched dependencies pass through untouched; resolver/network errors fail open (exit 0). +`yul` is a Claude Code `PreToolUse` hook (Go binary) that keeps dependencies current. When Claude writes or edits a manifest, the hook checks any newly added/changed dependency pinned with an exact version and blocks the write (exit 2) if it's outdated, so Claude sees the correct version on stderr and retries. It also blocks a dependency whose registry says it doesn't exist (a 404/410 that the ecosyste.ms fallback doesn't contradict), since that's likely a hallucinated name — except a go.mod module with a `replace` directive, which is expected to be missing upstream. Other files and untouched dependencies pass through untouched; resolver/network errors fail open (exit 0). Supported manifests: `pom.xml` (Maven Central), `requirements.txt` and `pyproject.toml` (PyPI, `==` pins only), `package.json` (npm, exact pins across all four dependency fields), `.github/workflows/*.yml`/`*.yaml` (GitHub Actions, `uses:` steps pinned to a version-like tag — outdated ones are blocked, and so are current ones whose SHA can be resolved, since a tag is mutable; the suggestion is ` # `. Branch names and commit SHAs are left alone), `go.mod` (Go modules, `require` entries — every entry is inherently an exact pin, since go.mod has no range syntax), `Cargo.toml` (crates.io, `=` pins only — a bare version like `"1.2.3"` is Cargo's implicit caret range, not an exact pin). diff --git a/main.go b/main.go index 47dc2852..af3f65f5 100644 --- a/main.go +++ b/main.go @@ -160,9 +160,10 @@ func parsePkgManagerPin(cmd string) (scheme, name, version string, ok bool) { } // checkPkgManagerPin resolves name's latest released version under scheme -// and, if pinnedVersion is older, blocks (exit 2) with the same "outdated -// dependencies" message the Write/Edit path prints - so Claude retries with -// the correct version instead of pinning it via bash and never finding out. +// and, if pinnedVersion is older or name doesn't exist on its registry, +// blocks (exit 2) with the same kind of message the Write/Edit path prints +// - so Claude retries with the correct version instead of pinning it via +// bash and never finding out. // It exits 0 (fails open) if the purl can't be built or the resolver can't // find a latest version, same as the Write/Edit path's own resolver errors. func checkPkgManagerPin(scheme, name, pinnedVersion string) { @@ -186,6 +187,12 @@ func checkPkgManagerPin(scheme, name, pinnedVersion string) { return // fail open on a resolver error; nothing to flag if it's already latest } + if m := mismatches[0]; m.NotFound { + fmt.Fprintln(os.Stderr, "dependency pinned via package manager CLI doesn't exist on its registry, so it's likely hallucinated; use a real package instead:") + fmt.Fprintf(os.Stderr, " %s %s\n", m.Name, m.Current) + os.Exit(2) + } + fmt.Fprintln(os.Stderr, "outdated dependency pinned via package manager CLI, use this version instead:") for _, m := range mismatches { fmt.Fprintf(os.Stderr, " %s %s -> %s\n", m.Name, m.Current, m.Latest) @@ -284,15 +291,28 @@ func runHook() { os.Exit(0) } - var outdated, ranges []mismatch.Mismatch + var notFound, outdated, ranges []mismatch.Mismatch for _, m := range blocking { - if m.Range { + switch { + case m.NotFound: + notFound = append(notFound, m) + case m.Range: ranges = append(ranges, m) - } else { + default: outdated = append(outdated, m) } } + if len(notFound) > 0 { + fmt.Fprintln(os.Stderr, "these dependencies don't exist on their registry, so they're likely hallucinated; use a real package instead:") + for _, m := range notFound { + name := m.Name + if m.Namespace != "" { + name = m.Namespace + ":" + m.Name + } + fmt.Fprintf(os.Stderr, " %s %s\n", name, m.Current) + } + } if len(outdated) > 0 { fmt.Fprintln(os.Stderr, "outdated dependencies, use these versions instead:") for _, m := range outdated { @@ -432,11 +452,14 @@ func emitScanContext(findings []scan.Finding, scannedAt time.Time) { os.Exit(0) } - var outdated, ranges []scan.Finding + var notFound, outdated, ranges []scan.Finding for _, f := range findings { - if f.Range { + switch { + case f.NotFound: + notFound = append(notFound, f) + case f.Range: ranges = append(ranges, f) - } else { + default: outdated = append(outdated, f) } } @@ -445,6 +468,16 @@ func emitScanContext(findings []scan.Finding, scannedAt time.Time) { b.WriteString("yul scanned this project's manifests") fmt.Fprintf(&b, " (as of %s)", scannedAt.Format("2006-01-02")) b.WriteString(" and found:\n") + if len(notFound) > 0 { + fmt.Fprintf(&b, "%d pinned dependencies that don't exist on their registry (possibly hallucinated, or from a private registry):\n", len(notFound)) + for _, f := range notFound { + name := f.Name + if f.Namespace != "" { + name = f.Namespace + ":" + f.Name + } + fmt.Fprintf(&b, " %s: %s %s\n", f.File, name, f.Current) + } + } if len(outdated) > 0 { fmt.Fprintf(&b, "%d pinned dependencies older than the latest release:\n", len(outdated)) for _, f := range outdated { diff --git a/pkg/golang/gomod.go b/pkg/golang/gomod.go index ecf42855..b6b9038b 100644 --- a/pkg/golang/gomod.go +++ b/pkg/golang/gomod.go @@ -52,6 +52,7 @@ package golang import ( "context" "fmt" + "slices" "strings" "github.com/git-pkgs/manifests" @@ -117,5 +118,39 @@ func CheckGoMod(before, after string, res resolver.Resolver) ([]mismatch.Mismatc if err != nil { return nil, err } - return pins.Diff(context.Background(), beforePins, afterPins, scheme, res, pins.NoRangeSupport, nil) + mismatches, err := pins.Diff(context.Background(), beforePins, afterPins, scheme, res, pins.NoRangeSupport, nil) + if err != nil { + return nil, err + } + + // A replaced module is built from its replacement, often a local + // directory, so its own path not existing upstream is expected. + replaced, err := replacedModules(after) + if err != nil { + return nil, err + } + return slices.DeleteFunc(mismatches, func(m mismatch.Mismatch) bool { + return m.NotFound && replaced[m.Name] + }), nil +} + +// replacedModules returns the required modules a replace directive points +// elsewhere: git-pkgs/manifests lists them under Dependencies, but leaves +// them out of Declarations. +func replacedModules(content string) (map[string]bool, error) { + parsed, err := manifests.Parse("go.mod", []byte(content)) + if err != nil { + return nil, fmt.Errorf("parsing go.mod: %w", err) + } + declared := make(map[string]bool, len(parsed.Declarations)) + for _, decl := range parsed.Declarations { + declared[decl.Name] = true + } + replaced := make(map[string]bool) + for _, dep := range parsed.Dependencies { + if !declared[dep.Name] { + replaced[dep.Name] = true + } + } + return replaced, nil } diff --git a/pkg/golang/gomod_test.go b/pkg/golang/gomod_test.go index d71e5cd9..1fa8584e 100644 --- a/pkg/golang/gomod_test.go +++ b/pkg/golang/gomod_test.go @@ -3,6 +3,8 @@ package golang import ( "context" "testing" + + "github.com/chains-project/yul/pkg/util/resolver" ) // fakeResolver resolves latest versions from a fixed PURL->version map, so @@ -116,6 +118,31 @@ require ( } } +func TestCheckGoModSkipsNotFoundReplacedModules(t *testing.T) { + res := &fakeResolver{latest: map[string]string{ + "pkg:golang/example.com/local": resolver.NotFound, + "pkg:golang/github.com/example/hallucinated": resolver.NotFound, + }} + + after := `module example.com/demo + +require ( + example.com/local v0.0.0 + github.com/example/hallucinated v1.0.0 +) + +replace example.com/local => ../local +` + + got, err := CheckGoMod("", after, res) + if err != nil { + t.Fatalf("CheckGoMod() error = %v", err) + } + if len(got) != 1 || got[0].Name != "github.com/example/hallucinated" || !got[0].NotFound { + t.Fatalf("CheckGoMod() = %#v, want only the hallucinated module as NotFound", got) + } +} + func TestCheckGoModAtLatestNoMismatch(t *testing.T) { res := &fakeResolver{latest: map[string]string{"pkg:golang/github.com/example/added": "v1.0.0"}} diff --git a/pkg/maven/pom_test.go b/pkg/maven/pom_test.go index 3059a969..a5a99192 100644 --- a/pkg/maven/pom_test.go +++ b/pkg/maven/pom_test.go @@ -3,6 +3,8 @@ package maven import ( "context" "testing" + + "github.com/chains-project/yul/pkg/util/resolver" ) type fakeResolver struct { @@ -233,6 +235,29 @@ func TestCheckPOMOnlyChecksChangedPinnedCoordinates(t *testing.T) { } } +func TestCheckPOMBlocksOnlyNewNotFoundSnapshots(t *testing.T) { + res := &fakeResolver{latest: map[string]string{ + "pkg:maven/org.example/existing": resolver.NotFound, + "pkg:maven/org.example/added": resolver.NotFound, + }} + + before := ` + org.exampleexisting1.0.0-SNAPSHOT + ` + after := ` + org.exampleexisting1.0.0-SNAPSHOT + org.exampleadded1.0.0-SNAPSHOT + ` + + got, err := CheckPOM(before, after, res) + if err != nil { + t.Fatalf("CheckPOM() error = %v", err) + } + if len(got) != 1 || got[0].Name != "added" || !got[0].NotFound { + t.Fatalf("CheckPOM() = %#v, want only the added SNAPSHOT as NotFound", got) + } +} + // TestCheckPOMResolvesPropertyVersion covers the case yul used to miss // entirely: a dependency version written as "${x.version}" with x.version // itself defined in , which is how most real-world POMs (e.g. diff --git a/pkg/util/mismatch/mismatch.go b/pkg/util/mismatch/mismatch.go index b2895df9..b784dc89 100644 --- a/pkg/util/mismatch/mismatch.go +++ b/pkg/util/mismatch/mismatch.go @@ -1,6 +1,7 @@ // Package mismatch defines the shared result type ecosystem manifest // checkers report back to the hook: a dependency that was just added or -// changed and is pinned older than what's actually released. +// changed and is pinned older than what's actually released, or doesn't +// exist on its registry at all. package mismatch type Mismatch struct { @@ -22,4 +23,8 @@ type Mismatch struct { // next to the manifest, so the range's actually-installed version // isn't pinned anywhere. NoLockfile bool + + // NotFound is true when the package itself doesn't exist on its + // registry, e.g. a hallucinated name; Latest and Range are unset. + NotFound bool } diff --git a/pkg/util/pins/pins.go b/pkg/util/pins/pins.go index 4d77d072..a08c0ebd 100644 --- a/pkg/util/pins/pins.go +++ b/pkg/util/pins/pins.go @@ -155,9 +155,10 @@ func satisfiesRange(latest, spec, scheme string) bool { } // Diff reports pins in after that are new or changed from before and need -// attention: an exact pin older than the latest release res knows about -// (compared under scheme's ordering rules); a range that excludes the -// latest release, recommending a replacement range that includes it; or a +// attention: a package res says doesn't exist (resolver.NotFound); an +// exact pin older than the latest release res knows about (compared under +// scheme's ordering rules); a range that excludes the latest release, +// recommending a replacement range that includes it; or a // range that already allows latest but has no lockfile alongside the // manifest (hasLockfile), so its actually-installed version isn't pinned // anywhere. Pins left untouched by the write are ignored even if outdated. @@ -195,6 +196,15 @@ func Diff(ctx context.Context, before, after map[string]Pin, scheme string, res if !ok { return nil, fmt.Errorf("resolving %s: no latest version found", pin.Name) } + if latestVersion == resolver.NotFound { + mismatches = append(mismatches, mismatch.Mismatch{ + Namespace: pin.Namespace, + Name: pin.Name, + Current: pin.Version, + NotFound: true, + }) + continue + } if pin.Range { m := mismatch.Mismatch{ Namespace: pin.Namespace, diff --git a/pkg/util/pins/pins_test.go b/pkg/util/pins/pins_test.go index 17359749..9407d3c5 100644 --- a/pkg/util/pins/pins_test.go +++ b/pkg/util/pins/pins_test.go @@ -3,6 +3,8 @@ package pins import ( "context" "testing" + + "github.com/chains-project/yul/pkg/util/resolver" ) func TestExactVersion(t *testing.T) { @@ -180,6 +182,23 @@ func TestDiffFailsOpenOnUnresolvedPurl(t *testing.T) { } } +func TestDiffFlagsNotFoundPackages(t *testing.T) { + res := &fakeResolver{latest: map[string]string{"pkg:npm/hallucinated": resolver.NotFound}} + + before := map[string]Pin{} + after := map[string]Pin{ + "hallucinated": {Name: "hallucinated", Version: "1.0.0", PURL: "pkg:npm/hallucinated"}, + } + + got, err := Diff(context.Background(), before, after, "npm", res, true, nil) + if err != nil { + t.Fatalf("Diff() error = %v", err) + } + if len(got) != 1 || got[0].Name != "hallucinated" || !got[0].NotFound || got[0].Latest != "" { + t.Fatalf("Diff() = %#v, want only hallucinated as NotFound with no Latest", got) + } +} + func TestDiffFlagsRangeExcludingLatest(t *testing.T) { res := &fakeResolver{latest: map[string]string{"pkg:npm/current": "2.0.0"}} format := func(_, v string) string { return "^" + v } diff --git a/pkg/util/resolver/registry.go b/pkg/util/resolver/registry.go index 9cd3f9b1..0673ed0f 100644 --- a/pkg/util/resolver/registry.go +++ b/pkg/util/resolver/registry.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "encoding/xml" + "errors" "fmt" "maps" "net/http" @@ -47,7 +48,9 @@ var registries = map[string]registry{ // registry directly instead of a mirror such as ecosyste.ms, which syncs on // its own schedule and can lag upstream by days. Purl types it has no // registry for, and lookups that fail (registry down, rate limited, timed -// out, or unknown package), are passed to Fallback. +// out, or unknown package), are passed to Fallback. A package the registry +// says doesn't exist, and Fallback doesn't know either, is reported as +// NotFound. type RegistryResolver struct { Fallback Resolver @@ -70,6 +73,7 @@ func (r *RegistryResolver) LatestVersions(ctx context.Context, purls []string) ( sem = make(chan struct{}, maxConcurrent) latest = make(map[string]string, len(purls)) fallback []string + notFound []string ) for _, p := range purls { parsed, err := purl.Parse(p) @@ -89,6 +93,9 @@ func (r *RegistryResolver) LatestVersions(ctx context.Context, purls []string) ( defer mu.Unlock() if err != nil || v == "" { fallback = append(fallback, p) + if errors.Is(err, errNotFound) { + notFound = append(notFound, p) + } return } latest[p] = v @@ -103,6 +110,11 @@ func (r *RegistryResolver) LatestVersions(ctx context.Context, purls []string) ( } maps.Copy(latest, got) } + for _, p := range notFound { + if _, ok := latest[p]; !ok { + latest[p] = NotFound + } + } return latest, nil } @@ -122,6 +134,11 @@ func fullName(p string, parsed *purl.PURL) string { return name } +// errNotFound is returned when a registry responds that a package doesn't +// exist: a 404, or a 410 from the Go module proxy, whose protocol treats +// both the same. +var errNotFound = errors.New("not found") + // get fetches reqURL and returns the response if it's a 200, closing it // otherwise. func get(ctx context.Context, c *http.Client, reqURL, accept string) (*http.Response, error) { @@ -137,6 +154,9 @@ func get(ctx context.Context, c *http.Client, reqURL, accept string) (*http.Resp } if resp.StatusCode != http.StatusOK { resp.Body.Close() + if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone { + return nil, fmt.Errorf("fetching %s: %w", reqURL, errNotFound) + } return nil, fmt.Errorf("fetching %s: unexpected status %d", reqURL, resp.StatusCode) } return resp, nil diff --git a/pkg/util/resolver/registry_test.go b/pkg/util/resolver/registry_test.go index aa66a37e..dccc9427 100644 --- a/pkg/util/resolver/registry_test.go +++ b/pkg/util/resolver/registry_test.go @@ -2,7 +2,10 @@ package resolver import ( "context" + "io" + "net/http" "slices" + "strings" "testing" "github.com/git-pkgs/vers" @@ -72,3 +75,76 @@ func TestRegistryResolverLatestVersions(t *testing.T) { t.Errorf("fallback got %v, want %v", fallback.got, want) } } + +// statusTransport answers every request with the status its URL's path +// maps to, or 200 with body if the path isn't listed. +type statusTransport struct { + status map[string]int + body string +} + +func (s statusTransport) RoundTrip(req *http.Request) (*http.Response, error) { + code, ok := s.status[req.URL.Path] + if !ok { + code = http.StatusOK + } + return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(s.body)), Request: req}, nil +} + +// knownFallback resolves only the purls in latest, like ecosyste.ms +// knowing some packages the registry doesn't. +type knownFallback map[string]string + +func (f knownFallback) LatestVersions(_ context.Context, purls []string) (map[string]string, error) { + latest := make(map[string]string) + for _, p := range purls { + if v, ok := f[p]; ok { + latest[p] = v + } + } + return latest, nil +} + +// TestRegistryResolverReportsNotFound checks that only a registry's own +// "doesn't exist" answer, unconfirmed by the fallback, is reported as +// NotFound, and not a lookup that just failed. +func TestRegistryResolverReportsNotFound(t *testing.T) { + r := &RegistryResolver{ + Fallback: knownFallback{"pkg:pypi/known-to-fallback": "1.0.0"}, + Client: &http.Client{Transport: statusTransport{ + body: `{"info": {"version": "2.0.0"}}`, + status: map[string]int{ + "/pypi/hallucinated/json": http.StatusNotFound, + "/pypi/known-to-fallback/json": http.StatusNotFound, + "/pypi/server-error/json": http.StatusInternalServerError, + "/example.com/hallucinated/@latest": http.StatusGone, + }, + }}, + } + + got, err := r.LatestVersions(context.Background(), []string{ + "pkg:pypi/hallucinated", + "pkg:pypi/known-to-fallback", + "pkg:pypi/server-error", + "pkg:golang/example.com/hallucinated", + "pkg:pypi/exists", + }) + if err != nil { + t.Fatalf("LatestVersions() error = %v", err) + } + + want := map[string]string{ + "pkg:pypi/hallucinated": NotFound, + "pkg:pypi/known-to-fallback": "1.0.0", + "pkg:golang/example.com/hallucinated": NotFound, + "pkg:pypi/exists": "2.0.0", + } + for p, v := range want { + if gotV, ok := got[p]; !ok || gotV != v { + t.Errorf("%s: got %q (present %v), want %q", p, gotV, ok, v) + } + } + if _, ok := got["pkg:pypi/server-error"]; ok { + t.Errorf("pkg:pypi/server-error: got %q, want it left out", got["pkg:pypi/server-error"]) + } +} diff --git a/pkg/util/resolver/resolver.go b/pkg/util/resolver/resolver.go index c5ae5790..ff9ae0b7 100644 --- a/pkg/util/resolver/resolver.go +++ b/pkg/util/resolver/resolver.go @@ -7,7 +7,13 @@ import "context" // Resolver resolves the latest released version for packages identified by // Package URL (PURL), e.g. "pkg:npm/lodash" or "pkg:pypi/requests". A PURL -// missing from the returned map has no resolvable latest version. +// missing from the returned map has no resolvable latest version; one +// mapped to NotFound doesn't exist at all. type Resolver interface { LatestVersions(ctx context.Context, purls []string) (map[string]string, error) } + +// NotFound is the latest version reported for a package its registry says +// doesn't exist (e.g. a 404), as opposed to one that just couldn't be +// looked up, which is left out of the result instead. +const NotFound = ""