Exporting Accessibility Results to Compliance Dashboards
A passing build is not an audit trail. When a compliance reviewer asks “which WCAG 2.2 criteria failed on the release commit, and when were they fixed?”, you need durable records keyed by commit, with stable rule IDs and explicit success-criterion mapping. This page is part of Reporting, Dashboards & Violation Tracking, and it covers two export targets: a SQL compliance table you query directly, and a SARIF upload that lights up a code-scanning dashboard.
- Map every axe-core rule to its WCAG 2.2 success criterion before export
- Write findings to a SQL table keyed by commit, rule ID, and criterion
- Emit SARIF so a code-scanning dashboard groups and dedupes findings
- Validate the export with a query that reconstructs an audit snapshot
Root Cause / Context
axe-core tags each rule with WCAG tags like wcag2aa and wcag143, but a compliance dashboard wants the dotted criterion — “WCAG 2.2 SC 1.4.3” — not a packed tag. Without an explicit mapping step, an auditor cannot answer “show me every failure of SC 1.4.3 over the last quarter,” because the criterion is buried inside a tag array and encoded as wcag143.
The second gap is identity. If you export the human-readable help text as the key, your audit trail breaks the moment axe-core rewords a message. Stable rule IDs (color-contrast, label, aria-required-attr) plus the resolved success criterion are the durable join keys an audit trail must preserve, and they are the same IDs you use when writing custom rules for complex data tables.
Configuration
The exporter resolves each finding’s WCAG tag into a dotted criterion, writes a row per finding into a SQL table, and emits a SARIF file. The WCAG map covers the common AA rules; extend it as you adopt more checks.
// export.js — write normalized findings to SQL + SARIF with WCAG 2.2 mapping
const fs = require('fs');
const Database = require('better-sqlite3');
const findings = require('./normalized.json');
// Resolve axe wcagNNN tags to dotted WCAG 2.2 success criteria
const WCAG = {
wcag111: '1.1.1', wcag131: '1.3.1', wcag143: '1.4.3', wcag412: '4.1.2',
wcag244: '2.4.4', wcag253: '2.5.3', wcag325: '3.2.5', wcag133: '1.3.3',
};
function toSC(wcagTag) {
const key = (wcagTag || '').toLowerCase();
return WCAG[key] ? `WCAG 2.2 SC ${WCAG[key]}` : 'unmapped';
}
const enriched = findings.map(f => ({ ...f, sc: toSC(f.wcag) }));
// --- (a) SQL compliance table ---
const db = new Database('compliance.db');
db.exec(`CREATE TABLE IF NOT EXISTS compliance (
commit_sha TEXT, ts TEXT, rule_id TEXT, impact TEXT,
success_criterion TEXT, selector TEXT,
PRIMARY KEY (commit_sha, rule_id, selector) -- dedupe identical findings per commit
)`);
const upsert = db.prepare(`INSERT OR REPLACE INTO compliance
(commit_sha, ts, rule_id, impact, success_criterion, selector)
VALUES (@commit, @timestamp, @ruleId, @impact, @sc, @selector)`);
db.transaction(rows => rows.forEach(r => upsert.run(r)))(enriched);
// --- (b) SARIF 2.1.0 for code-scanning dashboards ---
const rules = [...new Map(enriched.map(f => [f.ruleId, f])).values()].map(f => ({
id: f.ruleId,
properties: { 'security-severity': f.impact, tags: [f.sc] },
shortDescription: { text: f.help || f.ruleId },
}));
const sarif = {
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
version: '2.1.0',
runs: [{
tool: { driver: { name: 'axe-core', rules } },
results: enriched.map(f => ({
ruleId: f.ruleId,
level: f.impact === 'critical' || f.impact === 'serious' ? 'error' : 'warning',
message: { text: `${f.help} (${f.sc})` },
locations: [{
physicalLocation: {
artifactLocation: { uri: 'a11y-scan.json' }, // scanner has no source line
region: { startLine: 1 },
},
}],
partialFingerprints: { selector: f.selector }, // lets the dashboard dedupe over time
})),
}],
};
fs.writeFileSync('a11y.sarif', JSON.stringify(sarif, null, 2));
console.log(`Exported ${enriched.length} findings to SQL + SARIF`);
Upload the SARIF so the dashboard renders it. The SQL file can be committed to a compliance bucket or pushed to a shared Postgres; the schema is identical. Pair this with progressive threshold management so the dashboard reflects the same baseline your gate enforces.
- name: Export compliance data
if: always()
run: node export.js
- name: Upload SARIF to code scanning
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: a11y.sarif # appears in the repository's code-scanning dashboard
Validation
Confirm the SQL export is queryable and the SARIF parses. This audit query reconstructs the snapshot a reviewer would ask for: every failed criterion on a specific commit.
-- Audit snapshot: failed success criteria for one commit, by severity
SELECT success_criterion,
impact,
COUNT(*) AS occurrences
FROM compliance
WHERE commit_sha = :commit_sha
AND success_criterion != 'unmapped'
GROUP BY success_criterion, impact
ORDER BY occurrences DESC;
# SARIF must parse and contain at least one run
node -e "const s=require('./a11y.sarif'); if(!s.runs.length) throw new Error('no runs'); console.log('SARIF runs OK:', s.runs[0].results.length, 'results')"
Edge Cases & Conditional Guards
- Unmapped criteria: rules without a WCAG tag (best-practice checks) resolve to
unmapped; keep them in the table but exclude them from compliance counts with the!= 'unmapped'filter. - Selector churn: virtualized lists generate shifting selectors that defeat the primary key; for those rules, store a normalized selector (strip indices) so the same logical element does not appear as new each run.
- SARIF size limits: code-scanning dashboards cap upload size; if a large app produces thousands of results, dedupe by
ruleId+ normalized selector before writing the SARIF.
Pipeline Impact
Export runs with if: always() so a failing gate still produces a compliance record. The SARIF upload itself does not set the job status, but a code-scanning dashboard can be configured to fail a PR if new error-level results appear — keep that policy distinct from your scanner gate to avoid double-counting. Treat compliance.db and a11y.sarif as retained artifacts for audit retention.
Common Pitfalls
- Exporting the
helptext as the key, so an axe-core wording change forks one rule into two in the audit trail. - Forgetting
PRIMARY KEY ... INSERT OR REPLACE, which duplicates rows on pipeline retries and inflates compliance counts. - Leaving the WCAG map incomplete, silently dropping criteria into
unmappedso audits under-report failures. - Hard-coding a
startLinethat implies a real source location; reviewers will chase a line that does not exist.
FAQ
Why map to WCAG criteria instead of just storing the axe tag?
Auditors and compliance frameworks reference dotted success criteria like SC 1.4.3, not tool-specific tags like wcag143. Resolving the mapping at export time means your dashboard answers compliance questions directly, without every consumer re-deriving the criterion.
SQL table or SARIF — which should I use? Use both if you can. SARIF gives you a zero-infrastructure dashboard inside the code-scanning view with automatic dedupe; the SQL table gives you arbitrary audit queries and long-term trend joins that SARIF cannot express.
How do I keep the audit trail stable when selectors change?
Key rows on commit_sha, rule_id, and a normalized selector with volatile indices stripped. The stable rule ID plus criterion is what an auditor joins on; the raw selector is only a locator.
Related
- Reporting, Dashboards & Violation Tracking — the parent guide where normalization is defined.
- Progressive Threshold Management — aligning the dashboard baseline with the enforced gate.
- Writing Custom axe-core Rules for Complex Data Tables — where the stable rule IDs you export originate.