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
75 changes: 75 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"encoding/json"
"flag"
"fmt"
Expand All @@ -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"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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<name>[\w.\-/]+)@(?P<version>v\d[\w.\-+]*)`), "golang"},
{regexp.MustCompile(`\b(?:npm|pnpm|yarn)\s+(?:install|add|i)\b` + clause + `*?(?P<name>@[\w.\-]+/[\w.\-]+|[\w.\-]+)@(?P<version>\d[\w.\-+]*)`), "npm"},
{regexp.MustCompile(`\bcargo\s+add\b` + clause + `*?(?P<name>[\w.\-]+)@(?P<version>\d[\w.\-+]*)`), "cargo"},
{regexp.MustCompile(`\b(?:pip3?|poetry|uv)\s+(?:install|add)\b` + clause + `*?(?P<name>[\w.\-]+)==(?P<version>\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)
Expand All @@ -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)
}

Expand Down
43 changes: 43 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
Loading