← BACK TO ANTI-PATTERN SNIFFER

Documentation

Everything you need to install, configure, and use Anti-Pattern Sniffer.


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

FlagDescription
-i, --interactiveLaunch 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, --quietSuppress all output except errors
--jsonOutput results as JSON
-b, --batch <n>Limit interactive TUI to n issues at a time
--no-colorDisable colored output
initRun the setup wizard

Exit Codes

CodeMeaning
0No issues found
1Issues found (warnings or errors)
2Configuration 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

OptionTypeDefaultDescription
frameworksstring[]Auto-detectedFrameworks to scan for
includestring[]["**/*.{js,jsx,ts,tsx}"]Glob patterns for files to scan
excludestring[]["node_modules", "dist", "build"]Glob patterns to skip
sniffersobjectAll enabledPer-sniffer configuration
pluginsarray[]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

SnifferFrameworkDefault SeverityKey Threshold
prop-explosionReactwarningthreshold: 7 props
god-hookReactwarningmaxUseState: 4, maxUseEffect: 3
prop-drillingReactwarningDetects pass-through props
god-routesExpresswarningmaxRoutes: 10
missing-error-handlingExpresswarningChecks for try-catch
fat-controllersExpresswarningmaxLines: 50, maxAwaits: 3
no-input-validationExpresswarningChecks for validation library
callback-hellExpresswarningmaxDepth: 3
hardcoded-secretsExpresserrorPattern-based detection
god-serviceNestJSwarningmaxDependencies: 8
missing-dtosNestJSwarningChecks for type annotations
business-logic-in-controllersNestJSwarningmaxMethodLines: 50
missing-guardsNestJSwarningChecks sensitive routes
magic-stringsNestJSwarningminOccurrences: 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>
  );
}
OptionDefaultDescription
threshold7Max 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 };
}
OptionDefaultDescription
maxUseState4Max useState calls
maxUseEffect3Max useEffect calls
maxTotalHooks10Max 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>;
}
OptionDefaultDescription
minPassThroughProps4Min 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);
OptionDefaultDescription
maxRoutes10Max 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);
  }
});
OptionDefaultDescription
maxLines50Max lines in handler body
maxAwaits3Max 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);
  }
});
OptionDefaultDescription
maxDepth3Max 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:

PatternExample
HARDCODED_SECRETpassword = 'secret123'
AWS_ACCESS_KEYAKIAIOSFODNN7EXAMPLE
CONNECTION_STRING_WITH_CREDSmongodb://admin:pass@host

NestJS: god-service

Detects @Injectable() services with too many constructor dependencies or too many public methods.

OptionDefaultDescription
maxDependencies8Max constructor dependencies
maxPublicMethods15Max 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.

OptionDefaultDescription
maxMethodLines50Max 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) { /* ... */ }
OptionDefaultDescription
minOccurrences3Min 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

KeyAction
↑/↓ or j/kNavigate between issues
EnterExpand/collapse issue details
cCopy issue as AI prompt
iAdd to .snifferignore
fFilter by framework
sFilter by severity
qQuit

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:

ToolDetection
npm/yarnworkspaces field in root package.json
pnpmpnpm-workspace.yaml
Turborepoturbo.json
Nxnx.json
Lernalerna.json

How it works

  1. APS discovers all packages in the workspace
  2. Each package gets its own framework detection (one package can be React, another Express)
  3. Config in the root .snifferrc.json is inherited by all packages
  4. 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

  1. Security scan — checks for dangerous patterns (eval, child_process, network access)
  2. Schema check — verifies required exports (name, description, meta, detect)
  3. Meta schema check — validates the meta object fields
  4. Signature check — ensures detect accepts at least 2 parameters
  5. 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.