diff --git a/main.go b/main.go index 79cf0298..8a897bf2 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "flag" "fmt" @@ -11,6 +12,8 @@ import ( "strings" "time" + "github.com/git-pkgs/purl" + "github.com/chains-project/yul/pkg/cargo" "github.com/chains-project/yul/pkg/githubactions" "github.com/chains-project/yul/pkg/golang" @@ -20,6 +23,7 @@ import ( "github.com/chains-project/yul/pkg/scan" "github.com/chains-project/yul/pkg/util/manifestchecker" "github.com/chains-project/yul/pkg/util/mismatch" + "github.com/chains-project/yul/pkg/util/pins" "github.com/chains-project/yul/pkg/util/resolver" ) @@ -109,6 +113,74 @@ func looksLikeManifestWrite(cmd string) bool { return writeConstructToManifestRE.MatchString(cmd) || redirectToManifestRE.MatchString(cmd) } +// pkgManagerPinPatterns matches a package manager's own CLI syntax for +// pinning a dependency to an exact version, e.g. `go get mod@v1.2.3`, +// `npm install pkg@1.2.3`, `pip install pkg==1.2.3`, `cargo add crate@1.2.3` +// - these write the manifest just as much as a redirect does, but don't +// match looksLikeManifestWrite's direct-write patterns at all. +var pkgManagerPinPatterns = []struct { + re *regexp.Regexp + scheme string +}{ + {regexp.MustCompile(`\bgo\s+get\s+` + clause + `*?(?P[\w.\-/]+)@(?Pv\d[\w.\-+]*)`), "golang"}, + {regexp.MustCompile(`\b(?:npm|pnpm|yarn)\s+(?:install|add|i)\b` + clause + `*?(?P@[\w.\-]+/[\w.\-]+|[\w.\-]+)@(?P\d[\w.\-+]*)`), "npm"}, + {regexp.MustCompile(`\bcargo\s+add\b` + clause + `*?(?P[\w.\-]+)@(?P\d[\w.\-+]*)`), "cargo"}, + {regexp.MustCompile(`\b(?:pip3?|poetry|uv)\s+(?:install|add)\b` + clause + `*?(?P[\w.\-]+)==(?P\d[\w.\-+]*)`), "pypi"}, +} + +func parsePkgManagerPin(cmd string) (scheme, name, version string, ok bool) { + for _, p := range pkgManagerPinPatterns { + m := p.re.FindStringSubmatch(cmd) + if m == nil { + continue + } + for i, group := range p.re.SubexpNames() { + switch group { + case "name": + name = m[i] + case "version": + version = m[i] + } + } + return p.scheme, name, version, true + } + return "", "", "", false +} + +// 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. +// 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) { + res, err := resolver.NewEnrichmentResolver() + if err != nil { + return // fail open: a resolver construction error shouldn't block the command + } + + // The resolver's response keys purls without a version component (see + // pins.Diff / EnrichmentResolver.LatestVersions), same as every other + // checker's manifest-parsed PURLs - so this must match, not carry + // pinnedVersion. + purlStr := purl.BuildPURLString(scheme, name, "", "") + if purlStr == "" { + return + } + + pin := pins.Pin{Name: name, Version: pinnedVersion, PURL: purlStr} + mismatches, err := pins.Diff(context.Background(), nil, map[string]pins.Pin{name: pin}, scheme, res, pins.NoRangeSupport, nil) + if err != nil || len(mismatches) == 0 { + return // fail open on a resolver error; nothing to flag if it's already latest + } + + 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) + } + os.Exit(2) +} + // runHook is a PreToolUse hook for the Write, Edit, and Bash tools. func runHook() { raw, err := io.ReadAll(os.Stdin) @@ -128,6 +200,9 @@ func runHook() { fmt.Fprintln(os.Stderr, "yul: use the Write or Edit tool to modify dependency manifests, not bash (bash writes bypass the outdated-dependency check)") os.Exit(2) } + if scheme, name, pinnedVersion, ok := parsePkgManagerPin(in.ToolInput.Command); ok { + checkPkgManagerPin(scheme, name, pinnedVersion) + } os.Exit(0) } diff --git a/main_test.go b/main_test.go index f366f7be..5e42454c 100644 --- a/main_test.go +++ b/main_test.go @@ -121,3 +121,46 @@ git add pyproject.toml .gitignore`, false}, }) } } + +func TestParsePkgManagerPin(t *testing.T) { + tests := []struct { + testName string + cmd string + wantScheme string + wantName string + wantVer string + wantOK bool + }{ + {"go get exact version", `go get github.com/davecgh/go-spew@v1.1.0`, "golang", "github.com/davecgh/go-spew", "v1.1.0", true}, + {"npm install exact version", `npm install react@18.2.0`, "npm", "react", "18.2.0", true}, + {"npm add exact version", `npm add lodash@4.17.20`, "npm", "lodash", "4.17.20", true}, + {"npm scoped package exact version", `npm install @vue/core@3.2.1`, "npm", "@vue/core", "3.2.1", true}, + {"yarn add exact version", `yarn add lodash@4.17.20`, "npm", "lodash", "4.17.20", true}, + {"pip install exact version", `pip install requests==2.28.0`, "pypi", "requests", "2.28.0", true}, + {"pip3 install exact version", `pip3 install requests==2.28.0`, "pypi", "requests", "2.28.0", true}, + {"poetry add exact version", `poetry add requests==2.28.0`, "pypi", "requests", "2.28.0", true}, + {"uv add exact version", `uv add requests==2.28.0`, "pypi", "requests", "2.28.0", true}, + {"cargo add exact version", `cargo add serde@1.0.150`, "cargo", "serde", "1.0.150", true}, + + {"go get no version", `go get github.com/davecgh/go-spew`, "", "", "", false}, + {"go get latest", `go get github.com/davecgh/go-spew@latest`, "", "", "", false}, + {"npm install no version", `npm install react`, "", "", "", false}, + {"npm install caret range", `npm install react@^18.2.0`, "", "", "", false}, + {"npm install tilde range", `npm install react@~18.2.0`, "", "", "", false}, + {"pip install no version", `pip install requests`, "", "", "", false}, + {"pip install range", `pip install requests>=2.28.0`, "", "", "", false}, + {"cargo add no version", `cargo add serde`, "", "", "", false}, + {"unrelated at-sign in path", `cat notes@2.txt`, "", "", "", false}, + {"different clause has the pin", `go get github.com/foo/bar; echo done@v1.0.0`, "", "", "", false}, + } + + for _, test := range tests { + t.Run(test.testName, func(t *testing.T) { + scheme, name, version, ok := parsePkgManagerPin(test.cmd) + if ok != test.wantOK || scheme != test.wantScheme || name != test.wantName || version != test.wantVer { + t.Errorf("parsePkgManagerPin(%q) = (%q, %q, %q, %v), want (%q, %q, %q, %v)", + test.cmd, scheme, name, version, ok, test.wantScheme, test.wantName, test.wantVer, test.wantOK) + } + }) + } +}