hunter-party-go/smell-hunter-go/SKILL.md
Audit Go code for classic code smells — feature envy, data clumps, shotgun surgery, primitive obsession, temporal coupling, comments as deodorant, temporary fields, init() abuse, package-level mutable state, and stuttering names. Use when: reviewing Go code for structural design problems, preparing for a refactor, auditing code after rapid feature development, or hunting for misplaced responsibilities.
npx skillsauth add skyosev/agent-skills smell-hunter-goInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Audit Go code for code smells — structural patterns that indicate deeper design problems. This covers selected Fowler/Beck smells and Go-specific antipatterns that fall outside the scope of specialized hunters (SOLID, type design, boundaries, invariants, etc.). The goal: data lives where it's used, changes are localized, domain concepts are modeled explicitly, and Go idioms are respected.
Not covered (owned by other hunters): long method / mixed concerns (→ simplicity-hunter), dead code (→ simplicity-hunter / slop-hunter), speculative generality (→ simplicity-hunter), magic numbers (→ doc-hunter), interface pollution (→ simplicity-hunter / solid-hunter). See Operating Constraints for handoff rules.
Code smells are symptoms, not diagnoses. Each finding indicates a likely design problem that warrants investigation. Context determines whether the smell is a genuine issue or an acceptable trade-off.
Smells are symptoms, not diseases. A code smell indicates a probable design problem, not a guaranteed one. Evaluate each smell in context — some are intentional trade-offs. The goal is awareness, not mechanical elimination.
Follow the data. Feature envy, data clumps, and primitive obsession all point to misplaced or undermodeled data. When data and behavior want to be together, let them. When a group of values always travels together, they are a missing type.
One change, one place. Shotgun surgery means a single logical change requires edits across many unrelated files. This is the hallmark of misaligned module boundaries or scattered responsibilities. The fix is cohesion.
Comments should not be deodorant. A comment explaining confusing code is a band-aid over a design problem. The fix is clearer code — better names, extracted functions, simpler structure — not more comments.
Model the domain. Primitive obsession and data clumps often indicate missing domain types. A string that
represents an email address, a pair of float64s that represent a coordinate, or a group of parameters that
always appear together — these are domain concepts begging for a type.
Respect Go idioms. Go has specific conventions: no stuttering names, minimal init(), explicit state management, composition over inheritance. Language-specific smells are as important as universal ones.
Refactor incrementally. Split by responsibility, not by size. Introduce abstraction only when needed (wait for the second use case). Preserve behavior first — add tests before restructuring.
A function or method that uses more data from another package or struct than from its own receiver or local state.
Signals:
Action: Move the function to the package that owns the data it operates on (you can only define methods on types in the same package — move the function near its data, minding import cycles). If it uses data from two types equally, consider whether the shared data should be extracted into its own type.
The same group of parameters or fields that always appear together across multiple function signatures or struct definitions.
Signals:
host, port, scheme or
latitude, longitude, altitude)Street, City, Zip, Country in several types)StartDate, EndDate, Timezone inside a larger config)Action: Extract the group into a named struct. Replace the individual parameters/fields with the struct. If the group appears only in function signatures, create a parameter struct. If it appears in multiple struct definitions, extract a shared embedded or composed type.
A single logical change requires edits across many unrelated files or packages.
Signals:
Action: Consolidate the scattered responsibility. If a change to concept X requires touching packages A, B, C, D, and E, then X's logic is spread too thin. Consider:
Using primitive types (string, int, float64, bool) for domain concepts that deserve their own named types.
Signals:
string parameters for IDs, emails, URLs, currencies, or status codesstring or int parameters could be accidentally swapped (e.g., Transfer(from, to string, amount int))float64 used for money calculations (precision loss)int used for durations without unit clarity (seconds? milliseconds?)Process(data []byte, compress bool, encrypt bool)string comparisons for status/state values instead of typed constantsAction: Define a named type: type UserID string, type Email string, type Money int64 (cents). Add a
constructor that validates. The type system then prevents mixing UserID with OrderID at compile time. For boolean
flags, consider typed constants with iota or functional options.
Functions or operations that must be called in a specific order, but nothing in the API enforces that order.
Signals:
Init(), Setup(), or Configure() methods that must be called before Run() or Process()Build() can be called before required fields are setAction: Redesign the API to make the order implicit:
Build() that validates all required fieldsSet* methodsComments that explain what confusing code does rather than why — masking a design problem instead of fixing it.
Signals:
// Convert the user data to the format expected by the billing system above a 20-line block that
should be an extracted function named toBillingFormat()// This is confusing because... — if you're writing this comment, refactor insteadAction: Replace the comment with a code change:
Struct fields that are meaningful only in certain states or during specific operations — they are set for one code path and nil/zero for all others.
Signals:
tempResult, cachedX, lastError that serve a single transient useAction: Extract the temporary fields into a separate struct used only where needed. If the struct represents multiple states, use a discriminated pattern: separate structs for each state, or a method-scoped local variable instead of a field.
Complex logic, side effects, or non-trivial initialization in init() functions.
Signals:
init() that opens database connections, network sockets, or file handlesinit() that reads environment variables or configuration filesinit() with error handling (errors in init cannot be returned, only panicked)init() that registers global state (e.g., http.HandleFunc in library code)init() functions in the same package with ordering dependenciesinit() that makes the package untestable (side effects on import)Action: Move initialization logic to explicit constructor functions or a Setup() function called from main().
Reserve init() for truly static registrations (e.g., database/sql driver registration, codec registration) that
have no error conditions and no external dependencies. If init() can fail, it should not be in init().
Global variables at the package level that hold mutable state, creating hidden coupling and test interference.
Signals:
var db *sql.DB or var client *http.Client at package level, modified at runtimevar cache = map[string]interface{}{} at package level, written to by multiple functionssync.Mutex protecting package-level state (sign of a god package)var defaultConfig = Config{...} that is mutated by SetConfig() functionsvar logger = log.New(...) with SetLogger() functionsvar once sync.Once with var instance *Service (singleton pattern)Do not flag:
var declarations that are effectively constant lookup tables (e.g., var weekdayMap = map[string]time.Weekday{...})
when there is no write path after initialization. Go does not support const maps, so var is the only option.
Check for: (1) explicit annotations like nolint: gochecknoglobals with "read-only" comments, (2) absence of any
assignment to the variable outside its declaration. The smell is actual mutation (writes after init), not the var
keyword on a read-only structure.Action: Move state into struct instances passed via dependency injection. If truly global state is needed (rare),
encapsulate it in a struct with a constructor and pass the struct explicitly. Package-level constants and immutable
variables (var Version = "1.0") are fine — the smell is mutability.
Exported identifiers that repeat the package name, violating Go's naming convention where the package name provides context.
Signals:
user.UserName instead of user.Nameconfig.ConfigOption instead of config.Optionhttp.HTTPClient instead of http.Client (Go standard library gets this right)auth.AuthMiddleware instead of auth.Middlewaredb.DBConnection instead of db.Connectionerrors.ErrorCode instead of errors.CodeAction: Remove the package-name prefix from the identifier. In Go, user.Name reads as "the Name in the user
package" — the package name already provides the context. Stuttering adds noise and violates the convention documented
in Effective Go.
main/master)BASE=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo main)
SCOPE=$(git diff --name-only $(git merge-base HEAD $BASE)...HEAD)
Constrain all subsequent scans to the resolved surface.These scans produce candidates only — each match requires manual validation in Phase 3–5 before it becomes a finding. Expect a high false-positive rate from regex heuristics; the value is in surfacing locations to inspect.
For diff/path mode, append the resolved file list ($SCOPE) to each rg command. For codebase mode, omit it.
EXCLUDE='--glob !**/*_test.go --glob !**/vendor/** --glob !**/testdata/** --glob !**/*.pb.go --glob !**/*_gen.go --glob !**/*_generated.go --glob !**/mock_*.go --glob !**/mocks/**'
# Feature envy: methods that heavily reference another package's types
# (look for pkg.Type.Field or pkg.Func().Method patterns — then verify manually)
rg --pcre2 '\b[a-z]\w+\.[A-Z]\w+\.' --type go $EXCLUDE -- $SCOPE
# Data clumps: repeated parameter groups (functions with 4+ params)
rg --pcre2 'func\s+(\(\w+\s+\*?\w+\)\s+)?\w+\([^)]{100,}\)' --type go $EXCLUDE -- $SCOPE
# Primitive obsession: functions with 2+ bare string/int params in sequence
rg --pcre2 'func.*\(\s*\w+\s+string\s*,\s*\w+\s+string' --type go $EXCLUDE -- $SCOPE
# Temporal coupling: Init/Setup/Configure methods on receiver types
rg --pcre2 'func\s+\(\w+\s+\*?\w+\)\s+(Init|Setup|Configure|Prepare)\b' --type go $EXCLUDE -- $SCOPE
# init() functions (then inspect for side effects)
rg '^func init\(\)' --type go $EXCLUDE -- $SCOPE
# Package-level mutable state (var declarations — filter for mutability manually)
rg '^var\s+\w+\s' --type go $EXCLUDE -- $SCOPE
# Stuttering names (package name repeated in exported identifiers)
# Manual check per package — compare package name to exported symbol prefixes
# Comments as deodorant: multi-line comment blocks before code (inspect for "what" vs "why")
rg -B1 -A1 '^\s*// ' --type go $EXCLUDE -- $SCOPE | head -200
# Shotgun surgery: per-commit co-occurrence (see Phase 4)
For each function with cross-package data access:
For each function with 4+ parameters:
Shotgun surgery is detected through per-commit co-change analysis, not raw file churn. High churn on a single file is not shotgun surgery — the signal is many unrelated files changing together for a single logical change.
# Per-commit file sets: show which files change together in each commit
git log --pretty=format:'--- %h %s' --name-only -30 | head -200
# Package co-occurrence: for each commit, list distinct packages touched
git log --pretty=format:'COMMIT' --name-only -50 | awk '
/^COMMIT/ { if (NR>1) { for (p in pkgs) printf "%s ", p; print "" } delete pkgs; next }
/\// { sub(/\/[^\/]*$/, ""); pkgs[$0]=1 }
' | sort | uniq -c | sort -rn | head -20
For each commit that touches 4+ packages, ask: was this a single logical change scattered across unrelated modules, or a legitimate cross-cutting concern? Look for patterns: the same package set appearing in multiple commits suggests structural coupling.
For each init() function:
For each package-level var:
For each exported identifier:
Save as YYYY-MM-DD-smell-hunter-audit-{$LLM-name}.md in the project's docs folder (or project root if no docs folder exists).
# Smell Hunter Audit — {date}
## Scope
- Surface: {diff / path / codebase}
- Files: {count or list}
- Exclusions: {list}
## Findings
### Feature Envy
| # | Location | Function | Envied Type | Own Data Used | Foreign Data Used | Evidence | Action |
| - | -------- | -------- | ----------- | ------------- | ----------------- | -------- | ------ |
| 1 | file:line | `FormatOrder()` | `billing.Invoice` | 0 fields | 5 fields | `inv.Total + inv.Tax...` | Move to `billing` package |
### Data Clumps
| # | Locations | Parameters/Fields | Evidence | Suggested Type | Action |
| - | --------- | ----------------- | -------- | -------------- | ------ |
| 1 | file:line, file:line, file:line | `host, port, scheme` | 3 func signatures | `Endpoint` struct | Extract type |
### Shotgun Surgery
| # | Concept | Files Touched | Packages Touched | Action |
| - | ------- | ------------- | ---------------- | ------ |
| 1 | "Add new payment method" | 8 files | 5 packages | Consolidate payment logic |
### Primitive Obsession
| # | Location | Parameter/Field | Current Type | Evidence | Suggested Type | Action |
| - | -------- | --------------- | ------------ | -------- | -------------- | ------ |
| 1 | file:line | `userID string` | `string` | swappable with `orderID string` | `UserID` | Define named type with constructor |
### Temporal Coupling
| # | Location | Struct | Required Order | Action |
| - | -------- | ------ | -------------- | ------ |
| 1 | file:line | `Server` | `Init()` → `Start()` | Require deps in constructor |
### Comments as Deodorant
| # | Location | Comment | Action |
| - | -------- | ------- | ------ |
| 1 | file:line | `// Parse and validate the user input` | Extract `parseAndValidateInput()` |
### Temporary Field
| # | Location | Struct | Field | Used In | Action |
| - | -------- | ------ | ----- | ------- | ------ |
| 1 | file:line | `Processor` | `lastResult` | `Process()` only | Move to method-local var or return value |
### init() Abuse
| # | Location | Side Effects | Action |
| - | -------- | ------------ | ------ |
| 1 | file:line | Opens DB connection, reads env vars | Move to explicit `Setup()` called from main |
### Package-Level Mutable State
| # | Location | Variable | Mutated By | Action |
| - | -------- | -------- | ---------- | ------ |
| 1 | file:line | `var defaultClient` | `SetClient()` | Inject via struct field |
### Stuttering Names
| # | Location | Current Name | Suggested Name | Action |
| - | -------- | ------------ | -------------- | ------ |
| 1 | file:line | `user.UserName` | `user.Name` | Rename |
## Recommendations (Priority Order)
1. **Must-fix**: {data clumps with 5+ occurrences, primitive obsession causing type confusion, init() with error paths}
2. **Should-fix**: {feature envy, shotgun surgery patterns, package-level mutable state, temporal coupling}
3. **Consider**: {stuttering names, comments as deodorant, temporary fields}
file/path.go:line with the exact code.error return pattern, explicit control flow, and composition-based design are not
smells. Calibrate to Go conventions, not patterns from other languages.development
Transforms vague feature ideas into precise, codebase-grounded technical requirements. Use when requirements are ambiguous/incomplete, the user struggles to describe behavior, terminology is unclear, or multiple concepts are mixed. Output is a requirements spec—NOT an implementation plan.
tools
Audit TypeScript type definitions for design debt — duplicated shapes, missing derivations, over-engineered generics, under-constrained type parameters, reinvented utility types, and disorganized type architecture. Type structure and maintainability, not type enforcement. Use when: reviewing type definitions for maintainability, reducing type duplication, simplifying over-engineered type-level logic, or reorganizing type architecture after growth.
development
Audit TypeScript test code for quality gaps — missing coverage on critical paths, brittle tests coupled to implementation, over-mocking, assertion-free tests, missing edge cases, and duplicated test setup. Focuses on test effectiveness, not production code structure. Use when: reviewing TypeScript test suites for reliability, reducing false-positive test failures, improving coverage of critical business logic, or cleaning up test debt.
tools
Audit TypeScript class and interface design for SOLID violations — god classes, rigid extension points, broken substitutability, fat interfaces, and concrete dependency chains. Focuses on responsibility assignment and abstraction fitness. Use when: reviewing class hierarchies, preparing for extension with new variants, reducing coupling between services, or improving testability of class-heavy code.