-
Notifications
You must be signed in to change notification settings - Fork 7
Linter Rules
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.
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 rulesPure 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 fixThe output is JSON-serializable (LintReport.to_dict()), pipe-friendly, and consumed directly by ux fix to autocorrect findings inline.
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.
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).
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:
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.
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.
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.
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)\bWhy: 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.
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.
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; }.
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.
Pattern: Matches Unicode emoji ranges (U+1F300–U+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.
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;.
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.
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.
pip install uxskill
ux lint ./src --threshold high
echo $? # 0 = clean, 1 = failThe --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.
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 highThat's it. No config file, no plugin install, no token. The linter exits non-zero on any high+ finding and GitHub fails the check.
ux-lint:
image: python:3.11
script:
- pip install uxskill
- ux lint ./src --threshold high
only:
- merge_requests
- main# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: ux-lint
name: ux-lint
entry: ux lint --threshold high
language: system
pass_filenames: falseThe 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.
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 -qThe 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.
The linter is regex, not AST. Three things it intentionally does not catch:
-
Semantic intent. It can flag
<div onClick>withoutrole, but it can't tell whether the<div>should have been a<button>in the first place. That's a job for/ux-audit. -
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.mdenforcement. -
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-critiqueor/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