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 |
|---|---|
-i, --interactive | Launch the interactive TUI viewer |
-s, --sniffers <names> | Run only specific sniffers (comma-separated) |
-f, --framework <name> | Override framework auto-detection |
--dir <path> | Scan a specific directory instead of project root |
-q, --quiet | Suppress all output except errors |
--json | Output results as JSON |
-b, --batch <n> | Limit interactive TUI to n issues at a time |
--no-color | Disable colored output |
init | Run the setup wizard |
Exit Codes
| Code | Meaning |
|---|---|
0 | No issues found |
1 | Issues found (warnings or errors) |
2 | 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 |
|---|---|---|---|
frameworks | string[] | Auto-detected | Frameworks to scan for |
include | string[] | ["**/*.{js,jsx,ts,tsx}"] | Glob patterns for files to scan |
exclude | string[] | ["node_modules", "dist", "build"] | Glob patterns to skip |
sniffers | object | All enabled | Per-sniffer configuration |
plugins | 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 |
|---|---|---|---|
prop-explosion | React | warning | threshold: 7 props |
god-hook | React | warning | maxUseState: 4, maxUseEffect: 3 |
prop-drilling | React | warning | Detects pass-through props |
god-routes | Express | warning | maxRoutes: 10 |
missing-error-handling | Express | warning | Checks for try-catch |
fat-controllers | Express | warning | maxLines: 50, maxAwaits: 3 |
no-input-validation | Express | warning | Checks for validation library |
callback-hell | Express | warning | maxDepth: 3 |
hardcoded-secrets | Express | error | Pattern-based detection |
god-service | NestJS | warning | maxDependencies: 8 |
missing-dtos | NestJS | warning | Checks for type annotations |
business-logic-in-controllers | NestJS | warning | maxMethodLines: 50 |
missing-guards | NestJS | warning | Checks sensitive routes |
magic-strings | NestJS | warning | minOccurrences: 3 |
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 |
|---|---|---|
threshold | 7 | Max props before warning |
severity | "warning" | Severity level |
ignoredProps | [] | 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 |
|---|---|---|
maxUseState | 4 | Max useState calls |
maxUseEffect | 3 | Max useEffect calls |
maxTotalHooks | 10 | 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 |
|---|---|---|
minPassThroughProps | 4 | Min pass-through props to trigger |
whitelist | ["onChange", "onClick", "onSubmit", "onClose", "onOpenChange", "isOpen", "isLoading", "disabled", "loading", "open", "visible"] | 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 |
|---|---|---|
maxRoutes | 10 | 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 |
|---|---|---|
maxLines | 50 | Max lines in handler body |
maxAwaits | 3 | 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 |
|---|---|---|
maxDepth | 3 | 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 |
|---|---|
HARDCODED_SECRET | password = 'secret123' |
AWS_ACCESS_KEY | AKIAIOSFODNN7EXAMPLE |
CONNECTION_STRING_WITH_CREDS | mongodb://admin:pass@host |
NestJS: god-service
Detects @Injectable() services with too many constructor dependencies or too many public methods.
| Option | Default | Description |
|---|---|---|
maxDependencies | 8 | Max constructor dependencies |
maxPublicMethods | 15 | 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 |
|---|---|---|
maxMethodLines | 50 | 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 |
|---|---|---|
minOccurrences | 3 | Min times a string must appear |
ignoredStrings | ["all", "none"] | Strings to always skip |
Smart exemptions:
- Strings used as
caselabels are exempt — they are discriminated values, not magic strings. typeofcomparisons (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 |
|---|---|
↑/↓ or j/k | Navigate between issues |
Enter | Expand/collapse issue details |
c | Copy issue as AI prompt |
i | Add to .snifferignore |
f | Filter by framework |
s | Filter by severity |
q | 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 | workspaces field in root package.json |
| pnpm | pnpm-workspace.yaml |
| Turborepo | turbo.json |
| Nx | nx.json |
| Lerna | lerna.json |
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.jsonis inherited by all packages - Per-package
.snifferrc.jsonoverrides 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
metaobject fields - Signature check — ensures
detectaccepts at least 2 parameters - Smoke test — calls
detectwith 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.