check-safety/golden_test.go
Alex Dunmow cd88c808b0 initial: standalone check-safety module hoisted from CMS
Static safety/lint runner for the BlockNinja codebase. ~25 invariant
checks across Go and frontend sources. Was at git.dev.alexdunmow.com:block/ninja
in backend/cmd/check-safety/ until the 2026-06-06 consolidation moved
the BlockNinja repos under a shared ~/src/blockninja/ parent.

This repo is the standalone extraction:
- Own go.mod (git.dev.alexdunmow.com/block/check-safety, go 1.26.4)
- Vendored internal/{helpers,theme} from CMS (Go's internal/ rule
  blocks cross-module imports; vendoring is the workaround)
- CLI contract unchanged: `check-safety <target-dir> [--flags]`
- CMS Makefile shells into ../check-safety for safety-check /
  install-safety-checker targets

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 13:04:02 +08:00

290 lines
8.7 KiB
Go

package main
// Golden characterization test for cmd/check-safety.
//
// Builds the tool once (TestMain), then runs it against small in-memory fixture
// trees created per test case. Normalises volatile tokens (absolute fixture
// path, SDK version) before comparing against committed golden files.
//
// Usage:
//
// go test ./cmd/check-safety/ -run TestGoldenCharacterization # compare
// go test ./cmd/check-safety/ -run TestGoldenCharacterization -update # regenerate
//
// Archetype-B checks (2f codegen, 3 Go lint, 3b orchestrator tests, 4 frontend
// lint) are intentionally excluded from the golden corpus: they shell out to
// external tools and cannot be snapshotted deterministically. Fixtures are
// arranged so those four checks print SKIP (or OK for the `clean` case where
// the minimal Go package is deterministically lint-clean).
import (
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
)
var (
update = flag.Bool("update", false, "regenerate golden files instead of comparing")
checkSafetyBin string // set by TestMain
)
// fixtureDef describes a single fixture tree that is created in a temp dir.
type fixtureDef struct {
// path is relative to the fixture root; content is the file content.
path string
content string
}
// goldenCase is one row in the characterisation matrix.
type goldenCase struct {
name string
fixtures []fixtureDef
// extraArgs are additional CLI args passed after the positional target dir.
extraArgs []string
wantExit int
}
// goldenCases enumerates the test matrix.
var goldenCases = []goldenCase{
{
// nomod: no go.mod present.
// — Check 1 FAIL: os.Getenv(secret) outside config
// — Check 2 SKIP: no proto procedures
// — Check 2b OK
// — Check 2c FAIL: missing go.mod
// — Check 3 SKIP: no go.mod found (external tools NOT invoked)
// — Check 3b SKIP: orchestrator not found
// — Check 2f SKIP: no codegen targets
// — Check 4 SKIP: no frontend sources
// — Check 14 FAIL: raw SQL
// — Check 15 FAIL: err.Error() leaked
// — Check 17 FAIL: TODO marker
name: "nomod",
fixtures: []fixtureDef{
{
path: "internal/service/handler.go",
content: `// Package service provides a service handler.
package service
import (
"database/sql"
"net/http"
"os"
)
// MyService handles requests.
type MyService struct{}
// Init initialises the service.
// TODO: add proper initialisation
func (s *MyService) Init() {
// Leak a secret env var outside config.Load() — triggers Check 1
_ = os.Getenv("JWT_SECRET")
}
// QueryUsers fetches users using raw SQL — triggers Check 14.
func (s *MyService) QueryUsers(db *sql.DB) {
db.QueryContext(nil, "SELECT id, name FROM users WHERE active = true") //nolint
}
// HandleError leaks err.Error() to HTTP client — triggers Check 15.
func (s *MyService) HandleError(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
`,
},
},
wantExit: 1,
},
{
// clean: go.mod present, minimal valid Go package, no violations.
// — All deterministic checks OK or SKIP
// — Check 2c OK: go.mod has no replace directive for block/core
// — Check 3 OK: lint pipeline passes (deterministic: trivial package)
// — Check 3b SKIP
// — Check 2f SKIP
// — Check 4 SKIP: no frontend sources
name: "clean",
fixtures: []fixtureDef{
{
path: "go.mod",
content: "module example.com/goldenclean\n\ngo 1.21\n",
},
{
path: "internal/pkg/util.go",
content: `// Package pkg provides utility functions.
package pkg
// Add returns the sum of a and b.
func Add(a, b int) int {
return a + b
}
`,
},
},
wantExit: 0,
},
}
// ---- TestMain: build the binary once ----------------------------------------
func TestMain(m *testing.M) {
flag.Parse()
// Build check-safety to a temp file.
tmpDir, err := os.MkdirTemp("", "check-safety-golden-bin-*")
if err != nil {
fmt.Fprintf(os.Stderr, "golden_test: failed to create tmpdir for binary: %v\n", err)
os.Exit(2)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
binPath := filepath.Join(tmpDir, "check-safety")
// Build the standalone check-safety binary from its own module.
buildCmd := exec.Command("go", "build", "-o", binPath, ".")
buildCmd.Env = os.Environ() // inherit env (GOPATH, GOMODCACHE, etc.)
if out, buildErr := buildCmd.CombinedOutput(); buildErr != nil {
fmt.Fprintf(os.Stderr, "golden_test: failed to build check-safety binary:\n%s\n", out)
os.Exit(2)
}
checkSafetyBin = binPath
os.Exit(m.Run())
}
// ---- TestGoldenCharacterization -----------------------------------------------
func TestGoldenCharacterization(t *testing.T) {
for _, tc := range goldenCases {
t.Run(tc.name, func(t *testing.T) {
// 1. Create fixture tree in a temp dir.
fixtureDir := t.TempDir()
for _, f := range tc.fixtures {
dst := filepath.Join(fixtureDir, filepath.FromSlash(f.path))
if mkErr := os.MkdirAll(filepath.Dir(dst), 0o755); mkErr != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(dst), mkErr)
}
if writeErr := os.WriteFile(dst, []byte(f.content), 0o644); writeErr != nil {
t.Fatalf("write fixture %s: %v", dst, writeErr)
}
}
// 2. Run check-safety against the fixture.
args := append([]string{fixtureDir}, tc.extraArgs...)
cmd := exec.Command(checkSafetyBin, args...)
raw, _ := cmd.CombinedOutput() // exit code is checked separately
gotExit := 0
if cmd.ProcessState != nil {
gotExit = cmd.ProcessState.ExitCode()
}
// 3. Normalise volatile tokens.
got := normalizeGoldenOutput(string(raw), fixtureDir)
// 4. Golden update mode: write files and return.
if *update {
writeGolden(t, tc.name, got, gotExit)
return
}
// 5. Compare exit code.
if gotExit != tc.wantExit {
t.Errorf("exit code = %d, want %d", gotExit, tc.wantExit)
}
// 6. Compare normalised stdout.
want := readGoldenStdout(t, tc.name)
if got != want {
t.Errorf("golden mismatch for case %q\n\n--- want ---\n%s\n--- got ---\n%s\n",
tc.name, want, got)
}
// 7. Compare exit code from golden file.
wantExitFromFile := readGoldenExit(t, tc.name)
if gotExit != wantExitFromFile {
t.Errorf("exit code = %d, want %d (from golden file)", gotExit, wantExitFromFile)
}
})
}
}
// ---- normalisation ----------------------------------------------------------
// reSDKVersion matches the block/core semver in Check-2c messages.
// Example: "require git.dev.alexdunmow.com/block/core v0.9.0,"
var reSDKVersion = regexp.MustCompile(
`(git\.dev\.alexdunmow\.com/block/core) v\d+\.\d+\.\d+[-\w]*`,
)
// normalizeGoldenOutput replaces volatile tokens with stable placeholders.
// - The absolute fixture directory path → FIXTURE_DIR
// - The SDK semver in Check 2c messages → <SDK_VERSION>
func normalizeGoldenOutput(output, fixtureDir string) string {
// Normalise all occurrences of the absolute fixture path.
out := strings.ReplaceAll(output, fixtureDir, "FIXTURE_DIR")
// Normalise SDK version so golden files survive SDK bumps.
out = reSDKVersion.ReplaceAllString(out, "$1 <SDK_VERSION>")
return out
}
// ---- golden file helpers ----------------------------------------------------
func goldenDir(name string) string {
return filepath.Join("testdata", "golden", name)
}
func goldenStdoutPath(name string) string {
return filepath.Join(goldenDir(name), "expected.stdout")
}
func goldenExitPath(name string) string {
return filepath.Join(goldenDir(name), "expected.exit")
}
func readGoldenStdout(t *testing.T, name string) string {
t.Helper()
data, err := os.ReadFile(goldenStdoutPath(name))
if err != nil {
t.Fatalf("read golden stdout for %q: %v (run with -update to generate)", name, err)
}
return string(data)
}
func readGoldenExit(t *testing.T, name string) int {
t.Helper()
data, err := os.ReadFile(goldenExitPath(name))
if err != nil {
t.Fatalf("read golden exit for %q: %v (run with -update to generate)", name, err)
}
trimmed := strings.TrimSpace(string(data))
if trimmed == "0" {
return 0
}
if trimmed == "1" {
return 1
}
t.Fatalf("unexpected exit code in golden file for %q: %q", name, trimmed)
return -1
}
func writeGolden(t *testing.T, name, stdout string, exitCode int) {
t.Helper()
dir := goldenDir(name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir golden dir %s: %v", dir, err)
}
if err := os.WriteFile(goldenStdoutPath(name), []byte(stdout), 0o644); err != nil {
t.Fatalf("write golden stdout for %q: %v", name, err)
}
exitStr := fmt.Sprintf("%d\n", exitCode)
if err := os.WriteFile(goldenExitPath(name), []byte(exitStr), 0o644); err != nil {
t.Fatalf("write golden exit for %q: %v", name, err)
}
t.Logf("golden files updated for case %q (exit=%d)", name, exitCode)
}