diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdab223..373ffcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,16 +39,19 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.25.x' + go-version: '1.27.x' cache: false - run: go test -race ./... - run: go vet ./... + - name: Staticcheck pin and toolchain compatibility + run: go run ./cmd/check-staticcheck + - name: Static analysis run: | set -euo pipefail - go run honnef.co/go/tools/cmd/staticcheck@v0.7.0 ./... + go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./... - run: go build ./cmd/agent-runtime @@ -70,7 +73,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.25.x' + go-version: '1.27.x' cache: false - run: go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./... diff --git a/AGENTS.md b/AGENTS.md index 5df9550..8f62c54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,8 @@ belongs in a different repository. ``` go test -race ./... go vet ./... -go run honnef.co/go/tools/cmd/staticcheck@v0.7.0 ./... +go run ./cmd/check-staticcheck +go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./... go build ./cmd/agent-runtime go run ./cmd/check-fuzz go run ./cmd/check-cold-compile diff --git a/CHANGELOG.md b/CHANGELOG.md index d91d08e..a2d202e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ contract. ## [Unreleased] +- Raise Staticcheck to v0.8.1 and probe it against the local Go toolchain so the gate can analyze Go 1.27 export data. + ## [0.1.4] - 2026-09-15 - Pin reusable workflows to current module mains. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f90c8d..9296677 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,8 @@ Development requires Go 1.25 or newer. Before submitting a pull request, run: go test -race ./... go run ./cmd/check-fuzz go vet ./... -go run honnef.co/go/tools/cmd/staticcheck@v0.7.0 ./... +go run ./cmd/check-staticcheck +go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./... go build ./cmd/agent-runtime go run ./cmd/check-fuzz ``` diff --git a/cmd/check-staticcheck/main.go b/cmd/check-staticcheck/main.go new file mode 100644 index 0000000..2f1cc1f --- /dev/null +++ b/cmd/check-staticcheck/main.go @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package main + +import ( + "context" + "fmt" + "os" + + "github.com/NDDev-OpenNetwork/agent-runtime/internal/staticcheckcompat" +) + +func main() { + if err := staticcheckcompat.Verify(context.Background(), ".", os.Stdout, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, "staticcheck compatibility failed:", err) + os.Exit(1) + } + fmt.Printf("staticcheck %s pin and toolchain probe succeeded\n", staticcheckcompat.Version) +} diff --git a/docs/architecture.md b/docs/architecture.md index c49268d..5f8254c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,9 +64,10 @@ and is not claimed by v0. The vulnerability scanner and the linter are pinned at the point they are invoked in `.github/workflows/ci.yml`, so there is one place to read and one -place to change. CI disables automatic toolchain -downloads with `GOTOOLCHAIN=local` and records both tool versions in the job -summary. +place to change. `cmd/check-staticcheck` refuses a drifted pin or a local Go +toolchain whose export data the pinned Staticcheck cannot decode. CI disables +automatic toolchain downloads with `GOTOOLCHAIN=local` and records both tool +versions in the job summary. Closure is structurally distinct from an ordinary phase receipt. It records the achieved outcome, cleanup, typed remaining debt/risks, and canonical next work. diff --git a/internal/staticcheckcompat/compat.go b/internal/staticcheckcompat/compat.go new file mode 100644 index 0000000..3a70a84 --- /dev/null +++ b/internal/staticcheckcompat/compat.go @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Package staticcheckcompat pins Staticcheck and refuses a toolchain it cannot analyze. +package staticcheckcompat + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const Version = "v0.8.1" + +const Module = "honnef.co/go/tools/cmd/staticcheck" + +var ContractFiles = []string{ + "AGENTS.md", + "CONTRIBUTING.md", + ".github/workflows/ci.yml", +} + +type invocation struct { + path string + args []string + dir string + env []string + stdout io.Writer + stderr io.Writer +} + +type commandRunner interface { + run(context.Context, invocation) error +} + +type osCommandRunner struct{} + +func (osCommandRunner) run(ctx context.Context, call invocation) error { + command := exec.CommandContext(ctx, call.path, call.args...) + command.Dir, command.Env = call.dir, call.env + command.Stdout, command.Stderr = call.stdout, call.stderr + return command.Run() +} + +func VerifyPins(root string) error { + expected := Module + "@" + Version + for _, relative := range ContractFiles { + path := filepath.Join(root, relative) + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", relative, err) + } + text := string(data) + if !strings.Contains(text, expected) { + return fmt.Errorf("%s does not pin %s", relative, expected) + } + for _, line := range strings.Split(text, "\n") { + index := strings.Index(line, Module+"@") + if index < 0 { + continue + } + got := strings.Fields(line[index:])[0] + if got != expected { + return fmt.Errorf("%s pins %s, want %s", relative, got, expected) + } + } + } + return nil +} + +func Verify(ctx context.Context, root string, stdout, stderr io.Writer) error { + return verify(ctx, root, stdout, stderr, osCommandRunner{}) +} + +func verify(ctx context.Context, root string, stdout, stderr io.Writer, runner commandRunner) error { + if err := VerifyPins(root); err != nil { + return err + } + env := localToolchain(os.Environ()) + versionOut, versionErr := &bytes.Buffer{}, &bytes.Buffer{} + if err := runner.run(ctx, invocation{ + path: "go", + args: []string{"run", Module + "@" + Version, "-debug.version"}, + dir: root, + env: env, + stdout: io.MultiWriter(stdout, versionOut), + stderr: io.MultiWriter(stderr, versionErr), + }); err != nil { + return fmt.Errorf("staticcheck %s -debug.version: %w", Version, err) + } + if err := compatibilityError(versionOut.String() + versionErr.String()); err != nil { + return err + } + analyzeOut, analyzeErr := &bytes.Buffer{}, &bytes.Buffer{} + if err := runner.run(ctx, invocation{ + path: "go", + args: []string{"run", Module + "@" + Version, "."}, + dir: root, + env: env, + stdout: io.MultiWriter(stdout, analyzeOut), + stderr: io.MultiWriter(stderr, analyzeErr), + }); err != nil { + combined := analyzeOut.String() + analyzeErr.String() + if compat := compatibilityError(combined); compat != nil { + return compat + } + return fmt.Errorf("staticcheck %s compatibility probe: %w", Version, err) + } + return compatibilityError(analyzeOut.String() + analyzeErr.String()) +} + +func localToolchain(env []string) []string { + result := make([]string, 0, len(env)+1) + for _, entry := range env { + if strings.HasPrefix(entry, "GOTOOLCHAIN=") { + continue + } + result = append(result, entry) + } + return append(result, "GOTOOLCHAIN=local") +} + +func compatibilityError(output string) error { + if strings.Contains(output, "export data version") { + return fmt.Errorf("staticcheck %s cannot decode this Go toolchain's export data; the pinned analyzer is older than the local compiler", Version) + } + return nil +} diff --git a/internal/staticcheckcompat/compat_test.go b/internal/staticcheckcompat/compat_test.go new file mode 100644 index 0000000..e1b30ca --- /dev/null +++ b/internal/staticcheckcompat/compat_test.go @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package staticcheckcompat + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +type runnerFunc func(context.Context, invocation) error + +func (fn runnerFunc) run(ctx context.Context, call invocation) error { return fn(ctx, call) } + +func TestVerifyPinsMatchesRepositoryContracts(t *testing.T) { + t.Parallel() + root := filepath.Join("..", "..") + if err := VerifyPins(root); err != nil { + t.Fatal(err) + } +} + +func TestVerifyPinsFailsClosedOnMissingOrDriftedPin(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeContract(t, root, "AGENTS.md", "go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...\n") + writeContract(t, root, "CONTRIBUTING.md", "go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...\n") + if err := os.MkdirAll(filepath.Join(root, ".github", "workflows"), 0o755); err != nil { + t.Fatal(err) + } + if err := VerifyPins(root); err == nil { + t.Fatal("missing workflow pin accepted") + } + writeContract(t, root, ".github/workflows/ci.yml", "go run honnef.co/go/tools/cmd/staticcheck@v0.7.0 ./...\n") + if err := VerifyPins(root); err == nil { + t.Fatal("drifted workflow pin accepted") + } + writeContract(t, root, ".github/workflows/ci.yml", "go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...\n") + if err := VerifyPins(root); err != nil { + t.Fatal(err) + } +} + +func TestVerifyProbesPinnedStaticcheckWithLocalToolchain(t *testing.T) { + t.Parallel() + root := contractRoot(t) + var calls []invocation + runner := runnerFunc(func(_ context.Context, call invocation) error { + calls = append(calls, call) + return nil + }) + var stdout, stderr bytes.Buffer + if err := verify(context.Background(), root, &stdout, &stderr, runner); err != nil { + t.Fatal(err) + } + if len(calls) != 2 { + t.Fatalf("calls=%d", len(calls)) + } + if calls[0].path != "go" || strings.Join(calls[0].args, " ") != "run honnef.co/go/tools/cmd/staticcheck@v0.8.1 -debug.version" { + t.Fatalf("version call=%#v", calls[0]) + } + if calls[1].path != "go" || strings.Join(calls[1].args, " ") != "run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ." { + t.Fatalf("probe call=%#v", calls[1]) + } + for index, call := range calls { + if call.dir != root || call.stdout == nil || call.stderr == nil || count(call.env, "GOTOOLCHAIN=local") != 1 { + t.Fatalf("call %d lost directory, output, or toolchain boundary", index) + } + } +} + +func TestVerifyFailsClosedOnExportDataIncompatibility(t *testing.T) { + t.Parallel() + root := contractRoot(t) + err := verify(context.Background(), root, &bytes.Buffer{}, &bytes.Buffer{}, runnerFunc(func(_ context.Context, call invocation) error { + if strings.Contains(strings.Join(call.args, " "), "-debug.version") { + return nil + } + _, _ = io.WriteString(call.stderr, "cannot decode \"internal/byteorder\", export data version 4 is greater than maximum supported version 2\n") + return errors.New("exit status 1") + })) + if err == nil || !strings.Contains(err.Error(), "cannot decode this Go toolchain's export data") { + t.Fatalf("incompatibility not reported: %v", err) + } +} + +func writeContract(t *testing.T, root, relative, body string) { + t.Helper() + path := filepath.Join(root, relative) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func contractRoot(t *testing.T) string { + t.Helper() + root := t.TempDir() + writeContract(t, root, "AGENTS.md", "go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...\n") + writeContract(t, root, "CONTRIBUTING.md", "go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...\n") + writeContract(t, root, ".github/workflows/ci.yml", "go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...\n") + return root +} + +func count(values []string, want string) int { + found := 0 + for _, value := range values { + if value == want { + found++ + } + } + return found +} + +