Skip to content
Closed
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: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<purl>@<version>` 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
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<purl>@<version>` 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,
Expand Down
29 changes: 28 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

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

Expand Down
39 changes: 39 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions pkg/githubactions/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
}
Expand Down
55 changes: 55 additions & 0 deletions pkg/ignore/ignore.go
Original file line number Diff line number Diff line change
@@ -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
// "<purl>@<version>" (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]
}
56 changes: 56 additions & 0 deletions pkg/ignore/ignore_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
6 changes: 6 additions & 0 deletions pkg/util/mismatch/mismatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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' `@<sha> # <tag>` 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, "<PURL>@<Current>" is the key a .yul-ignore
// entry (see pkg/ignore) matches to suppress this mismatch.
PURL string
}
1 change: 1 addition & 0 deletions pkg/util/pins/pins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
}
Expand Down