---
name: anti-patterns
description: Use when adding, modifying, or debugging an anti-pattern detection rule in this repo. Walks through the TDD recipe, the rule schema, all five plug-in points, jsdom constraints, the cross-validation step against the impeccable skill, and the post-implementation checklist. Trigger this for any work touching src/detect-antipatterns.mjs, tests/fixtures/antipatterns/, or extension/detector/.
tools: Read, Edit, Write, Glob, Grep, Bash, mcp__claude-in-chrome__navigate, mcp__claude-in-chrome__javascript_tool, mcp__claude-in-chrome__tabs_context_mcp, mcp__claude-in-chrome__tabs_create_mcp
---

# Anti-Pattern Engine Maintenance

This agent handles every step of adding or modifying an anti-pattern detection rule in the impeccable repo. The rule engine is wired into many places and a single source-of-truth design ties them together. Skip any step at your peril — the build's cross-validator will fail loudly if drift slips in.

## The five things that need to stay in sync

When you add a rule, all of these update or get regenerated:

| Where | What | How it stays in sync |
|---|---|---|
| `src/detect-antipatterns.mjs` `ANTIPATTERNS` | Rule metadata (id, category, name, description, skillSection, skillGuideline) and the detection logic (`checkXxx`) | **Hand-edited.** Source of truth. |
| `src/detect-antipatterns-browser.js` | Browser-bundled engine for the public site overlay | Generated by `bun run build:browser` |
| `extension/detector/detect.js` | Browser-bundled engine for the Chrome extension | Generated by `bun run build:extension` |
| `extension/detector/antipatterns.json` | Rule list (id, name, category, description) for the extension's devtools panel — drives rule toggles UI | Generated by `bun run build:extension` |
| `public/js/generated/counts.js` | `DETECTION_COUNT` integer for homepage display | Generated by `bun run build` |
| `source/skills/impeccable/SKILL.md` | Per-rule **DON'T** line in the right `### Section` — taught to users via the impeccable skill | **Hand-edited.** Validator catches drift. |

The CLI (`bin/cli.js`) imports `ANTIPATTERNS` directly from `src/detect-antipatterns.mjs` — no separate sync needed.

## Rule schema

Each entry in the `ANTIPATTERNS` array (around src/detect-antipatterns.mjs:77) looks like this:

```js
{
  id: 'icon-tile-stack',                          // kebab-case, unique, stable
  category: 'slop',                                // 'slop' or 'quality' (see below)
  name: 'Icon tile stacked above heading',        // human-readable, used in extension UI
  description:                                     // 1–2 sentences. Used in CLI output, extension tooltips, web overlay labels
    'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
  skillSection: 'Typography',                      // OPTIONAL but strongly recommended. Must be one of the parser's allowed sections (see below)
  skillGuideline: 'large icons with rounded corners above every heading',  // OPTIONAL but strongly recommended. Substring that must appear in some **DON'T**: line of the named section in source/skills/impeccable/SKILL.md
}
```

### Categories

- **`slop`** = "AI tells". Patterns that scream *AI generated this*. Things like purple gradients, gradient text, dark glow accents, thick side borders, icon-tile-stacks. Flagging these is about taste and freshness, not correctness.
- **`quality`** = real design or accessibility issues regardless of who wrote the code. WCAG contrast, line length, padding, line height, justified text, skipped headings, etc.

If you're not sure, ask: *"would a human designer who's careful and tasteful still ship this?"* If no, it's `quality`. If they would (because it works fine, it just looks templated), it's `slop`.

### `skillSection` allowed values

These are the section names the `readPatterns()` parser at scripts/lib/utils.js:221 understands. Use **exactly** these strings (note the parser normalizes `Color & Theme` → `Color & Contrast`):

```
Typography
Color & Contrast
Layout & Space
Visual Details
Motion
Interaction
Responsive
UX Writing
```

### `skillGuideline` substring

A 3–6 word substring that appears verbatim in some `**DON'T**:` line of the named section in `source/skills/impeccable/SKILL.md`. The build validator checks this with `String.includes()`. Pick a substring that's:

- Short enough that benign rewordings of the DON'T won't break it
- Specific enough that it can't accidentally collide with an unrelated DON'T

Examples: `'AI color palette'`, `'large icons with rounded corners above every heading'`, `'WCAG AA contrast'`.

If a rule genuinely doesn't deserve a skill DON'T (rare — only the most niche a11y-only rules), omit both `skillSection` and `skillGuideline`. The validator skips rules without `skillGuideline`.

## The TDD recipe (always do it in this order)

This order is non-negotiable. Fixture and failing test before implementation. The full suite must run between the rule going in and you committing.

### 1. Write the fixture (two-column convention)

A single HTML file at `tests/fixtures/antipatterns/{rule-id}.html` with two columns: left = should-flag, right = should-pass. Each test case carries a unique heading text so the test can match snippets back to expectations.

Convention skeleton:

```html
<!DOCTYPE html>
<html>
<head>
  <style>
    .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; max-width: 960px; margin: 0 auto; padding: 24px; }
    .col h2 { font-size: 14px; text-transform: uppercase; }
    /* ... per-case styles with EXPLICIT pixel dimensions (jsdom can't lay out) ... */
  </style>
</head>
<body>
  <div class="grid">
    <div class="col" data-col="flag">
      <h2>Should flag</h2>
      <!-- 4–6 cases that should be flagged, each with a unique <h3> text -->
    </div>
    <div class="col" data-col="pass">
      <h2>Should pass</h2>
      <!-- 5–8 cases that should NOT be flagged: cover every false-positive shape you can think of -->
    </div>
  </div>
  <script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
```

The script tag at the bottom is critical — it lets you load the fixture in the browser via `http://localhost:3000/fixtures/antipatterns/{rule-id}.html` (served by `server/index.js:62` route for `/fixtures/*`).

**Should-pass cases must cover the false-positive shapes you can think of in advance.** A good fixture has 5+ pass cases. The icon-tile-stack fixture covers: round avatar, wide thumbnail, side-by-side, no-icon, too-tiny, too-huge.

### 2. Write the failing test

Add to `tests/detect-antipatterns-fixtures.test.mjs` in its own `describe` block. Use the snippet-substring matching pattern — the test parses heading text out of each finding's snippet and asserts membership against expected lists:

```js
describe('detectHtml — {rule-id}', () => {
  const SHOULD_FLAG = ['Heading One', 'Heading Two', /* ... */];
  const SHOULD_PASS = ['Pass Heading One', /* ... */];

  it('{rule-id}: flags only the should-flag column', async () => {
    const f = await detectHtml(path.join(FIXTURES, '{rule-id}.html'));
    const flagged = new Set();
    for (const r of f) {
      if (r.antipattern !== '{rule-id}') continue;
      const m = (r.snippet || '').match(/"([^"]+)"/);
      if (m) flagged.add(m[1]);
    }
    for (const text of SHOULD_FLAG) {
      assert.ok(flagged.has(text), `expected "${text}" to be flagged`);
    }
    for (const text of SHOULD_PASS) {
      assert.ok(!flagged.has(text), `"${text}" should NOT be flagged`);
    }
  });
});
```

For this to work, the rule's snippet **must include the heading text in quotes**. See "Snippet conventions" below.

Run `node --test tests/detect-antipatterns-fixtures.test.mjs` and **watch it fail**. If it doesn't fail, your test is wrong.

### 3. Add the rule definition

Add a new entry to the `ANTIPATTERNS` array in `src/detect-antipatterns.mjs`. Place it in the right category section (slop or quality). Fill in all fields including `skillSection` and `skillGuideline`.

### 4. Implement the pure check function

Add a `checkXxx(opts)` function alongside the others (`checkColors`, `checkBorders`, `checkMotion`, `checkGlow`, `checkIconTile`, etc.). The pure function takes a plain options object — no DOM access — and returns an array of `{ id, snippet }`. This makes it testable and reusable across the browser/Node adapters.

Example shape (see `checkIconTile` in src/detect-antipatterns.mjs for a real one):

```js
function checkXxx(opts) {
  const { tag, /* whatever fields the rule needs */ } = opts;
  if (SAFE_TAGS.has(tag)) return [];
  // ... your detection logic ...
  if (matches) {
    return [{ id: 'rule-id', snippet: `... "${headingText}"` }];
  }
  return [];
}
```

### 5. Add the two adapters

Two adapters wrap the pure function with environment-specific input gathering:

- **`checkElementXxxDOM(el)`** — for the browser. Uses `getComputedStyle(el)` and `el.getBoundingClientRect()`.
- **`checkElementXxx(el, tag, window)`** — for jsdom (Node). Uses `window.getComputedStyle(el)` and **must read explicit pixel dimensions from `parseFloat(style.width)`** instead of bounding rects, because **jsdom does not lay out** — `getBoundingClientRect()` returns 0×0 for everything.

If your rule needs vertical positioning info (e.g. "icon must be above heading"), that check is browser-only — gate it behind `if (headingTop && siblingBottom)` so the Node path skips it. The structural checks alone (sizes, sibling identity, classes) are enough for the fixture.

### 6. Wire into both element-iteration loops

Two loops iterate every element on the page. You need to add your DOM-adapter call to **both**:

- **Browser loop** at src/detect-antipatterns.mjs:1837 (`for (const el of document.querySelectorAll('*'))` with the `findings` spread). Add a line like:
  ```js
  ...checkElementXxxDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
  ```
- **Node (jsdom) loop** at src/detect-antipatterns.mjs:2058 (in `detectHtml`). Add a block like:
  ```js
  for (const f of checkElementXxx(el, tag, window)) {
    findings.push(finding(f.id, filePath, f.snippet));
  }
  ```

Forgetting one of these is the most common mistake — the test passes but the live page doesn't show anything (or vice versa).

### 7. Add the SKILL.md DON'T line if the rule doesn't reuse an existing one

Open `source/skills/impeccable/SKILL.md`, find the right `### Section`, and add a **DON'T** line that contains your `skillGuideline` substring verbatim. Match the style of existing DON'Ts (terse, prescriptive, 1–2 sentences max).

If your rule reuses an existing DON'T (e.g. multiple engine rules can map to the same skill guidance, like `side-tab` and `border-accent-on-rounded` both pointing to `'thick colored border on one side'`), no skill edit is needed.

### 8. Run the build (this regenerates everything and validates)

```bash
bun run build && bun run build:browser && bun run build:extension
```

This regenerates:
- `src/detect-antipatterns-browser.js` (public-site detector)
- `extension/detector/detect.js` (extension detector)
- `extension/detector/antipatterns.json` (extension rule list, includes description)
- `public/js/generated/counts.js` (DETECTION_COUNT)

And validates:
- Cross-checks every rule with `skillGuideline` against `source/skills/impeccable/SKILL.md` via `validateAntipatternRules()` in scripts/build.js. **Build fails if drift exists.**

### 9. Run the test suite

```bash
bun run test
```

166 unit tests + N fixture tests, including your new one. All should be green.

### 10. Verify on a live page in the browser

Don't skip this. The jsdom path uses `parseFloat(style.width)` and the browser path uses `getBoundingClientRect()` — they can disagree. The fixture test catches one path; manual browser verification catches the other.

```
http://localhost:3000/fixtures/antipatterns/{rule-id}.html
http://localhost:3000/antipattern-examples/{your-example}.html  (if relevant)
http://localhost:3000/                                           (no false positives on real pages)
```

Use the chrome MCP tools (`mcp__claude-in-chrome__navigate` + `mcp__claude-in-chrome__javascript_tool`) to inject `window.impeccableScan()` and read `.impeccable-overlay` / `.impeccable-label` from the DOM to verify. Don't try to screenshot — the overlays are decorative; read them programmatically.

## Snippet conventions

The fixture-test convention extracts the heading text from a finding's snippet using regex `/"([^"]+)"/` — so **wrap the identifying heading text in straight double quotes** in your snippet. Examples:

- `'80x80px icon tile above h3 "Lightning Fast"'`
- `'4.5:1 (need 4.5:1) — text #808080 on #3b82f6'`  ← uses element identifiers instead, since this rule isn't anchored to a heading

If your rule isn't naturally anchored to a heading, pick another stable identifier (a class name, the parent element's text, etc.) and document the test pattern in the test itself.

## jsdom constraints (the most common gotcha)

- **No layout.** `getBoundingClientRect()` returns `0×0` always. Read `parseFloat(style.width)` and `parseFloat(style.height)` instead — jsdom does honor explicit pixel widths in `<style>` and inline styles.
- **`background:` shorthand isn't decomposed.** `style.backgroundColor` and `style.backgroundImage` may be empty even when `style="background: ..."` is set. The existing `resolveBackground()` and `resolveGradientStops()` helpers (src/detect-antipatterns.mjs:631 and src/detect-antipatterns.mjs:670) handle this — use them.
- **Computed colors are normalized in real browsers, not in jsdom.** A browser returns `rgb(59, 130, 246)`; jsdom may return the original hex. The `parseGradientColors()` helper handles both.
- **No SAFE_TAGS skipping for parent walks.** When walking ancestors, you don't get the `SAFE_TAGS` filter the main loop applies — be explicit.

## Where to find concrete example rules to learn from

- Simplest border check: **`side-tab`** — `checkBorders()` at src/detect-antipatterns.mjs:312
- Color/contrast with gradient handling: **`low-contrast`** — `checkColors()` at src/detect-antipatterns.mjs:339
- Element-relationship check (siblings): **`icon-tile-stack`** — `checkIconTile()` at src/detect-antipatterns.mjs:425
- Page-level / cross-element: **`flat-type-hierarchy`** — `checkPageTypography()` at src/detect-antipatterns.mjs:1080
- Motion/animation: **`bounce-easing`** — `checkMotion()` at src/detect-antipatterns.mjs:425

## Pre-commit checklist

Before you commit a new rule, all of these MUST be true. The first three are non-negotiable — if any is missing, the engine, extension, public site, and skill will silently drift apart.

- [ ] Test passes: `bun run test` is green
- [ ] Build passes: `bun run build && bun run build:browser && bun run build:extension` is green (validator says `✓ Validated N/N anti-pattern rules`)
- [ ] Live verification: rule fires on a real page and produces zero false positives on the homepage `http://localhost:3000/`
- [ ] Both element loops were updated (browser DOM at line ~1846 + Node jsdom at line ~2058)
- [ ] Rule has a corresponding SKILL.md DON'T (or explicitly omitted `skillGuideline`)
- [ ] Snippet format matches the test's extraction regex
- [ ] Fixture covers ≥4 should-flag and ≥5 should-pass cases
- [ ] Commit only the relevant files — `git status` will show many unrelated stale skill builds; do not stage them

## Things that have bitten previous sessions

- **Forgot to run `bun run build:extension`** — extension JSON went stale, missing the new rule. Symptom: extension panel doesn't show toggle for new rule. Fix: always run all three build commands.
- **Forgot to update both loops** — test passed in jsdom but live browser was silent (or vice versa). Fix: grep for an existing rule's adapter call and copy its placement.
- **Used a `skillGuideline` substring that doesn't appear in SKILL.md** — validator fails. Fix: the substring must appear verbatim in some `**DON'T**:` line of the named section.
- **Used the wrong `skillSection` name** — `Color & Theme` vs `Color & Contrast` (parser normalizes the former to the latter, so use `Color & Contrast`).
- **Wrote the fixture without explicit pixel dimensions** — jsdom returned 0×0 and the rule never matched. Fix: always set `width: Npx; height: Npx` in CSS for fixture elements, or use inline style attributes.
