hunter-party-ts/smell-hunter-ts/SKILL.md
Audit TypeScript code for classic code smells — feature envy, data clumps, shotgun surgery, primitive obsession, temporal coupling, comments as deodorant, temporary fields, callback hell, enum abuse, and class abuse. Use when: reviewing TypeScript 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-tsInstall 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 TypeScript code for code smells — structural patterns that indicate deeper design problems. This covers selected Fowler/Beck smells and TypeScript-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 TypeScript 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 tuple of numbers that represent a coordinate, or a group of parameters that
always appear together — these are domain concepts begging for a type.
Use the language, not fight it. TypeScript has powerful features — union types, branded types, discriminated
unions, satisfies, as const — that eliminate entire categories of smells. When the type system can enforce a
constraint, use it instead of runtime checks or conventions.
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 module or class than from its own context.
Signals:
Action: Move the function to the module/class that owns the data it operates on. 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 properties that always appear together across multiple function signatures or type 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 type/interface. Replace the individual parameters/properties with the type.
If the group appears only in function signatures, create a parameter type. If it appears in multiple type definitions,
extract a shared type and use intersection (&) or composition.
A single logical change requires edits across many unrelated files or modules.
Signals:
Action: Consolidate the scattered responsibility. If a change to concept X requires touching modules A, B, C, D, and E, then X's logic is spread too thin. Consider:
Pick, Omit, Partial)Using primitive types (string, number, boolean) for domain concepts that deserve their own named or branded types.
Signals:
string parameters for IDs, emails, URLs, currencies, or status codesstring or number parameters could be accidentally swapped (e.g., transfer(from: string, to: string, amount: number))number used for money calculations (floating-point precision loss)number used for durations without unit clarity (seconds? milliseconds?)process(data: Buffer, compress: boolean, encrypt: boolean)string comparisons for status/state values instead of union types or enumsAction: Define a branded type or a NewType pattern:
type UserId = string & { readonly __brand: unique symbol };
type Email = string & { readonly __brand: unique symbol };
Or use a Zod schema / validation function that narrows to the branded type. For boolean flags, use discriminated
unions or option objects. For status values, use string literal unions: type Status = 'active' | 'inactive' | 'suspended'.
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:
Comments 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 instead@description that describes the implementation algorithm rather than the public contractAction: Replace the comment with a code change:
Object properties or class fields that are meaningful only in certain states or during specific operations — they are set for one code path and undefined/null for all others.
Signals:
tempResult, cachedData, lastError that serve a single transient useAction: Extract the temporary properties into a separate type used only where needed. If the type represents multiple states, use a discriminated union: separate types for each state. For transient computation, use local variables or return values instead of fields.
Deeply nested callback chains or .then() chains that could be flattened with async/await.
Signals:
(err, result) => { ... }).then().then().then() chains longer than 3 steps without async/await.then() creating a "promise pyramid" instead of a flat chain.catch() blocks instead of a single try/catchAction: Convert to async/await with try/catch. Flatten nested callbacks into sequential awaited calls.
Extract complex callback bodies into named functions. If truly parallel operations are needed, use
Promise.all()/Promise.allSettled() with await.
Using TypeScript enum where a string literal union type would be simpler, more type-safe, and more idiomatic.
Signals:
enum Status { Active = 'active', Inactive = 'inactive' } where type Status = 'active' | 'inactive' sufficesenum values compared with === against string literals (defeating the purpose of the enum)const enum used for values that need to be preserved at runtime (e.g., serialized, logged)enum with a single memberRecord<Status, T> with a union would be simplerAction: Replace with string literal union types for simple value sets. Use as const objects when you need both
a type and runtime access:
const Status = { Active: 'active', Inactive: 'inactive' } as const;
type Status = typeof Status[keyof typeof Status]; // 'active' | 'inactive'
Keep enum only when you need: reverse mapping (numeric enums), bitwise flags, or compatibility with non-TS
consumers.
Using classes where plain functions, closures, or modules would be simpler and more idiomatic TypeScript.
Signals:
getInstance() static method (singleton — use a module-level instance instead)Action: Replace with the simpler construct:
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.* --glob !**/*.spec.* --glob !**/node_modules/** --glob !**/dist/** --glob !**/*.generated.* --glob !**/__generated__/** --glob !**/*.g.ts --glob !**/generated/**'
# Feature envy: methods that heavily reference another module's types
# (look for obj.prop.prop patterns across module boundaries — verify manually)
rg --pcre2 '\b[a-z]\w+\.[a-z]\w+\.[a-z]\w+' --type ts $EXCLUDE -- $SCOPE
# Data clumps: repeated parameter groups (functions with 4+ params)
rg --pcre2 'function\s+\w+\s*\([^)]{100,}\)' --type ts $EXCLUDE -- $SCOPE
rg --pcre2 '(?:const|let)\s+\w+\s*=\s*(?:async\s+)?\([^)]{100,}\)\s*(?:=>|:)' --type ts $EXCLUDE -- $SCOPE
# Primitive obsession: functions with 2+ bare string/number params in sequence
rg --pcre2 'function\s+\w+\s*\([^)]*:\s*string\s*,[^)]*:\s*string' --type ts $EXCLUDE -- $SCOPE
# Temporal coupling: init/setup/configure methods
rg --pcre2 '(init|setup|configure|prepare)\s*\(' --type ts $EXCLUDE -- $SCOPE
# Callback hell: nested .then() chains
rg --pcre2 '\.then\([^)]*\)\s*\.then\([^)]*\)\s*\.then' --type ts $EXCLUDE -- $SCOPE
# Enum declarations (then evaluate for abuse)
rg 'enum\s+\w+' --type ts $EXCLUDE -- $SCOPE
# Classes (then evaluate for class abuse)
rg 'class\s+\w+' --type ts $EXCLUDE -- $SCOPE
# Singleton pattern
rg 'getInstance\s*\(' --type ts $EXCLUDE -- $SCOPE
# Comments as deodorant: multi-line comment blocks before code (inspect for "what" vs "why")
rg -B1 -A1 '^\s*// ' --type ts $EXCLUDE -- $SCOPE | head -200
# Shotgun surgery: per-commit co-occurrence (see Phase 4)
For each function with cross-module 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
# Directory co-occurrence: for each commit, list distinct directories touched
git log --pretty=format:'COMMIT' --name-only -50 | awk '
/^COMMIT/ { if (NR>1) { for (d in dirs) printf "%s ", d; print "" } delete dirs; next }
/\// { sub(/\/[^\/]*$/, ""); dirs[$0]=1 }
' | sort | uniq -c | sort -rn | head -20
For each commit that touches 4+ directories, ask: was this a single logical change scattered across unrelated modules, or a legitimate cross-cutting concern? Look for patterns: the same directory set appearing in multiple commits suggests structural coupling.
For each enum:
For each class:
For each .then() chain or callback:
async/await?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 properties | 5 properties | `inv.total + inv.tax...` | Move to billing module |
### Data Clumps
| # | Locations | Parameters/Properties | Evidence | Suggested Type | Action |
| - | --------- | -------------------- | -------- | -------------- | ------ |
| 1 | file:line, file:line, file:line | `host, port, scheme` | 3 func signatures | `Endpoint` type | Extract type |
### Shotgun Surgery
| # | Concept | Files Touched | Modules Touched | Action |
| - | ------- | ------------- | --------------- | ------ |
| 1 | "Add new payment method" | 8 files | 5 modules | Consolidate payment logic |
### Primitive Obsession
| # | Location | Parameter/Property | Current Type | Evidence | Suggested Type | Action |
| - | -------- | ------------------ | ------------ | -------- | -------------- | ------ |
| 1 | file:line | `userId: string` | `string` | swappable with `orderId: string` | `UserId` (branded) | Define branded type |
### Temporal Coupling
| # | Location | Class/Object | 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 | Type | Property | Used In | Action |
| - | -------- | ---- | -------- | ------- | ------ |
| 1 | file:line | `Processor` | `lastResult` | `process()` only | Use discriminated union or local var |
### Callback Hell
| # | Location | Pattern | Depth | Action |
| - | -------- | ------- | ----- | ------ |
| 1 | file:line | `.then().then().then()` | 4 | Convert to async/await |
### Enum Abuse
| # | Location | Enum | Members | Action |
| - | -------- | ---- | ------- | ------ |
| 1 | file:line | `enum Status` | 3 string values | Replace with `type Status = 'active' \| 'inactive' \| 'suspended'` |
### Class Abuse
| # | Location | Class | Methods | State | Action |
| - | -------- | ----- | ------- | ----- | ------ |
| 1 | file:line | `UserService` | 1 public | none | Replace with exported function |
## Recommendations (Priority Order)
1. **Must-fix**: {data clumps with 5+ occurrences, primitive obsession causing type confusion, callback hell > 3 levels}
2. **Should-fix**: {feature envy, shotgun surgery patterns, enum abuse, temporal coupling}
3. **Consider**: {class abuse, comments as deodorant, temporary fields}
file/path.ts:line with the exact code.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.