From 09eb16754d152cf375af01537655bed41b7d5a83 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Wed, 16 Sep 2026 18:16:34 +0500 Subject: [PATCH] fix(lint): raise Staticcheck to v0.8.1 for Go 1.27 v0.7.0 cannot decode Go 1.27 export data. Pin the first stable analyzer that can, probe the local toolchain before analysis, and run CI on Go 1.27.x. --- .github/workflows/ci.yml | 9 +- AGENTS.md | 3 +- CHANGELOG.md | 2 + CONTRIBUTING.md | 3 +- cmd/check-staticcheck/main.go | 19 ++++ docs/architecture.md | 7 +- internal/staticcheckcompat/compat.go | 132 ++++++++++++++++++++++ internal/staticcheckcompat/compat_test.go | 122 ++++++++++++++++++++ 8 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 cmd/check-staticcheck/main.go create mode 100644 internal/staticcheckcompat/compat.go create mode 100644 internal/staticcheckcompat/compat_test.go 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 +} + +