Skip to content

Linter Rules

Laith0003 edited this page May 28, 2026 · 1 revision

Linter Rules — the 100-rule anti-AI-slop regex catalog

engine/linter/core.py is a regex linter, not an LLM judge. 100 rules, 9 categories, four severities, runs in well under 50ms on a typical surface. Same input, same output. Deterministic. CI-friendly.

This page explains how it works, the severity model, the 9 categories, ten worked examples, the CI integration, and the JSON shape for adding a new rule. For the full catalog of every rule with its name, severity, and category, see Anti-Pattern Catalog 100. For the broader engine, see Architecture.


How it works

The linter loads data/anti-patterns.json at startup, compiles each rule's regex with its declared flags, walks the target paths, and applies every rule whose scope matches the file's extension.

def _compile_rules() -> List[Dict[str, Any]]:
    data = load("anti-patterns")
    rules = []
    for entry in data.get("entries", []):
        det = entry.get("detection", {})
        if det.get("type") != "regex":
            continue
        flags = 0
        if "i" in det.get("flags", ""): flags |= re.IGNORECASE
        if "m" in det.get("flags", ""): flags |= re.MULTILINE
        if "s" in det.get("flags", ""): flags |= re.DOTALL
        compiled = re.compile(det["pattern"], flags)
        rules.append({...})
    return rules

Pure Python re. No subprocess, no npm, no AST walker, no LLM. The cost of a 100-rule pass on a 5,000-line codebase is dominated by file I/O — the regex execution itself is microseconds per rule.

For each match the linter records:

@dataclass
class Finding:
    rule_id: str
    rule_name: str
    severity: str
    category: str
    file: str
    line: int
    column: int
    excerpt: str       # first 200 chars of the offending line
    fix: str           # the rule's prescribed fix

The output is JSON-serializable (LintReport.to_dict()), pipe-friendly, and consumed directly by ux fix to autocorrect findings inline.


Severity model

Four levels, ranked:

SEVERITY_RANK = {"low": 0, "medium": 1, "high": 2, "critical": 3}

The --threshold flag sets the lowest severity that gates CI. Anything at or above the threshold causes a non-zero exit code; anything below is still reported, just not fatal.

Severity Count When it fires CI behavior
critical 4 Accessibility-blocking or a11y-illegal — e.g. blocked pinch-zoom, aria-hidden on a focusable interactive element, click handler on a <div> with no role or tabindex Fails CI at any threshold including --threshold critical
high 33 Strong AI-slop fingerprint OR meaningful accessibility regression — Inter as display, purple-to-blue gradient, three equal cards, image without alt, anchor styled as button without href Fails CI at --threshold high (the default)
medium 42 Common bad habit, fixable easily — placeholder name "John Doe", font-weight: 700 on display, transition: all, inline style= attribute Fails CI at --threshold medium
low 21 Cosmetic or style preference — rounded-full everywhere, default 300ms transition, TODO comment in shipping code, card-1 / feature-2 numbered class names Fails CI only at --threshold low

Default threshold is high. Most teams run ux lint --threshold high in CI and ux lint --threshold low locally during polish.

Exit codes:

  • 0 — no findings at or above threshold (CI green).
  • 1 — at least one finding at or above threshold (CI red).

Findings below threshold are reported in JSON but don't affect the exit code.


The 9 categories

Every rule is tagged with a category. These map roughly to the bug surface:

Category Count What it catches
A11y 23 WCAG 2.2 AA fingerprints — focus rings removed without :focus-visible, missing alt text, missing lang, aria-hidden on interactive elements, hover-only card actions
Content 15 Placeholder text and AI-generated copy — "John Doe", "Lorem ipsum", emoji in UI, "Elevate / Seamless / Unleash", 99.99%, "Click here" CTAs
Layout 13 Visual fingerprints — three equal cards, centered hero, hamburger on desktop, rounded-full everywhere, display: table for layout
Typography 10 Type fingerprints — Inter as display, system-font-only stack, arbitrary 90px, Title Case On Display, font-weight: 700+ on display
Color 9 Color fingerprints — purple-to-blue gradient, multi-stop rainbow gradient on text, purple glow shadow, low-contrast text on card
Visual 9 Surface fingerprints — backdrop-blur frosted glass everywhere, repeating noise overlay, lone emoji as functional icon, five+ stacked box-shadows
Quality 9 Code hygiene — inline style=, console.log leak, any type leak, lazy z-index: 9999, TODO/FIXME in shipping code
Motion 8 Animation fingerprints — transition: all, 300ms default everywhere, scale(1.1) on card hover, bouncing arrow CTA
Performance 4 CWV fingerprints — <img> without width/height, <img src="*.jpg"> without <picture> AVIF/WebP source, scroll listener without passive: true

Counts add to 100. The breakdown reflects where AI-generated UI actually fails: accessibility first (23%), content discipline second (15%), layout / typography / color taste-failure third (32% combined).


Ten worked examples

Each rule in data/anti-patterns.json is a self-contained JSON object: id, name, severity, category, detection (with pattern, flags, scope), why, fix. Ten illustrative rules:

inter-as-display — Typography, high

Pattern:

font-family:\s*['"]?Inter['"]?[^;}]*[;}][^{]*(?:font-size:\s*([4-9]\d|\d{3,})px|\btext-(5xl|6xl|7xl|8xl|9xl)\b)

Why: Inter is a body font tuned for screen legibility at small sizes; deployed as display it reads as the default startup-landing fingerprint.

Fix: Pair Inter (body) with a distinctive display face: Geist, Satoshi, Cabinet Grotesk, General Sans, Outfit, or a brand-specific variable sans.

purple-to-blue-gradient — Color, high

Pattern:

bg-gradient-to-[a-z]+\s+from-(purple|violet|fuchsia|indigo)-(400|500|600)\s+(?:via-[a-z]+-[0-9]+\s+)?to-(blue|sky|cyan)-(400|500|600)

Why: Purple-to-blue gradient on white is the strongest visual fingerprint of unconstrained model output and reads as template-marketplace AI slop.

Fix: Use a single restrained accent (Emerald, Electric Blue, Deep Rose, Amber) against neutrals; keep gradient hue spread under 60 degrees if used at all.

three-equal-card-grid — Layout, high

Pattern:

(?:grid-cols-3|grid-template-columns:\s*repeat\(\s*3\s*,\s*(?:1fr|minmax)).*?>(?:\s*<[^/][^>]*class="[^"]*(?:card|feature)[^"]*"[^>]*>.*?</[^>]+>\s*){3}

Why: Three equal cards with three icons and three short paragraphs is the safest default the generator reaches for and the strongest layout fingerprint in AI-generated marketing surfaces.

Fix: Use asymmetric layouts: bento grids, 2-and-1 splits, 4 with one spanning width. Vary card size by content priority.

fake-name-john-doe — Content, medium

Pattern:

\b(?:John\s+Doe|Jane\s+Doe|John\s+Smith|Jane\s+Smith|Sarah\s+Chan|Test\s+User|Demo\s+User|Foo\s+Bar)\b

Why: John/Jane Doe and their cousins signal that nobody thought about who would actually use the product — immediate AI-generated-tutorial vibe.

Fix: Use plausible names that fit the target market: Maya Iqbal, Adam Levin, Wen Zhang, Layla Haddad.

viewport-no-zoom — A11y, critical

Pattern: Detects <meta name="viewport" ... user-scalable=no> or maximum-scale=1.

Why: Blocking pinch-zoom is a WCAG 2.2 AA failure (1.4.4 Resize Text). Low-vision users need to zoom.

Fix: Remove user-scalable=no and any maximum-scale cap. <meta name="viewport" content="width=device-width, initial-scale=1"> is enough.

outline-none-no-focus-visible — A11y, critical

Pattern: outline:\s*(?:none|0) not followed by a :focus-visible rule in the same file.

Why: Removing the focus ring without providing a :focus-visible replacement is a keyboard-trap and a WCAG 2.4.7 failure.

Fix: If you must remove the default outline, restore one for keyboard users: *:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }.

div-onclick-no-role — A11y, critical

Pattern: <div [^>]*onClick= without role= and without tabIndex=.

Why: A <div> with a click handler is invisible to screen readers and unreachable by keyboard. WCAG 4.1.2 failure.

Fix: Use a <button>. If you genuinely cannot, add role="button" AND tabIndex={0} AND a keyDown handler for Enter and Space.

emoji-in-ui — Content, high

Pattern: Matches Unicode emoji ranges (U+1F300U+1FAFF, etc.) inside JSX, Vue templates, Blade, or HTML.

Why: Emoji as UI signals AI-template output and breaks at the screen-reader layer. They also bloat the chrome and clash with brand restraint.

Fix: Use inline SVG (Lucide, Phosphor, Feather, Heroicons) at 1.5–2px stroke, currentColor for fills.

transition-property-all — Motion, medium

Pattern: transition:\s*all\b or transitionProperty:\s*["']all["'].

Why: transition: all is the lazy property list — it animates layout properties (width, height, margin) which causes jank, and it's the default AI-generated motion fingerprint.

Fix: List the properties you actually want: transition: opacity 200ms ease-out, transform 200ms ease-out;.

img-no-dimensions — Performance, high

Pattern: <img\s+(?:(?!width\b)(?!height\b)[^>])*>.

Why: Missing width and height attributes cause Cumulative Layout Shift (CLS), one of the three Core Web Vitals. Lighthouse penalizes it directly.

Fix: Always set explicit width and height attributes on <img>. Use CSS aspect-ratio if the image scales fluidly.


CI integration

The linter is the only piece of the engine that lives in your CI pipeline. The recommender is a design-time tool; the linter is a build-time gate.

Standalone command

pip install uxskill
ux lint ./src --threshold high
echo $?    # 0 = clean, 1 = fail

The --threshold flag is the lever. high is the default and the right value for most teams. critical lets through high-severity findings (use this only when migrating an existing codebase). medium and low are stricter — useful in design-system repos or pre-1.0 products that want to keep the bar absolute.

GitHub Actions snippet

name: ux-lint
on:
  pull_request:
  push:
    branches: [main]

jobs:
  ux-lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install uxskill
      - run: ux lint ./src --threshold high

That's it. No config file, no plugin install, no token. The linter exits non-zero on any high+ finding and GitHub fails the check.

GitLab CI snippet

ux-lint:
  image: python:3.11
  script:
    - pip install uxskill
    - ux lint ./src --threshold high
  only:
    - merge_requests
    - main

Pre-commit hook

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: ux-lint
        name: ux-lint
        entry: ux lint --threshold high
        language: system
        pass_filenames: false

The pre-commit hook catches findings before they reach the PR. Most teams pair pre-commit with the GitHub Actions job — pre-commit gives developers a fast local signal, the CI job is the gate.


Adding a new rule

A rule is one JSON object in data/anti-patterns.json. The shape:

{
  "id": "kebab-case-id",
  "name": "Human-readable rule name",
  "severity": "high",
  "category": "Typography",
  "detection": {
    "type": "regex",
    "pattern": "your\\s+regex\\s+here",
    "flags": "im",
    "scope": ["css", "scss", "tsx", "jsx", "vue", "html"]
  },
  "why": "One sentence on why this is bad. Mention the AI-slop fingerprint or the WCAG failure or the CWV impact.",
  "fix": "One sentence on the do-instead. Concrete. Name a token, a property, a pattern."
}
Field Required Notes
id yes kebab-case, unique. Used by ux fix, by CI logs, by the --rule flag.
name yes Human-readable. Shows up in lint output and in PR comments.
severity yes One of critical, high, medium, low.
category yes One of A11y, Content, Color, Layout, Typography, Quality, Motion, Visual, Performance.
detection.type yes Always regex today. The linter compiles detection.pattern with re.compile.
detection.pattern yes A valid Python regex. Test it with python -c "import re; print(re.search(r'YOUR_PATTERN', sample))" before committing.
detection.flags yes A string. Currently honored: i (IGNORECASE), m (MULTILINE), s (DOTALL).
detection.scope yes List of file extensions to apply the rule to. Special-case: blade matches .blade.php. Empty list = apply to everything.
why yes The argument. One sentence. Quoted in PR comments.
fix yes The do-instead. One sentence. Quoted by ux fix.

After editing the JSON, run the test suite:

pytest tests/test_linter.py -q
pytest tests/test_data_validation.py -q

The data-validation tests check that every rule has a valid regex (compiles without raising), a valid severity, a valid category, and a non-empty why and fix. A broken regex would otherwise silently disable the rule — the test fails closed.


What the linter does not do

The linter is regex, not AST. Three things it intentionally does not catch:

  1. Semantic intent. It can flag <div onClick> without role, but it can't tell whether the <div> should have been a <button> in the first place. That's a job for /ux-audit.
  2. Cross-file consistency. It can flag Inter as display, but it can't catch "your design system says Outfit, this file uses Geist." That's a job for MASTER.md enforcement.
  3. Visual taste at the pixel level. It can flag purple-to-blue gradient, but it can't grade your color harmony. That's a job for /ux-critique or /ux-polish.

The linter is the floor — the things that are objectively wrong, deterministically detectable, and absolutely should not ship. Everything above the floor is a judgment call, and judgment calls belong in the LLM lane.


See also: Anti-Pattern Catalog 100 · Architecture · Recommender Engine · All 22 Commands Source: github.com/Laith0003/ux-skill/blob/main/engine/linter/core.py Rules: github.com/Laith0003/ux-skill/blob/main/data/anti-patterns.json

Clone this wiki locally