Anti-Pattern Sniffer
Documentation
Find architectural anti-patterns before they become tech debt
Getting Started
What is Anti-Pattern Sniffer?
Anti-Pattern Sniffer (aps) scans React, Express, and NestJS codebases for common anti-patterns using regex-based heuristics. It has zero runtime dependencies, runs entirely in Node.js, and gives you actionable fixes for every issue it finds.
Install
npm install --save-dev anti-pattern-sniffer
Requires Node.js 20 or later.
Your First Scan
Run npx aps in your project root. The tool auto-detects your framework from package.json and scans accordingly.
npx aps
Example output:
# Anti-Pattern Report
## src/components/UserProfile.tsx
### Prop Explosion (warning)
- Line 12: Component "UserProfile" accepts 11 props (threshold: 7)
> Consider grouping related props into an object or using composition.
## src/hooks/useDashboard.ts
### God Hook (warning)
- Line 1: Hook "useDashboard" uses 6 useState and 4 useEffect calls
> Split into smaller, focused hooks.
Found 2 issues across 2 files.
Setup Wizard
The init command walks you through creating a .snifferrc.json config file:
npx aps init
The wizard detects frameworks from your package.json, lets you enable or disable individual sniffers, and writes a config file to your project root.
Interactive Mode
Launch the TUI (terminal user interface) to browse results with keyboard navigation:
npx aps -i
Use arrow keys to move between issues, Enter to expand details, and q to quit.
CLI Reference
Usage
npx aps [options]
Flags
Flag | Description |
|---|---|
| Launch the interactive TUI viewer |
| Run only specific sniffers (comma-separated) |
| Override framework auto-detection |
| Scan a specific directory instead of project root |
| Suppress all output except errors |
| Output results as JSON |
| Limit interactive TUI to n issues at a time |
| Disable colored output |
| Run the setup wizard |
Exit Codes
Code | Meaning |
|---|---|
| No issues found |
| Issues found (warnings or errors) |
| Configuration or runtime error |
Examples
# Scan with default settings
npx aps
# Run only specific sniffers
npx aps -s prop-explosion,god-hook
# Run only the secret detection sniffer
npx aps -s hardcoded-secrets
# Output as JSON for CI parsing
npx aps --json
# Scan a specific directory
npx aps --dir ./src/api
# Interactive mode with batch limit
npx aps -i -b 20
Configuration
Create a .snifferrc.json file in your project root.
Minimal Config
{
"frameworks": ["react", "express"],
"include": ["**/*.{jsx,tsx}", "**/*.{js,ts}"],
"exclude": ["node_modules", "dist", "build"]
}
Full Options
Option | Type | Default | Description |
|---|---|---|---|
| string[] | Auto-detected | Frameworks to scan for |
| string[] |
| Glob patterns for files to scan |
| string[] |
| Glob patterns to skip |
| object | All enabled | Per-sniffer configuration |
| array |
| Custom plugin registrations |
Per-Sniffer Configuration
{
"sniffers": {
"prop-explosion": {
"threshold": 5,
"severity": "error"
},
"callback-hell": false,
"hardcoded-secrets": {
"severity": "warning"
}
}
}
Set any sniffer to false to disable it entirely. Any sniffer not listed uses its defaults.
Sniffers
All 14 Sniffers
Sniffer | Framework | Default Severity | Key Threshold |
|---|---|---|---|
| React | warning |
|
| React | warning |
|
| React | warning | Detects pass-through props |
| Express | warning |
|
| Express | warning | Checks for try-catch |
| Express | warning |
|
| Express | warning | Checks for validation library |
| Express | warning |
|
| Express | error | Pattern-based detection |
| NestJS | warning |
|
| NestJS | warning | Checks for type annotations |
| NestJS | warning |
|
| NestJS | warning | Checks sensitive routes |
| NestJS | warning |
|
React: prop-explosion
Detects components that receive too many props.
Why it matters: A component with many props is doing too much. It becomes hard to test, difficult to reuse, and painful to refactor.
// BAD — 10 props, gets flagged
function UserProfileCard({
firstName, lastName, email, phone, avatarUrl,
role, department, isActive, lastLoginDate, onEdit,
}) {
return <div className="card">...</div>;
}
// GOOD — grouped into objects
function UserProfileCard({ user, org, isActive, onEdit }) {
return (
<div className="card">
<UserAvatar user={user} />
<UserDetails user={user} org={org} />
{isActive && <ActiveBadge />}
<button onClick={onEdit}>Edit</button>
</div>
);
}
Option | Default | Description |
|---|---|---|
|
| Max props before warning |
|
| Severity level |
|
| Prop names to exclude from counting |
Smart bonuses: Components with a level or depth prop (tree/recursive nodes) get +4 to the threshold (effective: 11). Components with x, y, width, height props (SVG/positioning) get +3 (effective: 10).
React: god-hook
Detects custom hooks with too many useState, useEffect, or total hook calls.
Why it matters: A hook with 5+ useState calls and multiple useEffect blocks has grown beyond a single responsibility.
// BAD — 5 useState, 4 useEffect, 11 total hooks
function useUserDashboard(userId) {
const [profile, setProfile] = useState(null);
const [orders, setOrders] = useState([]);
const [notifications, setNotifications] = useState([]);
const [preferences, setPreferences] = useState({});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => { fetchProfile(userId).then(setProfile); }, [userId]);
useEffect(() => { fetchOrders(userId).then(setOrders); }, [userId]);
useEffect(() => { fetchNotifications(userId).then(setNotifications); }, [userId]);
useEffect(() => { fetchPreferences(userId).then(setPreferences); }, [userId]);
return { profile, orders, notifications, preferences, isLoading };
}
// GOOD — split into focused sub-hooks
function useUserProfile(userId) {
const [profile, setProfile] = useState(null);
useEffect(() => { fetchProfile(userId).then(setProfile); }, [userId]);
return profile;
}
function useUserOrders(userId) {
const [orders, setOrders] = useState([]);
useEffect(() => { fetchOrders(userId).then(setOrders); }, [userId]);
return useMemo(() => orders.filter(o => o.status === 'active'), [orders]);
}
function useUserDashboard(userId) {
const profile = useUserProfile(userId);
const orders = useUserOrders(userId);
return { profile, orders };
}
Option | Default | Description |
|---|---|---|
|
| Max useState calls |
|
| Max useEffect calls |
|
| Max total hook calls |
React: prop-drilling
Detects props that a component receives but only passes through to children without using them locally.
Smart exemptions:
on[A-Z]* event handlers (e.g. onFileOpen, onDeleteTask) are auto-whitelisted — they are designed for parent-to-child passing.
Multi-child composition detection: Components distributing props across 3+ distinct children are recognized as composition, not drilling. Components with 2 children are only flagged if one child receives all pass-through props.
Specialization wrappers (components providing 3+ default values) and list containers (components using .map() to render children) are automatically skipped.
Render-slot injection (props passed into render callbacks like components={{ a: (props) => ... }}) is not counted as drilling.
// BAD — OrderPage passes everything through
function OrderPage({ userId, cartItems, onCheckout }) {
return (
<div>
<h1>Your Order</h1>
<OrderSummary userId={userId} cartItems={cartItems} onCheckout={onCheckout} />
</div>
);
}
// GOOD — use React Context
const CartContext = createContext(null);
function OrderPage() {
return (
<div>
<h1>Your Order</h1>
<OrderSummary />
</div>
);
}
function OrderSummary() {
const { userId, cartItems, onCheckout } = useContext(CartContext);
return <div>{cartItems.length} items for user {userId}</div>;
}
Option | Default | Description |
|---|---|---|
|
| Min pass-through props to trigger |
|
| Props to always skip |
Express: god-routes
Detects route files that define too many route handlers in a single file.
// BAD — 11 routes in one file
router.get('/users', listUsers);
router.post('/users', createUser);
router.get('/users/:id', getUser);
router.put('/users/:id', updateUser);
router.delete('/users/:id', deleteUser);
router.get('/orders', listOrders);
router.post('/orders', createOrder);
router.get('/orders/:id', getOrder);
router.put('/orders/:id/status', updateOrderStatus);
router.get('/products', listProducts);
router.post('/products', createProduct);
// GOOD — split by domain
// users.routes.ts
usersRouter.get('/', listUsers);
usersRouter.post('/', createUser);
usersRouter.get('/:id', getUser);
// app.ts
app.use('/users', usersRouter);
app.use('/orders', ordersRouter);
app.use('/products', productsRouter);
Option | Default | Description |
|---|---|---|
|
| Max route handlers per file |
Express: fat-controllers
Detects route handler callbacks that are too long or perform too many await calls.
// BAD — 7 await calls, mixes data fetching + business logic + side effects
router.post('/orders', async (req, res) => {
const user = await User.findById(req.body.userId);
const items = await Product.find({ _id: { $in: req.body.itemIds } });
const inventory = await Inventory.checkAvailability(items);
const tax = await TaxService.calculate(subtotal, user.address);
const order = await Order.create({ userId: user.id, items, total });
await EmailService.sendConfirmation(user.email, order);
await AnalyticsService.trackPurchase(order);
res.status(201).json(order);
});
// GOOD — thin handler, service does the work
router.post('/orders', async (req, res, next) => {
try {
const order = await orderService.placeOrder(req.body.userId, req.body.itemIds);
res.status(201).json(order);
} catch (err) {
next(err);
}
});
Option | Default | Description |
|---|---|---|
|
| Max lines in handler body |
|
| Max await expressions |
Express: missing-error-handling
Detects async route handlers with no try-catch block and no centralized error middleware.
// BAD — unhandled promise rejection
router.post('/users', async (req, res) => {
const user = await UserService.create(req.body);
res.status(201).json(user);
});
// GOOD — error forwarded to middleware
router.post('/users', async (req, res, next) => {
try {
const user = await UserService.create(req.body);
res.status(201).json(user);
} catch (err) {
next(err);
}
});
Express: no-input-validation
Detects files that access req.body, req.params, or req.query without importing a validation library (zod, joi, yup, express-validator, celebrate, or class-validator).
// BAD — raw user input
router.post('/register', async (req, res) => {
const username = req.body.username;
await User.create({ username });
res.status(201).json({ message: 'Registered' });
});
// GOOD — validated with zod
import { z } from 'zod';
const registerSchema = z.object({
username: z.string().min(3).max(30),
email: z.string().email(),
});
router.post('/register', async (req, res) => {
const data = registerSchema.parse(req.body);
await User.create(data);
res.status(201).json({ message: 'Registered' });
});
Express: callback-hell
Detects deeply nested callback patterns exceeding a depth threshold.
Note: This sniffer automatically skips.tsxand.jsxfiles. React idioms (event handlers, streaming callbacks,.then()continuations) don't map to Express-style callback hell.
// BAD — nesting depth 5
app.get('/report', (req, res) => {
db.getUser(req.query.userId, (err, user) => {
db.getOrders(user.id, (err, orders) => {
db.getPayments(orders, (err, payments) => {
generateReport(user, orders, payments, (err, report) => {
res.json(report);
});
});
});
});
});
// GOOD — flat with async/await
app.get('/report', async (req, res, next) => {
try {
const user = await db.getUser(req.query.userId);
const orders = await db.getOrders(user.id);
const payments = await db.getPayments(orders);
const report = await generateReport(user, orders, payments);
res.json(report);
} catch (err) {
next(err);
}
});
Option | Default | Description |
|---|---|---|
|
| Max callback nesting depth |
Express: hardcoded-secrets
Detects hardcoded passwords, API keys, AWS access keys, and connection strings with embedded credentials. Default severity: error.
Catches three patterns:
Pattern | Example |
|---|---|
|
|
|
|
|
|
NestJS: god-service
Detects @Injectable() services with too many constructor dependencies or too many public methods.
Option | Default | Description |
|---|---|---|
|
| Max constructor dependencies |
|
| Max public methods |
NestJS: missing-dtos
Detects two problems: (1) @Body(), @Param(), or @Query() parameters without type annotations or typed as any, and (2) DTO classes without class-validator decorators.
// BAD — untyped @Body()
@Post()
async createUser(@Body() body) {
return this.userService.create(body);
}
// GOOD — typed with validated DTO
import { IsString, IsEmail, IsInt, Min } from 'class-validator';
class CreateUserDto {
@IsString() @IsNotEmpty() name: string;
@IsEmail() email: string;
@IsInt() @Min(13) age: number;
}
@Post()
async createUser(@Body() body: CreateUserDto) {
return this.userService.create(body);
}
NestJS: business-logic-in-controllers
Detects controller methods containing .map(), .filter(), .reduce(), for loops, or computational keywords. Controllers should only parse input, call a service, and return the response.
Option | Default | Description |
|---|---|---|
|
| Max lines in controller method |
NestJS: missing-guards
Detects route handlers on sensitive paths (admin, auth, user, password, settings, dashboard, token, secret) without @UseGuards() at the method or class level.
// BAD — admin route with no guard
@Controller('admin')
class AdminController {
@Get('dashboard')
async getDashboard() {
return this.adminService.getDashboardStats();
}
}
// GOOD — class-level guard
@UseGuards(AuthGuard, RolesGuard)
@Controller('admin')
class AdminController {
@Get('dashboard')
async getDashboard() {
return this.adminService.getDashboardStats();
}
}
NestJS: magic-strings
Detects string literals appearing 3+ times in conditional expressions (===, !==, case). Extract to constants or TypeScript enums.
// BAD — "pending" appears 4 times
if (order.status === 'pending') { /* ... */ }
if (order.status === 'pending') { /* ... */ }
case 'pending': /* ... */
if (order.status === 'pending') { /* ... */ }
// GOOD — use an enum
enum OrderStatus {
Pending = 'pending',
Shipped = 'shipped',
}
if (order.status === OrderStatus.Pending) { /* ... */ }
Option | Default | Description |
|---|---|---|
|
| Min times a string must appear |
|
| Strings to always skip |
Smart exemptions:
Strings used as case labels are exempt — they are discriminated values, not magic strings.
typeof comparisons (typeof x === 'string') are skipped — these are JavaScript language primitives.
Method-call return comparisons (.getIsSorted() === 'asc') are skipped — these are framework API contracts.
TypeScript union literal types (type Mode = 'a' | 'b') and interface property types (flowState: 'idle' | 'extracting') are extracted and exempt.
Interactive TUI
Launch with npx aps -i. The terminal UI provides:
Keybindings
Key | Action |
|---|---|
| Navigate between issues |
| Expand/collapse issue details |
| Copy issue as AI prompt |
| Add to .snifferignore |
| Filter by framework |
| Filter by severity |
| Quit |
Copy as AI Prompt
Press c on any issue to copy a formatted prompt to your clipboard. Paste it into ChatGPT, Claude, or any AI assistant and it will have full context: the file path, the anti-pattern detected, the code, and the suggestion.
Workspace & Monorepo Support
APS auto-detects workspace configurations:
Tool | Detection |
|---|---|
npm/yarn |
|
pnpm |
|
Turborepo |
|
Nx |
|
Lerna |
|
How it works
APS discovers all packages in the workspace
Each package gets its own framework detection (one package can be React, another Express)
Config in the root .snifferrc.json is inherited by all packages
Per-package .snifferrc.json overrides the root config
Just run npx aps from the workspace root — it figures out the rest.
Custom Plugins
Write your own sniffer in ~20 lines:
// plugins/no-console-log.js
module.exports = {
name: 'no-console-log',
description: 'Flags console.log statements in production code',
meta: {
name: 'no-console-log',
description: 'Flags console.log statements in production code',
category: 'custom',
severity: 'warning',
defaultConfig: { enabled: true, severity: 'warning' },
},
detect(fileContent, filePath, config) {
const detections = [];
const lines = fileContent.split('\n');
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/console\.log\s*\(/);
if (match) {
detections.push({
snifferName: 'no-console-log',
filePath,
line: i + 1,
column: match.index + 1,
message: 'console.log found. Remove before shipping.',
severity: config.severity || 'warning',
suggestion: 'Use a proper logger (winston, pino) or remove it.',
details: { lineContent: lines[i].trim() },
});
}
}
return detections;
},
};
Register in .snifferrc.json
{
"plugins": [
{ "path": "./plugins/no-console-log.js" }
]
}
Validation Stages
Security scan — checks for dangerous patterns (eval, child_process, network access)
Schema check — verifies required exports (name, description, meta, detect)
Meta schema check — validates the meta object fields
Signature check — ensures detect accepts at least 2 parameters
Smoke test — calls detect with empty input to verify it returns an array
CI/CD Integration
Husky Pre-Commit Hook
npx husky add .husky/pre-commit "npx aps -q"
The -q (quiet) flag suppresses all output except errors. The process exits with code 1 if any issues are found, blocking the commit.
GitHub Actions
name: Anti-Pattern Check
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx aps --json > aps-report.json
- name: Check for issues
run: |
if [ $(cat aps-report.json | jq '.totalIssues') -gt 0 ]; then
echo "Anti-patterns detected!"
cat aps-report.json | jq '.issues[]'
exit 1
fi
JSON Output
npx aps --json
Returns structured JSON for programmatic consumption — pipe to jq, parse in CI scripts, or feed to dashboards.
.snifferignore
Create a .snifferignore file to suppress specific detections. Same syntax as .gitignore:
# Ignore all files in generated directory
generated/
# Ignore specific file
src/legacy/old-component.tsx
# Ignore by pattern
**/*.test.ts
**/*.spec.ts
You can also generate ignore entries from the interactive TUI by pressing i on any issue.