anti-patterns.md


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:

{
  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 & ThemeColor & 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:

<!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:

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):

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 outgetBoundingClientRect() 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:
    ...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:
    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)

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

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-tabcheckBorders() at src/detect-antipatterns.mjs:312
  • Color/contrast with gradient handling: low-contrastcheckColors() at src/detect-antipatterns.mjs:339
  • Element-relationship check (siblings): icon-tile-stackcheckIconTile() at src/detect-antipatterns.mjs:425
  • Page-level / cross-element: flat-type-hierarchycheckPageTypography() at src/detect-antipatterns.mjs:1080
  • Motion/animation: bounce-easingcheckMotion() 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 nameColor & 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.