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 @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

`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).

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).
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).

## Commands

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Supported manifests:
- `pyproject.toml` — PyPI, `[project.dependencies]` / `[project.optional-dependencies]`, `==` pins only
- `package.json` — npm registry, `dependencies` / `devDependencies` / `optionalDependencies` / `peerDependencies`, exact version pins only
- `.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 (single-line and block form, direct and indirect)

## Install as a Claude Code plugin (recommended)

Expand Down
48 changes: 48 additions & 0 deletions benchmark/cases.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,5 +198,53 @@
"type": "existing",
"prompt": "This CI workflow reinstalls npm dependencies from scratch on every run. Add a step that caches them using actions/cache.",
"seed": "name: CI\non: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: 20\n - run: npm install\n - run: npm test\n"
},
{
"id": "go-01-resty",
"ecosystem": "golang",
"manifest": "go.mod",
"type": "fresh",
"prompt": "Set up a new Go module (go.mod) for a CLI tool that needs to make HTTP requests to a REST API. Use go-resty/resty for the HTTP client.",
"seed": null
},
{
"id": "go-02-cobra",
"ecosystem": "golang",
"manifest": "go.mod",
"type": "fresh",
"prompt": "Set up a new Go module (go.mod) for a command-line tool with subcommands, using spf13/cobra.",
"seed": null
},
{
"id": "go-03-zap",
"ecosystem": "golang",
"manifest": "go.mod",
"type": "fresh",
"prompt": "Set up a new Go module (go.mod) for a backend service that needs structured, leveled logging. Use uber-go/zap.",
"seed": null
},
{
"id": "go-04-uuid",
"ecosystem": "golang",
"manifest": "go.mod",
"type": "fresh",
"prompt": "Set up a new Go module (go.mod) for a service that needs to generate UUIDs for request IDs. Use google/uuid.",
"seed": null
},
{
"id": "go-05-testify-existing",
"ecosystem": "golang",
"manifest": "go.mod",
"type": "existing",
"prompt": "This Go module has no test assertion library yet. Add stretchr/testify so we can write cleaner test assertions.",
"seed": "module example.com/demo\n\ngo 1.21\n\nrequire github.com/google/uuid v1.3.0\n"
},
{
"id": "go-06-pseudo-version-unreleased-fix",
"ecosystem": "golang",
"manifest": "go.mod",
"type": "fresh",
"prompt": "This project needs a fix in golang.org/x/sync that landed after its latest tagged release but hasn't shipped in a tag yet. Set up a new Go module (go.mod) that pins golang.org/x/sync to that specific unreleased commit using Go's pseudo-version format (v0.0.0-<timestamp>-<12-char commit hash>), not a tagged version.",
"seed": null
}
]
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

"github.com/chains-project/yul/pkg/githubactions"
"github.com/chains-project/yul/pkg/golang"
"github.com/chains-project/yul/pkg/maven"
"github.com/chains-project/yul/pkg/npm"
"github.com/chains-project/yul/pkg/pypi"
Expand All @@ -30,6 +31,7 @@ func newCheckers(res resolver.Resolver) []manifestchecker.ManifestChecker {
pypi.PyprojectChecker{Resolver: res},
npm.Checker{Resolver: res},
githubactions.Checker{Resolver: res, Sha: githubactions.GitHubShaResolver{}},
golang.Checker{Resolver: res},
}
}

Expand Down
6 changes: 4 additions & 2 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"

"github.com/chains-project/yul/pkg/githubactions"
"github.com/chains-project/yul/pkg/golang"
"github.com/chains-project/yul/pkg/maven"
"github.com/chains-project/yul/pkg/npm"
"github.com/chains-project/yul/pkg/pypi"
Expand All @@ -28,6 +29,7 @@ func TestCheckerFor(t *testing.T) {
{filename: "package.json", want: npm.Checker{}},
{filename: ".github/workflows/ci.yml", want: githubactions.Checker{}},
{filename: "/home/user/project/.github/workflows/release.yaml", want: githubactions.Checker{}},
{filename: "go.mod", want: golang.Checker{}},
}

checkers := newCheckers(nil)
Expand All @@ -45,8 +47,8 @@ func TestCheckerFor(t *testing.T) {
}

func TestCheckerForUnknownManifest(t *testing.T) {
if got := checkerFor(newCheckers(nil), "go.mod"); got != nil {
t.Fatalf("checkerFor(%q) returned %T, want nil", "go.mod", got)
if got := checkerFor(newCheckers(nil), "Cargo.toml"); got != nil {
t.Fatalf("checkerFor(%q) returned %T, want nil", "Cargo.toml", got)
Comment thread
algomaster99 marked this conversation as resolved.
}
}

Expand Down
118 changes: 118 additions & 0 deletions pkg/golang/gomod.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Package golang checks go.mod for required modules pinned older than
// what's actually released, using git-pkgs/manifests to parse the module
// file and an injected resolver.Resolver to look up latest releases.
//
// Design notes (see chains-project/yul#4):
//
// What counts as an "exact pin": go.mod has no concept of version range
// operators like `~` or `^` (e.g. `github.com/git-pkgs/vers v0.4.0` is
// already a bare module@version pair), so there's nothing to parse -
// pins.ExactVersion is still called, with requireOperator=false, purely to
// reject anything malformed.
//
// Pseudo-versions: a module pinned to an untagged commit uses Go's
// pseudo-version format (e.g. "v0.0.0-20210101000000-abcdef123456"), which
// git-pkgs/vers' "golang" scheme compares as a semver prerelease - always
// older than any real tagged release, per semver precedence rules.
// Thus, Claude is prompted to return the latest release tag by design.
//
// Build metadata: a "+incompatible" suffix (used by v2+ modules that
// predate module-aware tagging) is build metadata under semver precedence,
// so git-pkgs/vers ignores it when comparing - "v2.3.4+incompatible" and
// "v2.3.4" compare equal.
//
// Minimum version selection: the version in a require line is a *minimum*
Comment thread
algomaster99 marked this conversation as resolved.
// - Go's MVS may resolve the actual build to something higher because
// another module in the graph requires more, and a later `go mod tidy`
// can lower a stale entry back down to what's actually needed. This
// checker, like every other ecosystem checker here, only ever looks at
// the manifest's own literal declaration, never a resolved/effective graph.
//
// Parser coverage: git-pkgs/manifests' go.mod parser only extracts
// `require` entries (single-line and block form); it does not surface
// `replace` directives as dependencies. A stale version introduced via
// `replace module => module vX.Y.Z` therefore isn't visible to this
// checker - the same category of blind spot as e.g. Maven's <parent>
// version being a property reference.
//
// Resolution coverage: whether git-pkgs/enrichment's ecosyste.ms-backed
// resolver actually has LatestVersion data for pkg:golang purls wasn't
// verified against the live API when this was written (network access to
// packages.ecosyste.ms wasn't available in that environment, same gap
// noted in pkg/githubactions). If it doesn't, every changed module simply
// fails to resolve, which - per pins.Diff's existing "no latest version
// found" behavior - the hook already treats as fail-open (an unresolvable
// purl is a resolver error, not a mismatch), so this degrades safely to
// "never blocks" rather than blocking incorrectly. This checker never
// updates a manifest itself; it only compares the declared pin against
// whatever version the resolver reports and blocks the write when they
// disagree, so Claude retries with the correct version on stderr.
package golang

import (
"context"
"fmt"
"strings"

"github.com/git-pkgs/manifests"

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

const scheme = "golang"

// Checker implements manifestchecker.ManifestChecker for go.mod.
type Checker struct {
// Resolver resolves latest released versions. main.go wires up an
// enrichment-backed resolver; tests inject a fake one.
Resolver resolver.Resolver
}

func (Checker) Filename() string { return "go.mod" }

func (c Checker) Check(before, after string) ([]mismatch.Mismatch, error) {
return CheckGoMod(before, after, c.Resolver)
}

// parseGoModPins parses go.mod content and returns its required modules,
// keyed by module path. Every require entry - single-line or block form,
// direct or "// indirect" - is a bare module@version pair with no operator,
// so pins.ExactVersion's role here is only to reject anything malformed.
func parseGoModPins(content string) (map[string]pins.Pin, error) {
result := make(map[string]pins.Pin)
if strings.TrimSpace(content) == "" {
return result, nil
}

parsed, err := manifests.Parse("go.mod", []byte(content))
if err != nil {
return nil, fmt.Errorf("parsing go.mod: %w", err)
}

for _, dep := range parsed.Dependencies {
version, ok := pins.ExactVersion(dep.Version, scheme, false)
if !ok {
continue
}
result[dep.Name] = pins.Pin{Name: dep.Name, Version: version, PURL: dep.PURL}
}
return result, nil
}

// CheckGoMod compares go.mod content before and after a Write and reports
// any required module that is newly added or whose pinned version was just
// changed, and doesn't match the latest release res knows about. Modules
// the write didn't touch are left alone, even if outdated.
func CheckGoMod(before, after string, res resolver.Resolver) ([]mismatch.Mismatch, error) {
beforePins, err := parseGoModPins(before)
if err != nil {
return nil, err
}
afterPins, err := parseGoModPins(after)
if err != nil {
return nil, err
}
return pins.Diff(context.Background(), beforePins, afterPins, scheme, res)
}
Loading