Anti-Pattern SnifferProducts

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

-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 .tsx and .jsx files. 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 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

↑/↓ 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.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.

ii