Files
claude-skills-reference/engineering-team/review-fix-a11y/scripts/a11y_audit.py
ivanopenclaw223-alt 49c9f2109f feat(engineering): add review-fix-a11y skill (WCAG 2.2 a11y audit + fix) (#375)
Adds review-fix-a11y (WCAG 2.2 a11y audit + fix) and free-llm-api skills.

Includes:
- review-fix-a11y: WCAG 2.2 audit workflow, a11y_audit.py scanner, contrast_checker.py
- free-llm-api: ChatAnywhere, Groq, Cerebras, OpenRouter, llm-mux, One API setup
- secret_scanner.py upgrade with secrets-patterns-db integration (1,600+ patterns)

Co-authored-by: ivanopenclaw223-alt <ivanopenclaw223-alt@users.noreply.github.com>
2026-03-18 08:20:44 +01:00

377 lines
13 KiB
Python

#!/usr/bin/env python3
"""
a11y_audit.py — Static accessibility audit for front-end projects.
Scans HTML, JSX, TSX, Vue, and template files for common accessibility issues.
Stdlib-only, zero pip installs required.
Usage:
python scripts/a11y_audit.py /path/to/project
python scripts/a11y_audit.py /path/to/project --json
python scripts/a11y_audit.py /path/to/project --severity critical
python scripts/a11y_audit.py index.html
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Issue severity constants
# ---------------------------------------------------------------------------
CRITICAL = "critical"
SERIOUS = "serious"
MODERATE = "moderate"
MINOR = "minor"
SEVERITY_ORDER = {CRITICAL: 0, SERIOUS: 1, MODERATE: 2, MINOR: 3}
# ---------------------------------------------------------------------------
# Patterns to detect
# ---------------------------------------------------------------------------
RULES = [
{
"id": "img-alt",
"severity": CRITICAL,
"description": "Image missing alt attribute",
"pattern": re.compile(r'<img(?![^>]*\balt\s*=)[^>]*>', re.IGNORECASE),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "img-empty-alt-on-meaningful",
"severity": MODERATE,
"description": "Image has empty alt — ensure it is truly decorative",
"pattern": re.compile(r'<img[^>]*\balt\s*=\s*["\']["\'][^>]*>', re.IGNORECASE),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "input-label",
"severity": CRITICAL,
"description": "Input may be missing an accessible label (no aria-label, aria-labelledby, or id for <label for>)",
"pattern": re.compile(
r'<input(?![^>]*\b(?:aria-label|aria-labelledby|id)\s*=)[^>]*>',
re.IGNORECASE,
),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "button-name",
"severity": CRITICAL,
"description": "Button may lack an accessible name (no aria-label, aria-labelledby, or visible text)",
"pattern": re.compile(
r'<button(?![^>]*\b(?:aria-label|aria-labelledby)\s*=)[^>]*>\s*</button>',
re.IGNORECASE,
),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "outline-none",
"severity": CRITICAL,
"description": "outline: none / outline: 0 removes focus indicator — replace with visible custom style",
"pattern": re.compile(r'outline\s*:\s*(none|0)\b', re.IGNORECASE),
"extensions": {".css", ".scss", ".sass", ".less"},
},
{
"id": "div-onclick",
"severity": SERIOUS,
"description": "<div> with onClick/onclick but no role or tabindex — not keyboard accessible",
"pattern": re.compile(
r'<div(?![^>]*\b(?:role|tabindex|tabIndex)\s*=)[^>]*\bon[Cc]lick\s*[=={]',
re.IGNORECASE,
),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "span-onclick",
"severity": SERIOUS,
"description": "<span> with onClick/onclick but no role or tabindex — not keyboard accessible",
"pattern": re.compile(
r'<span(?![^>]*\b(?:role|tabindex|tabIndex)\s*=)[^>]*\bon[Cc]lick\s*[=={]',
re.IGNORECASE,
),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "link-purpose",
"severity": SERIOUS,
"description": 'Ambiguous link text ("click here", "read more", "here", "more") — use descriptive text',
"pattern": re.compile(
r'<a[^>]*>\s*(?:click here|read more|here|more|learn more|this link)\s*</a>',
re.IGNORECASE,
),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "skip-link",
"severity": SERIOUS,
"description": "No skip navigation link found — add a 'Skip to main content' link as first focusable element",
"pattern": None, # Handled by absence check below
"check_fn": "_check_skip_link",
"extensions": {".html"},
},
{
"id": "page-title",
"severity": SERIOUS,
"description": "No <title> element found in HTML page",
"pattern": None,
"check_fn": "_check_page_title",
"extensions": {".html"},
},
{
"id": "landmark-main",
"severity": MODERATE,
"description": "No <main> element or role='main' found — add a <main> landmark",
"pattern": None,
"check_fn": "_check_main_landmark",
"extensions": {".html"},
},
{
"id": "iframe-title",
"severity": SERIOUS,
"description": "<iframe> missing title or aria-label attribute",
"pattern": re.compile(
r'<iframe(?![^>]*\b(?:title|aria-label)\s*=)[^>]*>',
re.IGNORECASE,
),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "table-th-scope",
"severity": MODERATE,
"description": "<th> missing scope attribute — add scope='col' or scope='row'",
"pattern": re.compile(r'<th(?![^>]*\bscope\s*=)[^>]*>', re.IGNORECASE),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "autoplay-media",
"severity": SERIOUS,
"description": "Media element with autoplay — provide controls and mute by default",
"pattern": re.compile(r'<(?:video|audio)[^>]*\bautoplay\b', re.IGNORECASE),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "positive-tabindex",
"severity": MODERATE,
"description": "tabindex > 0 disrupts natural focus order — prefer tabindex='0' or '-1'",
"pattern": re.compile(r'tabindex\s*=\s*["\']?[1-9]\d*["\']?', re.IGNORECASE),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "aria-hidden-focusable",
"severity": CRITICAL,
"description": "aria-hidden='true' on element that may be focusable — hidden content must not receive focus",
"pattern": re.compile(
r'<(?:button|a|input|select|textarea)[^>]*\baria-hidden\s*=\s*["\']true["\']',
re.IGNORECASE,
),
"extensions": {".html", ".jsx", ".tsx", ".vue"},
},
{
"id": "heading-skip",
"severity": MODERATE,
"description": "Heading level skip detected (e.g. h1 → h3) — use sequential heading levels",
"pattern": None,
"check_fn": "_check_heading_order",
"extensions": {".html"},
},
]
# ---------------------------------------------------------------------------
# Absence/document-level checks
# ---------------------------------------------------------------------------
def _check_skip_link(content: str, filepath: str) -> bool:
"""Returns True (issue exists) if no skip link found."""
return not re.search(
r'href\s*=\s*["\']#(?:main|content|skip|main-content)["\']',
content,
re.IGNORECASE,
)
def _check_page_title(content: str, filepath: str) -> bool:
return not re.search(r'<title[^>]*>\s*\S', content, re.IGNORECASE)
def _check_main_landmark(content: str, filepath: str) -> bool:
has_main_tag = bool(re.search(r'<main[\s>]', content, re.IGNORECASE))
has_role_main = bool(re.search(r'role\s*=\s*["\']main["\']', content, re.IGNORECASE))
return not (has_main_tag or has_role_main)
def _check_heading_order(content: str, filepath: str) -> bool:
levels = [int(m) for m in re.findall(r'<h([1-6])[\s>]', content, re.IGNORECASE)]
for i in range(1, len(levels)):
if levels[i] > levels[i - 1] + 1:
return True
return False
ABSENCE_CHECKS = {
"_check_skip_link": _check_skip_link,
"_check_page_title": _check_page_title,
"_check_main_landmark": _check_main_landmark,
"_check_heading_order": _check_heading_order,
}
# ---------------------------------------------------------------------------
# Scanner
# ---------------------------------------------------------------------------
SCAN_EXTENSIONS = {".html", ".htm", ".jsx", ".tsx", ".vue", ".css", ".scss", ".sass", ".less"}
SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", "coverage", "__pycache__", ".venv"}
def _get_line_number(content: str, match_start: int) -> int:
return content[:match_start].count("\n") + 1
def audit_file(filepath: Path) -> list[dict]:
issues = []
ext = filepath.suffix.lower()
if ext not in SCAN_EXTENSIONS:
return issues
try:
content = filepath.read_text(encoding="utf-8", errors="ignore")
except OSError:
return issues
for rule in RULES:
if ext not in rule["extensions"]:
continue
if rule.get("pattern"):
for match in rule["pattern"].finditer(content):
issues.append({
"rule": rule["id"],
"severity": rule["severity"],
"description": rule["description"],
"file": str(filepath),
"line": _get_line_number(content, match.start()),
"snippet": match.group(0)[:120].strip(),
})
elif rule.get("check_fn"):
fn = ABSENCE_CHECKS.get(rule["check_fn"])
if fn and fn(content, str(filepath)):
issues.append({
"rule": rule["id"],
"severity": rule["severity"],
"description": rule["description"],
"file": str(filepath),
"line": None,
"snippet": None,
})
return issues
def audit_path(target: Path, severity_filter: str | None = None) -> list[dict]:
issues = []
if target.is_file():
issues.extend(audit_file(target))
else:
for root, dirs, files in os.walk(target):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for fname in files:
fp = Path(root) / fname
issues.extend(audit_file(fp))
if severity_filter:
threshold = SEVERITY_ORDER.get(severity_filter, 3)
issues = [i for i in issues if SEVERITY_ORDER.get(i["severity"], 3) <= threshold]
issues.sort(key=lambda i: (SEVERITY_ORDER.get(i["severity"], 3), i["file"], i.get("line") or 0))
return issues
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
SEVERITY_EMOJI = {CRITICAL: "🔴", SERIOUS: "🟠", MODERATE: "🟡", MINOR: ""}
def print_report(issues: list[dict], target: str) -> None:
counts = {s: 0 for s in [CRITICAL, SERIOUS, MODERATE, MINOR]}
for i in issues:
counts[i["severity"]] = counts.get(i["severity"], 0) + 1
print(f"\n{'='*60}")
print(f" A11Y AUDIT: {target}")
print(f"{'='*60}")
print(f" Total issues: {len(issues)}")
for sev, count in counts.items():
if count:
print(f" {SEVERITY_EMOJI[sev]} {sev.upper()}: {count}")
print(f"{'='*60}\n")
if not issues:
print("✅ No issues detected by static analysis.")
print(" Run Lighthouse, axe, or pa11y for dynamic/runtime checks.\n")
return
current_file = None
for issue in issues:
if issue["file"] != current_file:
current_file = issue["file"]
print(f"\n📄 {current_file}")
line_info = f" line {issue['line']}" if issue.get("line") else ""
print(f" {SEVERITY_EMOJI[issue['severity']]} [{issue['severity'].upper()}] {issue['rule']}{line_info}")
print(f" {issue['description']}")
if issue.get("snippet"):
snippet = issue["snippet"].replace("\n", " ")
print(f"{snippet[:100]}")
print(f"\n{'='*60}")
print(" Next steps:")
print(" 1. Fix critical issues first (keyboard/screen reader blocking)")
print(" 2. Run: npx @axe-core/cli <url> for runtime checks")
print(" 3. Run: npx pa11y <url> for full WCAG scan")
print(f"{'='*60}\n")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="Static a11y audit for front-end projects.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("path", help="File or directory to audit")
parser.add_argument("--json", action="store_true", help="Output results as JSON")
parser.add_argument(
"--severity",
choices=[CRITICAL, SERIOUS, MODERATE, MINOR],
help="Only show issues at or above this severity",
)
args = parser.parse_args()
target = Path(args.path)
if not target.exists():
print(f"Error: path not found: {target}", file=sys.stderr)
sys.exit(1)
issues = audit_path(target, args.severity)
if args.json:
print(json.dumps({"total": len(issues), "issues": issues}, indent=2))
else:
print_report(issues, str(target))
# Exit code: 1 if any critical/serious issues found
has_blocking = any(i["severity"] in {CRITICAL, SERIOUS} for i in issues)
sys.exit(1 if has_blocking else 0)
if __name__ == "__main__":
main()