Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<sha> # <tag>`. 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).

Expand Down
51 changes: 42 additions & 9 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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 {
Expand Down
37 changes: 36 additions & 1 deletion pkg/golang/gomod.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ package golang
import (
"context"
"fmt"
"slices"
"strings"

"github.com/git-pkgs/manifests"
Expand Down Expand Up @@ -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
}
27 changes: 27 additions & 0 deletions pkg/golang/gomod_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}}

Expand Down
25 changes: 25 additions & 0 deletions pkg/maven/pom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package maven
import (
"context"
"testing"

"github.com/chains-project/yul/pkg/util/resolver"
)

type fakeResolver struct {
Expand Down Expand Up @@ -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 := `<project><dependencies>
<dependency><groupId>org.example</groupId><artifactId>existing</artifactId><version>1.0.0-SNAPSHOT</version></dependency>
</dependencies></project>`
after := `<project><dependencies>
<dependency><groupId>org.example</groupId><artifactId>existing</artifactId><version>1.0.0-SNAPSHOT</version></dependency>
<dependency><groupId>org.example</groupId><artifactId>added</artifactId><version>1.0.0-SNAPSHOT</version></dependency>
</dependencies></project>`

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 <properties>, which is how most real-world POMs (e.g.
Expand Down
7 changes: 6 additions & 1 deletion pkg/util/mismatch/mismatch.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
}
16 changes: 13 additions & 3 deletions pkg/util/pins/pins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions pkg/util/pins/pins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package pins
import (
"context"
"testing"

"github.com/chains-project/yul/pkg/util/resolver"
)

func TestExactVersion(t *testing.T) {
Expand Down Expand Up @@ -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 }
Expand Down
22 changes: 21 additions & 1 deletion pkg/util/resolver/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"maps"
"net/http"
Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -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) {
Expand All @@ -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
Expand Down
Loading
Loading