Smell Hunter
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), boolean parameters (→ simplicity-hunter, which carries the calibrated do-not-flag rule), 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.
When to Use
- Reviewing code for structural design problems before a refactor
- Auditing code after rapid feature development or prototyping
- Hunting for misplaced responsibilities and data
- Identifying missing domain types and abstractions
- Preparing a codebase for long-term maintainability
- Complementing specialized hunters with cross-cutting smell detection
Core Principles
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
stringthat represents an email address, a pair offloat64s 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.
What to Hunt
1. Feature Envy
A function or method that uses more data from another package or struct than from its own receiver or local state.
Signals:
- Method on struct A that primarily accesses fields/methods of struct B (passed as parameter)
- Function that accesses many fields of a struct from another package
- Helper function that exists in package A but only operates on types from package B
- Method that receives a struct and calls 3+ of its methods while using none of its own receiver's fields
- A function whose name suggests it belongs to the type it operates on, not the package it lives in
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.
2. Data Clumps
The same group of parameters or fields that always appear together across multiple function signatures or struct definitions.
Signals:
- 3+ parameters that appear together in multiple function signatures (e.g.,
host,port,schemeorlatitude,longitude,altitude) - Multiple structs with the same subset of fields (e.g.,
Street,City,Zip,Countryin several types) - Functions that pass a group of related values individually instead of as a struct
- Struct with fields that form a logical sub-group (e.g.,
StartDate,EndDate,Timezoneinside 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.
3. Shotgun Surgery
A single logical change requires edits across many unrelated files or packages.
Signals:
- Adding a new field to a domain type requires updating 5+ files (handlers, validators, mappers, tests, serializers)
- Adding a new error type requires changes in the error package, every handler, and every test
- A config change requires editing code in multiple packages rather than just the config package
- Renaming a concept requires find-and-replace across the entire codebase
- A new feature flag requires changes in config, middleware, handlers, and templates
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:
- A registry or map-based dispatch instead of scattered switch cases
- Code generation for boilerplate that varies with each new type/field
- A single package that owns the concept end-to-end
4. Primitive Obsession
Using primitive types (string, int, float64, bool) for domain concepts that deserve their own named types.
Ownership: primitive obsession as domain modeling is owned here; type-hunter keeps only alias-vs-named-type
mechanics (type X = Y misuse, method-less named types). Boolean parameters belong to simplicity-hunter. (No
tension with boundary-hunter's "primitives flow" principle: both resolve in shared domain types — a UserID named
type flowing between packages satisfies both.)
Signals:
- Functions that accept
stringparameters for IDs, emails, URLs, currencies, or status codes - Functions where two
stringorintparameters could be accidentally swapped (e.g.,Transfer(from, to string, amount int)) - Validation logic for a "typed" string scattered across multiple call sites instead of enforced at construction
float64used for money calculations (precision loss)intused for durations without unit clarity (seconds? milliseconds?)- Raw
stringcomparisons for status/state values instead of typed constants
Action: 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.
5. Temporal Coupling
Functions or operations that must be called in a specific order, but nothing in the API enforces that order. Ownership: the coupling and the redesign recommendation are owned here; doc-hunter's ordering section covers only constraints that are staying (redesign rejected or out of scope) and need documenting.
Signals:
- A struct with
Init(),Setup(), orConfigure()methods that must be called beforeRun()orProcess() - Documentation or comments that say "must call X before Y"
- Panic or nil dereference when methods are called in the wrong order
- A builder pattern where
Build()can be called before required fields are set - State machine transitions that are valid only from certain states but not enforced by the API
Action: Redesign the API to make the order implicit:
- Use constructor functions that return a fully initialized struct
- Use the builder pattern with a
Build()that validates all required fields - Use state-machine types where each state is a different type with only valid transitions as methods
- Accept dependencies in the constructor rather than via separate
Set*methods
6. Comments as Deodorant
Comments that explain what confusing code does rather than why — masking a design problem instead of fixing it.
Comment ownership rule (stated identically in doc-hunter, slop-hunter, and smell-hunter):
- Comment absent and the "why" non-obvious → doc-hunter (add the missing "why" comment).
- Comment present and the code trivial → slop-hunter (delete the redundant comment).
- Comment present and the code non-trivial → smell-hunter (extract/refactor; the comment is deodorant).
Signals:
- A comment like
// Convert the user data to the format expected by the billing systemabove a 20-line block that should be an extracted function namedtoBillingFormat() - Comments that explain complex boolean expressions rather than extracting them into named functions
- Inline comments at each step of a long function, effectively creating "sections" that should be separate functions
- Comments explaining workarounds for the code's own design rather than external constraints
// This is confusing because...— if you're writing this comment, refactor instead
Action: Replace the comment with a code change:
- Extract the commented block into a function with an intent-revealing name
- Replace complex expressions with named variables or helper functions
- Split long functions at the comment boundaries into named sub-functions
- Only keep comments that explain why (business rules, external constraints, workarounds for third-party bugs)
7. Temporary Field
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:
- Struct field that is non-nil in only one of several code paths
- Fields that are set in method A and read in method B but meaningless in methods C, D, E
- Optional fields that exist because the struct is used in multiple contexts with different data requirements
- Fields documented as "only valid when X is true" or "set only during processing"
- Struct with fields like
tempResult,cachedX,lastErrorthat serve a single transient use
Action: 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.
8. init() Abuse
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.HandleFuncin library code)- Multiple
init()functions in the same package with ordering dependencies init()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().
9. Package-Level Mutable State
Global variables at the package level that hold mutable state, creating hidden coupling and test interference.
Signals:
var db *sql.DBorvar client *http.Clientat package level, modified at runtimevar cache = map[string]interface{}{}at package level, written to by multiple functions- Package-level
sync.Mutexprotecting package-level state (sign of a god package) var defaultConfig = Config{...}that is mutated bySetConfig()functions- Global loggers modified at runtime:
var logger = log.New(...)withSetLogger()functions - Package-level
var once sync.Oncewithvar instance *Service(singleton pattern)
Do not flag:
vardeclarations 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 supportconstmaps, sovaris the only option. Check for: (1) explicit annotations likenolint: gochecknoglobalswith "read-only" comments, (2) absence of any assignment to the variable outside its declaration. The smell is actual mutation (writes after init), not thevarkeyword 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.
10. Stuttering Names
Exported identifiers that repeat the package name, violating Go's naming convention where the package name provides context. Boundary with slop-hunter: stuttering as a package-design smell is owned here; slop-hunter owns Go naming-convention drift introduced by the audited change (its diff orientation).
Signals:
user.UserNameinstead ofuser.Nameconfig.ConfigOptioninstead ofconfig.Optionhttp.HTTPClientinstead ofhttp.Client(Go standard library gets this right)auth.AuthMiddlewareinstead ofauth.Middlewaredb.DBConnectioninstead ofdb.Connectionerrors.ErrorCodeinstead oferrors.Code
Action: 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.
Audit Workflow
Phase 1: Gain Context
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 feature envy or a data clump often requires reading the envied type's package. The co-change analysis (Phase 4) reads git history project-wide by nature; its findings still anchor to in-scope packages.Understand the project's domain model — what are the core entities, value objects, and operations?
Note the project's conventions for naming, struct design, and package organization.
Phase 2: Scan for Smell Candidates
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)
Phase 3: Evaluate Feature Envy and Data Clumps
For each function with cross-package data access:
- Does it access more fields/methods from another type than from its receiver?
- Would moving it to the other type's package improve cohesion?
For each function with 4+ parameters:
- Do the same parameters appear together in other function signatures?
- Is there a domain concept these parameters represent?
Phase 4: Evaluate Shotgun Surgery
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.
# Each commit's package set is sorted before printing (awk's `for (p in pkgs)` order is unspecified,
# and uniq -c only groups identical lines); the END block flushes the final commit; root-level files
# (no "/") count as package ".".
git log --pretty=format:'COMMIT' --name-only -50 | awk '
function flush( n, i, j, t, keys, s) {
n = 0
for (p in pkgs) keys[++n] = p
if (n) {
for (i = 1; i < n; i++)
for (j = i + 1; j <= n; j++)
if (keys[j] < keys[i]) { t = keys[i]; keys[i] = keys[j]; keys[j] = t }
s = keys[1]
for (i = 2; i <= n; i++) s = s " " keys[i]
print s
}
delete pkgs
}
/^COMMIT/ { flush(); next }
NF {
if ($0 ~ /\//) sub(/\/[^\/]*$/, ""); else $0 = "."
pkgs[$0] = 1
}
END { flush() }
' | 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.
Phase 5: Evaluate Go-Specific Smells
For each init() function:
- Does it have side effects (I/O, network, global state)?
- Can it fail? Does it panic?
- Does it make the package hard to test?
For each package-level var:
- Is it mutable? Is it written to after initialization?
- Is it accessed from multiple goroutines?
For each exported identifier:
- Does the name repeat the package name?
Phase 6: Produce Report
Output Format
Save as YYYY-MM-DD-smell-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.
# Smell 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
## 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. **High**: {data clumps with 5+ occurrences, primitive obsession causing type confusion, init() with error paths}
2. **Medium**: {feature envy, shotgun surgery patterns, package-level mutable state, temporal coupling}
3. **Low**: {stuttering names, comments as deodorant, temporary fields}
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: classic code smells and Go-specific antipatterns only. If a finding doesn't answer "is this a structural smell?", it belongs to another hunter — do not flag it here. Named boundaries: primitive obsession as domain modeling is owned here (type-hunter keeps alias mechanics); temporal coupling and its redesign are owned here (doc-hunter documents constraints that stay); boolean parameters belong to simplicity-hunter; apply the comment ownership rule (§6) for the doc/slop/smell split; stuttering is owned here, naming drift in the audited change belongs to slop-hunter.
- Evidence required. Every finding must cite
file/path.go:linewith the exact code. - Context matters. A smell in a prototype is less urgent than a smell in a payment system. Assess severity relative to the code's criticality and change frequency.
- Pragmatism. Not every smell requires action. A data clump that appears twice may not justify a new type. Feature envy in a utility function may be intentional. Report the smell, assess the cost/benefit, and let the team decide.
- Respect Go idioms. Go's
errorreturn pattern, explicit control flow, and composition-based design are not smells. Calibrate to Go conventions, not patterns from other languages. - Handoff, not duplication. Smells often have root causes owned by other hunters. Smell-hunter owns the symptom detection (e.g., "these 8 files always change together"); the root-cause fix may belong to boundary-hunter (dependency direction), solid-hunter (SRP), or simplicity-hunter (mixed concerns). When a finding clearly belongs to another hunter's domain, note it as a cross-reference in the report and do not duplicate the analysis. When the smell is the primary signal and no other hunter covers the detection method, own the finding.
- Assess refactoring risk briefly. Actions are recommendations, not commands — implementation is a separate step. When a recommendation would affect exported API surface, serialization behavior, or could introduce import cycles, note the risk in the Action column (e.g., "Extract type — verify no external importers").