Invariant Hunter
Audit Go code to make invariants explicit and enforced — catching unchecked errors, nil dereferences waiting to happen, type assertions without ok checks, zero-value traps, and places where panic replaces proper error handling. The goal: errors are always handled, nil is never dereferenced unexpectedly, and invalid states are caught at construction boundaries.
When to Use
- Tightening a domain model after initial prototyping
- Reducing panic risks across a codebase
- Improving error handling discipline
- Reviewing zero-value safety of struct types
- Before a major refactor to establish a safety baseline
- After enabling new linters or static analysis tools
Core Principles
Errors are values, not exceptions. Every returned error must be handled — checked, propagated, or explicitly discarded with documented justification. An unchecked error is a silent failure.
Nil is the billion-dollar mistake in Go too. A nil pointer dereference panics at runtime. Validate at boundaries, return early on nil, and document nil semantics in function contracts.
Resolve at construction boundaries. Defaults and validation belong where data is created or enters the system — constructor functions (
New*), API handlers, config loaders. Downstream functions should require their inputs to be valid. If every caller checks the same condition, push the check to the constructor.Zero values must be safe or documented. Go initializes all variables to their zero value. If a struct's zero value is invalid (e.g., a nil map, zero ID, empty required field), the constructor must enforce valid construction, and this constraint must be documented.
Type assertions must be checked. A bare type assertion (
x.(T)) panics on failure. Use the two-value form (v, ok := x.(T)) or a type switch. Panic-on-assertion is acceptable only in test code or when the assertion is provably correct.Panic is for programmer errors, not runtime conditions. Panic for truly unreachable code, violated invariants in internal logic, or test failures. Never panic on user input, network errors, or recoverable conditions.
Context cancellation is not optional. Functions that accept
context.Contextmust check for cancellation, especially in loops, I/O operations, and long-running computations. Ignoring context defeats the purpose of passing it.recoveris not error handling. Usingrecoverto catch panics and continue is a code smell. It hides bugs. The only legitimate use is at the top of goroutines in server code to prevent one request from crashing the process.
Canonical Exceptions
Not every finding requires action. Document these but do not flag as "must-fix":
| Pattern | When Acceptable |
|---|---|
_ = f() (discarded error) |
Error is truly ignorable (Close on read-only file, logger flush) with comment |
| Bare type assertion in test | Test code where panic is the desired failure mode |
| Zero-value struct | Struct is explicitly designed for zero-value usability (documented) |
recover in HTTP middleware |
Top-level panic recovery to prevent server crash |
panic in init() |
Truly fatal configuration error during startup |
panic in constructors for required fields |
When the project explicitly endorses panic for invariant violations (check CLAUDE.md, AGENTS.md, or project conventions). Do not recommend checked-constructor alternatives when panic is the intentional policy |
Important: Before flagging panic/recover patterns, check the project's CLAUDE.md or equivalent for stated conventions. If the project says "panic is appropriate for invariant violations", respect that convention. Acknowledge it in the report and do not recommend alternatives unless the panic is reachable from untrusted input (user-facing API boundary).
What to Hunt
1. Unchecked Errors
Returned errors that are silently discarded or ignored.
Signals:
- Function call with error return where error is assigned to
_without justification - Error return completely ignored (result count mismatch)
defer f.Close()without checking the error (especially on writes)fmt.Fprintf(w, ...)in HTTP handlers without checking write error- Error checked with
if err != nilbut the non-error path doesn't use the result
Action: Handle every error: return it, wrap it, or explicitly discard with a justifying comment. For defer Close,
use a named return to capture the error.
2. Nil Pointer Risks
Code paths that can dereference nil without prior validation.
Signals:
- Function that returns
(*T, error)where callers use*Twithout checking error first - Map lookup result used without checking the
okboolean when the zero value is ambiguous or could mask a missing key (v := m[key]then usingvwherev == ""orv == 0is indistinguishable from "not found") - Interface method call without nil check on the interface value
- Struct fields of pointer type accessed without nil guard after construction
rangeover a nil slice is safe, but indexing a nil slice is not — check index operations
Action: Validate at the boundary where nil could enter. Return early or return error on nil. Document nil semantics in function contracts.
3. Unsafe Type Assertions
Type assertions without the two-value ok check, risking panics.
Signals:
x.(ConcreteType)without ok check (panics if wrong type)- Type assertion chain:
x.(A).field.(B)— multiple panic points - Type assertions on
interface{}/anyvalues from external sources (JSON, config, etc.) - Type switch with no
defaultcase on an open interface (where new implementations can appear). Exhaustive type switches on sealed/internal interfaces with a known finite set of types are not a concern
Action: Use two-value form v, ok := x.(T) or type switch. Handle the failure case explicitly.
4. Zero-Value Traps
Structs whose zero value is invalid but can be created without a constructor.
Signals:
- Struct with map, channel, or func fields that are nil at zero value and used without initialization
- Struct that requires non-zero ID, name, or config but has no
New*constructor - Exported struct type with no constructor and methods that panic on zero value
sync.Mutexorsync.WaitGroupcopied after first use (detected viago vet)
Action: Provide a constructor. Make the struct unexported if zero-value construction is dangerous. Document zero-
value behavior. For types that must not be copied: Go cannot make a struct non-copyable — the mitigations are API
design and detection, not enforcement. Keep such types behind pointers (constructors return *T; don't accept or
return the struct by value), document the no-copy invariant, and run go vet's copylocks analyzer, which is what
actually catches violations. (The unexported noCopy marker used by the standard library exists solely to make
copylocks fire — a vet convention, not a language guarantee.)
5. Error Wrapping and Sentinel Errors
Poor error handling patterns that lose context or prevent errors.Is/errors.As matching.
Signals:
fmt.Errorfwithout%w(loses the error chain — cannot useerrors.Is)- Custom error type that doesn't implement
Unwrap()method - String comparison of error messages instead of
errors.Is/errors.As - Sentinel errors defined incorrectly: e.g.,
var ErrNotFound = "not found"(string, not error) orvar ErrNotFound error(nil, not initialized) instead ofvar ErrNotFound = errors.New("not found") - Error wrapping that adds no useful context:
fmt.Errorf("error: %w", err)
Action: Use %w in fmt.Errorf for wrappable errors. Use errors.Is/errors.As for comparison. Add meaningful
context when wrapping: package name, operation, relevant parameters.
Ownership split with slop-hunter: invariant-hunter owns correctness of the error chain (%w, Unwrap,
errors.Is/errors.As); slop-hunter owns redundancy of the wrapping messages (double-wrapping, noise words). The
two recommendations compose: wrap correctly, and wrap once where the context is meaningful.
6. Context Misuse
Ignoring context cancellation, improper context construction, or missing context propagation.
Signals:
- Function accepts
context.Contextbut never checksctx.Err()orctx.Done()in loops/I/O context.Background()orcontext.TODO()in non-main, non-test code- Context values used for passing dependencies (should use struct fields)
context.WithCancelorcontext.WithTimeoutwithout calling the cancel function- Missing
ctxparameter in functions that make network/DB calls
Action: Check context in loops and before expensive operations. Use context.TODO() only temporarily with a
comment. Pass context through the call chain. Always defer cancel functions.
7. Panic/Recover Misuse
Panics used for control flow or error handling instead of proper error returns.
Signals:
panic()called on user input validation failurepanic()in library code reachable by callers (not internal assertions)recover()used to suppress errors and continue normal executionlog.Fatal()oros.Exit()in library code (notmainor init)must*functions that panic in non-test, non-init contexts
Action: Replace panic with error return for any condition reachable by external callers. Use panic only for
genuinely unreachable code (exhaustive switch defaults, violated internal invariants). Limit recover to goroutine
crash prevention in server code.
8. Race Conditions and Synchronization Gaps
Shared mutable state accessed without proper synchronization.
Ownership: invariant-hunter owns data-race and synchronization correctness ("shared state is always accessed
under synchronization" is an invariant). security-hunter's concurrency section is narrowed to exploitability-framed
findings (unbounded goroutine creation from user input, deadlocks reachable from external requests, TOCTOU races on
auth decisions); goroutine-leak and -race discipline in tests stays with test-hunter.
Signals:
- Map read/write from multiple goroutines without mutex
- Struct field accessed from multiple goroutines without sync primitives
sync.Mutexlocked in one path but not in another that accesses the same statesync.WaitGroup.Add()called inside the goroutine instead of beforego- Non-atomic read-modify-write on shared variables
Action: Protect shared state with sync.Mutex, sync.RWMutex, sync/atomic, or channels. Run tests with
-race flag.
Audit Workflow
Phase 1: Establish Baseline
Resolve audit surface. The prompt may specify the scope as:
- Diff: files changed relative to the base branch — committed, staged, unstaged, and untracked
- Path: specific files, folders, or packages
- Codebase: the entire project (the default when unspecified; set
SCOPE=.)
Party mode: when the orchestrator supplies a scope snapshot (a resolved file list), use it verbatim and do not re-resolve. The resolution below applies to standalone runs only.
For diff mode, resolve fail-closed:
BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/@@') if [ -z "$BASE" ]; then for b in origin/main origin/master main master; do git rev-parse -q --verify "$b" >/dev/null && BASE=$b && break done fi # If BASE is still empty: STOP. Ask for an explicit base. Do not continue. SCOPE=$( { git diff --name-only --diff-filter=d "$BASE"...HEAD; git diff --name-only --diff-filter=d HEAD; git ls-files --others --exclude-standard; } | sort -u ) DELETED=$( { git diff --name-only --diff-filter=D "$BASE"...HEAD; git diff --name-only --diff-filter=D HEAD; } | sort -u )If
$SCOPEis empty, run no scans: write the report with "Audit completed: 0 findings — empty diff scope", listing$DELETEDunder "Deleted in diff" if non-empty, and stop. If the resolved surface exceeds what can be read within the context budget, report the file count and ask to narrow or chunk.Two surfaces. Findings are reported only against the target scope (
$SCOPE) — every finding anchors (file:line) there. Related files may still be read as context: judging whether every caller handles an error often requires reading callers outside the scope.Run the toolchain first — prefer the repository's existing invocation. If the project has a configured vet/lint entry point (Makefile target, CI step,
golangci-lintconfig), run that and triage its output. Otherwise rungo vet ./...when a Go toolchain is available and dependencies resolve. Delegate where the project has the tools configured:errcheckis the canonical detector for unchecked errors (use the project's pinned version — current errcheck requires a recent toolchain to build),errcheck -assertsor golangci-lint'sforcetypeassertfor unchecked type assertions, andgo vet'scopylocksfor copied locks. Never install or reconfigure tools for the audit; if a tool is unavailable or fails to load packages, record "skipped/unavailable" in the Tooling Context section and fall back to the heuristic scans below. If a relevant tool is absent from the project, recommend enabling it in the report. Note: vet's checks are heuristic — low-false-positive, not zero; triage rather than transcribe.Scan for patterns. These are candidate generators with stated blind spots, not detectors — the analyzers in step 2 are the reliable versions where available. Run every scan against the target scope (
SCOPE=.in codebase mode):EXCLUDE='--glob !**/*_test.go --glob !**/vendor/** --glob !**/testdata/** --glob !**/*.pb.go --glob !**/*_gen.go --glob !**/*_generated.go' # Generated code is authoritatively marked by a "// Code generated .* DO NOT EDIT." line before the first # non-comment text; the globs are the cheap approximation. # Explicitly discarded errors (note: cannot see implicitly ignored return values — that is errcheck's job) rg '\b_\s*=\s*\w+\(' --type go $EXCLUDE -- $SCOPE # Type assertion candidate census — manual triage required. Regex cannot reliably separate bare assertions # from checked ones; use errcheck -asserts / forcetypeassert (step 2) for the real detection. rg '\.\(\*?\w+\)' --type go $EXCLUDE -- $SCOPE | rg -v '\.\(type\)' # Panic calls rg 'panic\(' --type go $EXCLUDE -- $SCOPE # Recover calls rg 'recover\(\)' --type go $EXCLUDE -- $SCOPE # log.Fatal / os.Exit outside main rg 'log\.Fatal|os\.Exit' --type go $EXCLUDE -- $SCOPE # context.Background() / context.TODO() in non-main rg 'context\.(Background|TODO)\(\)' --type go $EXCLUDE -- $SCOPE # fmt.Errorf without %w rg 'fmt\.Errorf' --type go $EXCLUDE -- $SCOPE # Error string comparison rg 'err\.Error\(\)\s*==|strings\.Contains\(err' --type go $EXCLUDE -- $SCOPE # Nil map/channel field access rg 'make\(map|make\(chan' --type go $EXCLUDE -- $SCOPE # Deferred close without error check rg 'defer\s+\w+\.Close\(\)' --type go $EXCLUDE -- $SCOPEProduce counts by category, grouped by package.
Phase 2: Evaluate Error Handling
For each function that returns error: Is every caller handling it?
For each _ = f(): Is the discard justified?
For each error wrap: Does it use %w? Does it add context?
Phase 3: Evaluate Nil Safety
For each pointer return: Do callers check error before using the pointer? For each map lookup: Is the ok value checked when the zero value is meaningful? For each interface parameter: Is nil handled or documented?
Phase 4: Evaluate Type Safety
For each type assertion: Is the ok check present? For each type switch: Is there a default case? For each zero-value struct: Is the zero value safe or documented?
Phase 5: Evaluate Concurrency Safety
For each shared variable: Is it protected? For each goroutine: Is there a cancellation mechanism? For each sync primitive: Is it used correctly and consistently?
Output Format
Save as YYYY-MM-DD-invariant-hunter-audit-{model-name}.md — {model-name} is the executing model's short name
(e.g. fable-5) — in the project's docs folder (or project root if no docs folder exists). If the caller specifies
an output path or return mode (e.g. the party-hunter orchestrator), it overrides this default.
Severity levels, used for per-finding labels and the Recommendations grouping:
- Critical — exploitable now, causes data loss, or breaks behavior on production paths.
- High — a defect with likely user-visible, security, or reliability impact if left unaddressed.
- Medium — correctness or maintainability risk without imminent impact.
- Low — hygiene; no behavioral risk.
# Invariant Hunter Audit — {date}
## Scope
- Surface: {diff / path / codebase}
- Files: {count or list}
- Exclusions: {list}
- {Deleted in diff: {list} — only for diff scope with deletions}
- Audit completed: {N} findings
## Tooling Context
- `go vet`: {run for this audit / skipped-unavailable / enabled in CI}
- Static analysis: {golangci-lint / staticcheck / errcheck / none — run or skipped}
- Race detection: {enabled/disabled in CI}
## Baseline
| Category | Count |
| -------- | ----- |
| Discarded errors (`_ =`) | {n} |
| Bare type assertions | {n} |
| `panic()` calls (non-test) | {n} |
| `recover()` calls | {n} |
| `log.Fatal` / `os.Exit` in non-main | {n} |
| `context.Background()` in non-main | {n} |
| `fmt.Errorf` without `%w` | {n} |
| Error string comparison | {n} |
| Deferred Close without error check | {n} |
## Unchecked Errors
| # | Location | Call | Action |
| - | -------- | ---- | ------ |
| 1 | file:line | `_ = db.Close()` | Check error or add justification comment |
## Nil Pointer Risks
| # | Location | Pattern | Action |
| - | -------- | ------- | ------ |
| 1 | file:line | `user.Name` without nil check after `FindUser` | Check error before dereference |
## Type Assertion Safety
| # | Location | Assertion | Action |
| - | -------- | --------- | ------ |
| 1 | file:line | `val.(string)` | Use `val, ok := val.(string)` |
## Zero-Value Traps
| # | Type | Location | Issue | Action |
| - | ---- | -------- | ----- | ------ |
| 1 | `Config` | file:line | Nil map field, no constructor | Add `NewConfig()` constructor |
## Error Wrapping Issues
| # | Location | Pattern | Action |
| - | -------- | ------- | ------ |
| 1 | file:line | `fmt.Errorf("failed: %v", err)` | Use `%w` for unwrappable error |
## Context Misuse
| # | Location | Pattern | Action |
| - | -------- | ------- | ------ |
| 1 | file:line | `context.Background()` in service layer | Accept context from caller |
## Panic/Recover Misuse
| # | Location | Pattern | Action |
| - | -------- | ------- | ------ |
| 1 | file:line | `panic("invalid input")` | Return error instead |
## Race Condition Risks
| # | Location | Pattern | Action |
| - | -------- | ------- | ------ |
| 1 | file:line | Shared map without mutex | Add sync.RWMutex |
## Recommendations (Priority Order)
1. **Critical**: {unchecked errors on critical paths, nil dereference risks, bare type assertions on external data}
2. **High**: {race conditions on shared state, panic in library code reachable by callers}
3. **Medium**: {error wrapping without %w, context misuse, zero-value traps}
4. **Low**: {zero-value documentation, deferred close error handling, race detection in CI}
Operating Constraints
- No code edits. This skill produces an audit report only. Implementation is a separate step.
- No empty finding sections. Include only categories with findings. Omit a heading, table, or list entirely when it would contain zero items — do not include empty tables, placeholder subsections, or negative statements like "no dead exports", "none found", or "no issues". Execution status is exempt: the "Audit completed: N findings" line in the Scope section is always present, even at zero findings.
- Scope: invariant enforcement only. If a finding doesn't answer "is this invariant enforced?", it belongs to
another hunter — do not flag it here. Named boundaries: race-condition and synchronization correctness is owned
here (security-hunter keeps only exploitability-framed concurrency; test-hunter keeps
-racediscipline in tests); error-chain correctness (%w,errors.Is) is owned here while wrapping-message redundancy belongs to slop-hunter. - Evidence required. Every finding must cite
file/path.go:linewith the exact code. - Architecture-first. Understand the project's error handling conventions before flagging violations.
- Complexity honesty. Some invariants are expensive to encode in Go's type system. When recommending a runtime check, say so explicitly.
- Challenge assumptions. If the current approach makes a deliberate trade-off (e.g., panic in must-succeed init), acknowledge it rather than mechanically flagging it.
- Prioritize: unchecked errors > nil dereferences > bare type assertions > zero-value traps > error wrapping > context misuse. Fix the crashing bugs first.