a11y-violation-gate

Use when a project has accessibility debt and a zero-violations gate would block every PR. Builds a CI ratchet that fails only on new WCAG violations a PR introduces, with pre-existing ones baselined per rule and page, from axe-core, pa11y, Lighthouse, WAVE or IBM Equal Access results.

/plugin install qa-accessibility@testland-qa

First time in Claude Code? Add the marketplace with /plugin marketplace add testland/qa

View source (opens in a new tab)

Overview

A binary "all or nothing" a11y gate blocks every PR on a project with accumulated debt, so teams disable it. The ratchet pattern fixes this: the gate fails only on new violations vs. a stored baseline. Existing violations are grandfathered; fixes shrink the baseline.

This skill builds that gate, aggregating outputs from any combination of the five scanners covered by axe-a11y (axe-core, pa11y, Lighthouse a11y, WAVE, IBM Equal Access).

Sibling gates with the same architecture: data-quality-gate, visual-baseline-gate, contract-compatibility-gate, perf-budget-gate.

When to use

  • The project has a11y debt and the team wants to gate against regressions while paying down the debt over time.
  • Multiple a11y scanners run in CI; the team wants one verdict.
  • Per-rule or per-page severity tiering matters (e.g. block on serious/critical, warn on moderate).

If the project is a11y-clean already, prefer a strict scanner- native gate (e.g. axe-core configured to fail on any violation) without ratchet - simpler.

Step 1 - Run the scanners and unify their outputs

Each scanner emits its own report shape (axe-core violations[], pa11y issues[], Lighthouse categories.accessibility.audits, WAVE categories, IBM Equal Access results[]). Normalize every finding to one unified record:

{
  "scanner": "axe",
  "rule_id": "color-contrast",
  "wcag_sc": "1.4.3",
  "page_url": "/dashboard",
  "selector": "button.primary",
  "severity": "serious",
  "fingerprint": "axe::color-contrast::/dashboard::button.primary"
}

The fingerprint is the load-bearing field - same fingerprint across runs = same violation; a new fingerprint = a new violation. Per-scanner output shapes and field mappings are in references/gate-implementation.md.

Step 2 - Maintain a baseline

The baseline is a checked-in JSON file listing every grandfathered fingerprint:

{
  "version": 1,
  "updated_at": "2026-05-04T12:00:00Z",
  "violations": [
    "axe::color-contrast::/legacy-page::div.subtitle",
    "axe::label::/old-form::input#user_email",
    "pa11y::WCAG2AA.Principle1.Guideline_1_4.1_4_3.G18.Fail::/legacy-page::span.muted"
  ]
}

Check it into the repo at a11y-baseline.json. Update it deliberately (as part of cleanup PRs); never auto-update from CI.

Step 3 - Apply the gate decision

Grandfather any fingerprint in the baseline, then block on new violations by severity tier:

Severity tierBehavior
Block (critical/serious)Fail the build.
Warn (moderate)Surface in PR comment; no build failure.
Info (minor)Log; no PR comment unless count > N.

The runnable gate (axe-core ingestion shown; extend to the other scanners per references/gate-implementation.md):

# scripts/run_a11y_gate.py
import json, sys
from pathlib import Path

records = []
axe_path = Path("axe-results.json")
if axe_path.exists():
    axe = json.loads(axe_path.read_text())
    url = axe.get('url', '/')
    for v in axe.get('violations', []):
        for node in v.get('nodes', []):
            sel = node.get('target', ['?'])[0]
            records.append({
                'scanner': 'axe', 'rule_id': v['id'],
                'wcag_sc': v.get('tags', [None])[-1],
                'page_url': url, 'selector': sel,
                'severity': v.get('impact', 'moderate'),
                'fingerprint': f"axe::{v['id']}::{url}::{sel}",
            })

baseline = set()
bp = Path("a11y-baseline.json")
if bp.exists():
    baseline = set(json.loads(bp.read_text()).get('violations', []))

seen = {r['fingerprint'] for r in records}
new = [r for r in records if r['fingerprint'] not in baseline]
blockers = [r for r in new if r['severity'] in ('critical', 'serious')]
warnings = [r for r in new if r['severity'] == 'moderate']
fixed = [f for f in baseline if f not in seen]   # shrinking baseline

verdict = 'no-go' if blockers else 'go'
print(f"# A11y Gate - verdict: {verdict.upper()}")
print(f"blockers={len(blockers)} warnings={len(warnings)} fixed={len(fixed)}")
for r in blockers:
    print(f"- {r['scanner']} :: {r['rule_id']} on {r['page_url']} ({r['selector']})")
sys.exit(0 if verdict == 'go' else 1)

The shrinking baseline counter (fixed) is the positive signal: when baseline fingerprints disappear from the latest scan, the team fixed them. Surface it as "5 fixed / 47 remaining." A no-go verdict exits non-zero so CI halts.

Step 4 - Emit the PR artifact

For a PR comment or $GITHUB_STEP_SUMMARY, render the verdict as a markdown table grouping blockers, warnings, the grandfathered count, and the fixed-since-baseline count, then a recommended-next-step block. Full template: references/gate-implementation.md.

Step 5 - Baseline maintenance workflow

The baseline is shared state - careful coordination prevents bit rot:

  1. Initial creation: run all scanners; emit every current violation as a fingerprint; write to a11y-baseline.json.
  2. PR adds new violations: gate fails; PR author fixes OR debates whether the violation was actually pre-existing (regenerate baseline if the team agrees).
  3. PR fixes existing violation: the violation's fingerprint disappears from the next scan; the gate's "fixed since baseline" counter increments. Manually remove the fingerprint from a11y-baseline.json in the same PR - otherwise the baseline accumulates stale entries.
  4. Quarterly review: the team reviews the baseline; any entry older than N quarters becomes a follow-up ticket.

Anti-patterns

Anti-patternWhy it failsFix
Auto-update baseline on every PRRegressions silently get grandfathered.Manual baseline updates only; reviewers verify each addition is intentional.
One severity threshold for all rulescolor-contrast and bypass (skip-link) have different impact; uniform threshold over- or under-blocks.Per-rule severity overrides; align with W3C-published rule severities.
Scoring "any violation = fail"Tests every PR against the entire backlog; team disables the gate.Ratchet against the baseline; only fail on net-new.
Skipping the "fixed-since-baseline" counterTeam has no positive feedback for cleanup work.Surface the counter prominently; tie to OKRs.
Failing only on criticalserious issues (most contrast / most ARIA) become invisible.Block on critical AND serious.

References

  • axe-a11y - the scanner umbrella; its references/ carry pa11y, Lighthouse a11y, WAVE, and IBM Equal Access.
  • W3C WCAG 2.2 - https://www.w3.org/TR/WCAG22/
  • Sibling gate skills (same architecture): data-quality-gate, visual-baseline-gate, contract-compatibility-gate, perf-budget-gate.
Reference

a11y-violation-gate: scanner shapes and artifact template

View source (opens in a new tab) (opens in new window)

Detail moved out of SKILL.md to keep the core gate script lean. The runnable gate (axe-core ingestion) is in SKILL.md Step 3; extend it to the other four scanners using the shapes below, then emit the PR artifact with the template at the end of this file.

Scanner native output shapes

ScannerNative output
axe-coreJSON with violations[]; rule ID, impact, nodes.
pa11yJSON with issues[]; code (WCAG SC), type.
Lighthouse a11yLHR JSON with categories.accessibility.audits.
WAVEJSON via WebAIM API; categories with errors / warnings.
IBM Equal AccessJSON with results[].

Normalizing the other scanners

Each scanner is read the same way as axe-core: pull its findings array (the second column above), map its native fields onto the unified record {scanner, rule_id, wcag_sc, page_url, selector, severity}, and build the same fingerprint f"{scanner}::{rule_id}::{page_url}::{selector}". A finding whose fingerprint reappears on the next run is the same violation; a new fingerprint is a new violation.

Full PR artifact template

Render for $GITHUB_STEP_SUMMARY or a PR comment:

# A11y Gate - verdict: NO-GO

**Blockers (NEW violations): 2**

| Scanner | Rule              | WCAG SC | Page         | Selector            | Severity |
|---------|-------------------|---------|--------------|---------------------|----------|
| axe     | color-contrast    | 1.4.3   | /checkout    | button.primary      | serious  |
| axe     | aria-required-attr | 4.1.2  | /checkout    | div[role="dialog"]  | critical |

**Warnings (NEW moderate): 1**

| Scanner | Rule          | WCAG SC | Page         | Selector |
|---------|---------------|---------|--------------|----------|
| pa11y   | landmark-one-main | 1.3.1 | /checkout | (page-level) |

**Grandfathered (in baseline): 47**
**Fixed since baseline: 5**  (positive trend)

## Recommended next step

Block-tier violations must be fixed in this PR. To address the
two blockers:
- `button.primary` on `/checkout`: contrast ratio 3.8:1; needs >=4.5:1.
- `div[role="dialog"]`: missing `aria-labelledby` or `aria-label`.

A no-go verdict exits non-zero so CI halts.

  • The fixed counter has been climbing and I want to quote it
  • Five fixes and five regressions off a PR that added one table row
  • Two ways to stop hand-editing the known-findings list, pick one
  • Four changes on the table and six months of bugs the build never stopped
  • Three weeks of cleanup and the number in CI has not moved