diff --git a/CLAUDE.md b/CLAUDE.md index 32b52ec..6e75b1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 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 — 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). +A user (or Claude, once the user has confirmed it) can opt a specific pin out of being blocked by listing it in a `.yul-ignore` file at the project root — one `@` per line (`#` comments and blank lines allowed), matched against `mismatch.Mismatch.PURL`+`Current` (`pkg/ignore`, loaded in `main.go` via `hookInput.Cwd`). `runHook` filters mismatches against it before deciding whether to block, and the block message itself prints the exact line(s) to add and tells Claude not to work around the hook instead. This exists because the hook otherwise has no way to know "the user already said no" (it sees only the manifest diff, never chat history or a permission denial), which was pushing Claude toward disabling or editing the hook when a user rejected a suggested version in manual approval mode (chains-project/yul#22). + ## Commands ```sh diff --git a/README.md b/README.md index 5443971..1c5868b 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,17 @@ outdated dependencies, use these versions instead: Claude reads this from stderr and rewrites the manifest with the correct version. +#### Keeping a pin the hook would otherwise block + +Sometimes you don't want a dependency bumped — you're intentionally staying on an older release for compatibility, or you've already told Claude "no" and don't want to relitigate it every time the file gets touched. The hook can't see chat history or permission decisions, so it has no other way to know that on its own, and blocking a write it can't get out of is exactly what pushes Claude toward disabling or editing the hook instead. `yul` prints the fix for this in the same block message: add the listed `@` line(s) to a `.yul-ignore` file at the project root (create it if it doesn't exist yet) and retry the original write unchanged. Blank lines and lines starting with `#` are ignored, so it doubles as a place to leave a note about why: + +``` +# staying on this pin for compatibility with our internal fork +pkg:npm/lodash@4.17.20 +``` + +Once a pin is listed there, the hook stops flagging that exact package/version pair — check the file in so the whole team's decision sticks. A pin isn't grandfathered in forever: if the manifest is later changed to an even older or different version, that's a new write the hook checks against `.yul-ignore` fresh. + #### Initial scan If you are already working on a project and then you install the hook, diff --git a/main.go b/main.go index d157779..94ed329 100644 --- a/main.go +++ b/main.go @@ -13,11 +13,13 @@ import ( "github.com/chains-project/yul/pkg/cargo" "github.com/chains-project/yul/pkg/githubactions" "github.com/chains-project/yul/pkg/golang" + "github.com/chains-project/yul/pkg/ignore" "github.com/chains-project/yul/pkg/maven" "github.com/chains-project/yul/pkg/npm" "github.com/chains-project/yul/pkg/pypi" "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/resolver" ) @@ -64,7 +66,10 @@ var version = "dev" // hookInput is the subset of Claude Code's PreToolUse hook payload we need. type hookInput struct { - ToolName string `json:"tool_name"` + ToolName string `json:"tool_name"` + // Cwd is the session's working directory, used to locate a project's + // .yul-ignore file (see pkg/ignore). + Cwd string `json:"cwd"` ToolInput struct { FilePath string `json:"file_path"` Content string `json:"content"` // Write @@ -74,6 +79,20 @@ type hookInput struct { } `json:"tool_input"` } +// filterIgnored drops any mismatch the project's .yul-ignore file (see +// pkg/ignore) already covers, so a pin the user has explicitly chosen to +// keep doesn't keep re-blocking the same write. +func filterIgnored(mismatches []mismatch.Mismatch, ignored ignore.Set) []mismatch.Mismatch { + var kept []mismatch.Mismatch + for _, m := range mismatches { + if ignored.Contains(m.PURL, m.Current) { + continue + } + kept = append(kept, m) + } + return kept +} + // runHook is a PreToolUse hook for the Write and Edit tools. It figures out // which ecosystem owns the manifest being written (by filename), and blocks // (exit 2) if a newly added/changed dependency is pinned older than what's @@ -132,6 +151,7 @@ func runHook() { os.Exit(0) // fail open: a resolver/network error shouldn't block the write } + mismatches = filterIgnored(mismatches, ignore.Load(in.Cwd)) if len(mismatches) == 0 { os.Exit(0) } @@ -148,6 +168,13 @@ func runHook() { } fmt.Fprintf(os.Stderr, " %s %s -> %s\n", name, m.Current, latest) } + fmt.Fprintf(os.Stderr, "\nIf the user does not want one of these updated, do not edit, disable, or otherwise work around this hook. Confirm with the user, then add the matching line(s) below to %s at the project root (create it if missing) and retry the original write unchanged:\n", ignore.Filename) + for _, m := range mismatches { + if m.PURL == "" { + continue + } + fmt.Fprintf(os.Stderr, " %s@%s\n", m.PURL, m.Current) + } os.Exit(2) } diff --git a/main_test.go b/main_test.go index a972c50..7bb3685 100644 --- a/main_test.go +++ b/main_test.go @@ -7,9 +7,11 @@ import ( "github.com/chains-project/yul/pkg/githubactions" "github.com/chains-project/yul/pkg/golang" + "github.com/chains-project/yul/pkg/ignore" "github.com/chains-project/yul/pkg/maven" "github.com/chains-project/yul/pkg/npm" "github.com/chains-project/yul/pkg/pypi" + "github.com/chains-project/yul/pkg/util/mismatch" ) type stubResolver struct{} @@ -52,6 +54,43 @@ func TestCheckerForUnknownManifest(t *testing.T) { } } +func TestFilterIgnored(t *testing.T) { + mismatches := []mismatch.Mismatch{ + {Name: "lodash", Current: "4.17.20", Latest: "4.17.21", PURL: "pkg:npm/lodash"}, + {Name: "requests", Current: "2.30.0", Latest: "2.32.0", PURL: "pkg:pypi/requests"}, + } + ignored := ignore.Set{"pkg:npm/lodash@4.17.20": true} + + got := filterIgnored(mismatches, ignored) + if len(got) != 1 || got[0].Name != "requests" { + t.Fatalf("filterIgnored() = %+v, want only the requests mismatch", got) + } +} + +func TestFilterIgnoredKeepsMismatchWhenVersionDiffers(t *testing.T) { + mismatches := []mismatch.Mismatch{ + {Name: "lodash", Current: "4.17.19", Latest: "4.17.21", PURL: "pkg:npm/lodash"}, + } + // The ignore entry covers a different pinned version than what's + // actually being written, so it shouldn't suppress this mismatch. + ignored := ignore.Set{"pkg:npm/lodash@4.17.20": true} + + got := filterIgnored(mismatches, ignored) + if len(got) != 1 { + t.Fatalf("filterIgnored() = %+v, want the mismatch kept", got) + } +} + +func TestFilterIgnoredNoIgnores(t *testing.T) { + mismatches := []mismatch.Mismatch{ + {Name: "lodash", Current: "4.17.20", Latest: "4.17.21", PURL: "pkg:npm/lodash"}, + } + got := filterIgnored(mismatches, ignore.Set{}) + if len(got) != 1 { + t.Fatalf("filterIgnored() = %+v, want the mismatch kept", got) + } +} + func TestNewCheckersWiresResolverIntoMaven(t *testing.T) { res := &stubResolver{} checker, ok := checkerFor(newCheckers(res), "pom.xml").(maven.Checker) diff --git a/pkg/githubactions/workflow.go b/pkg/githubactions/workflow.go index 5624b37..64700db 100644 --- a/pkg/githubactions/workflow.go +++ b/pkg/githubactions/workflow.go @@ -244,6 +244,7 @@ func CheckWorkflow(before, after string, res resolver.Resolver, shaRes ShaResolv Name: pin.name, Current: pin.version, Latest: latestVersion, + PURL: pin.purl, }) } } diff --git a/pkg/ignore/ignore.go b/pkg/ignore/ignore.go new file mode 100644 index 0000000..739d055 --- /dev/null +++ b/pkg/ignore/ignore.go @@ -0,0 +1,55 @@ +// Package ignore reads a project's .yul-ignore file: an explicit, +// user-maintained record of exact dependency pins the project has decided +// to keep despite being older than the latest release, so the hook stops +// re-blocking a write the user has already declined once. +package ignore + +import ( + "bufio" + "os" + "path/filepath" + "strings" +) + +// Filename is the ignore file's name, expected at a project's root +// (the hook's session cwd). +const Filename = ".yul-ignore" + +// Set is the collection of pins a project has chosen to keep, keyed by +// "@" (e.g. "pkg:npm/lodash@4.17.20"). +type Set map[string]bool + +// Load reads dir/.yul-ignore. A missing file is not an error: it just +// means nothing is ignored, so a project without one behaves exactly as +// it did before this package existed. +func Load(dir string) Set { + set := make(Set) + if dir == "" { + return set + } + + f, err := os.Open(filepath.Join(dir, Filename)) + if err != nil { + return set + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + set[line] = true + } + return set +} + +// Contains reports whether the exact pin of purl (version-less, e.g. +// "pkg:npm/lodash") at version has been marked ignored. +func (s Set) Contains(purl, version string) bool { + if purl == "" || version == "" { + return false + } + return s[purl+"@"+version] +} diff --git a/pkg/ignore/ignore_test.go b/pkg/ignore/ignore_test.go new file mode 100644 index 0000000..d4858bf --- /dev/null +++ b/pkg/ignore/ignore_test.go @@ -0,0 +1,56 @@ +package ignore + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadMissingFile(t *testing.T) { + set := Load(t.TempDir()) + if len(set) != 0 { + t.Fatalf("Load on a dir with no %s = %v, want empty", Filename, set) + } +} + +func TestLoadEmptyDir(t *testing.T) { + set := Load("") + if len(set) != 0 { + t.Fatalf("Load(\"\") = %v, want empty", set) + } +} + +func TestLoadParsesEntriesAndSkipsCommentsAndBlanks(t *testing.T) { + dir := t.TempDir() + content := "# kept for compatibility with our internal fork\n" + + "pkg:npm/lodash@4.17.20\n" + + "\n" + + " pkg:maven/com.squareup.okhttp3/okhttp@4.9.0 \n" + if err := os.WriteFile(filepath.Join(dir, Filename), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + set := Load(dir) + if !set.Contains("pkg:npm/lodash", "4.17.20") { + t.Error("expected pkg:npm/lodash@4.17.20 to be ignored") + } + if !set.Contains("pkg:maven/com.squareup.okhttp3/okhttp", "4.9.0") { + t.Error("expected the trimmed maven entry to be ignored") + } + if set.Contains("pkg:npm/lodash", "4.17.21") { + t.Error("a different version of an ignored package should not be ignored") + } + if len(set) != 2 { + t.Fatalf("len(set) = %d, want 2 (comment and blank line should not count)", len(set)) + } +} + +func TestSetContainsEmptyInputs(t *testing.T) { + set := Set{"pkg:npm/lodash@4.17.20": true} + if set.Contains("", "4.17.20") { + t.Error("Contains with empty purl should be false") + } + if set.Contains("pkg:npm/lodash", "") { + t.Error("Contains with empty version should be false") + } +} diff --git a/pkg/util/mismatch/mismatch.go b/pkg/util/mismatch/mismatch.go index aff9f14..6d62748 100644 --- a/pkg/util/mismatch/mismatch.go +++ b/pkg/util/mismatch/mismatch.go @@ -12,4 +12,10 @@ type Mismatch struct { // Suggested is a commit SHA to pin to instead of Latest, or "" if none // applies (e.g. GitHub Actions' `@ # ` pin convention). Suggested string + + // PURL is the version-less package URL identifying the dependency + // (e.g. "pkg:npm/lodash"), or "" if the checker has none to offer. + // Combined with Current, "@" is the key a .yul-ignore + // entry (see pkg/ignore) matches to suppress this mismatch. + PURL string } diff --git a/pkg/util/pins/pins.go b/pkg/util/pins/pins.go index 846f813..8e98963 100644 --- a/pkg/util/pins/pins.go +++ b/pkg/util/pins/pins.go @@ -100,6 +100,7 @@ func Diff(ctx context.Context, before, after map[string]Pin, scheme string, res Name: pin.Name, Current: pin.Version, Latest: latestVersion, + PURL: pin.PURL, }) } }