Reporting, Dashboards and Violation Tracking for Accessibility Scans
A scan produces one artifact and three completely different questions get asked of it. The developer who opened the pull request wants the selector that failed and nothing else; the team lead wants to know whether color-contrast has been in the top three rules for six sprints; the person who signs the accessibility conformance statement wants a list of WCAG 2.2 success criteria with a status against each one. This guide is part of CI/CD Integration & Automated Quality Gating, and it covers the data model that answers all three from one set of rows — because the alternative, three separate scripts each parsing the raw report their own way, produces three numbers that never agree.
The reporting layer is not a formatting problem. It is a schema problem that looks like a formatting problem for about two months, until the first time somebody asks “when did this violation first appear?” and the honest answer is that nobody can tell, because every run overwrote a JSON file whose only key was the branch name.
Problem Statement
The output of axe.run() is a single object with four sibling arrays: violations, passes, incomplete and inapplicable. Every rule that executed lands in exactly one of them. A rule whose selector matched nothing goes to inapplicable; a rule that matched but could not reach a verdict — a colour behind a background image, an element inside a closed shadow root — goes to incomplete; the rest go to passes or violations. Each entry in those arrays is a rule result, not an element result, and it carries the rule’s id, an impact string (critical, serious, moderate or minor), a tags array, a short help string, a longer description, and a nodes array. The elements live in nodes: each node has a target array of CSS selectors, the element’s outer html, and a failureSummary written as human-facing remediation prose.
{
"testEngine": { "name": "axe-core", "version": "4.10.2" },
"url": "https://shop.example.com/pricing?plan=team",
"timestamp": "2026-07-25T09:14:02.331Z",
"violations": [
{
"id": "color-contrast",
"impact": "serious",
"tags": ["cat.color", "wcag2aa", "wcag143", "TTv5", "ACT"],
"help": "Elements must meet minimum color contrast ratio thresholds",
"description": "Ensures foreground and background colors meet WCAG 2 AA thresholds",
"nodes": [
{
"impact": "serious",
"target": ["#pricing > .plan:nth-child(2) > .badge"],
"html": "<span class=\"badge\">Most popular</span>",
"failureSummary": "Fix any: contrast 2.71:1 is below the 4.5:1 threshold"
}
]
}
],
"passes": [],
"incomplete": [],
"inapplicable": []
}
Storing that object verbatim, one blob per run, is the schema almost every team starts with and it fails in four specific ways. The first is volume: passes and inapplicable are usually 90–97% of the payload, so a forty-route scan of a moderately sized application writes 8–30 MB of JSON per run, and a repository with fifty runs a day accumulates a gigabyte a month of data nobody queries. The second is queryability — “which routes regressed since Tuesday” becomes a script that parses every blob in the bucket, so nobody writes it. The third is identity: nothing in the raw object connects a node in run 412 to the same broken element in run 413, so “still failing” and “failed again” are indistinguishable. The fourth is coupling: the blob’s shape belongs to the scanner version, so an axe-core upgrade that renames a field silently breaks reads of two years of history.
The alternative is one normalised record per failing node, written once, and three read paths built on top of it. That is what the rest of this guide constructs.
Key implementation targets:
- A normaliser that flattens
violationsandincompleteinto one row per node, with the WCAG criteria parsed out of the rule’stagsarray rather than left packed aswcag143. - A stable node fingerprint that survives
nth-childshuffles and generated element ids, so the same defect keeps one identity across runs. - A two-table store — one row per run, one row per finding — with a separate criterion table because the rule-to-criterion relationship is many-to-many.
- A retention policy with three tiers: node detail for weeks, rule-level rollups for months, criterion snapshots indefinitely.
- Three read paths: a per-run author summary that only shows what this pull request introduced, an aggregate rule and route view for the team, and a criterion rollup that carries a not-tested state for the compliance owner.
Prerequisites
1. Normalise the Report Into Node-Level Records
The normaliser has one job: turn a directory of raw scan files into a stream of flat records, deriving the fields the raw report only implies. Five values must be computed rather than copied.
The route comes from results.url with the query string, hash and trailing slash removed. Leaving them in splits /pricing, /pricing?plan=team and /pricing/ into three routes that trend independently, which makes every route-level aggregate wrong. If a query parameter genuinely changes the page — a locale switch, a feature flag — keep it, but keep it in a separate column so it is a dimension rather than part of the route key.
The criteria come from the tags array. axe packs success criteria as wcag143 and conformance levels as wcag2a, wcag21aa, wcag22aa. Those are different shapes and need different parsers: the criterion form is always a principle digit, a guideline digit, and one or more criterion digits, so wcag1412 resolves to 1.4.12 and wcag143 to 1.4.3 with no ambiguity. A single rule can carry several criteria, which is why this field is an array and why it gets its own table later.
The impact rank is a small integer alongside the impact string. Sorting or taking a maximum over the text values is meaningless — alphabetically, critical sorts before minor and moderate before serious — and every leaderboard query needs a worst-impact aggregate. Store both.
The status distinguishes violation from incomplete. Normalise both into the same table. Incomplete results are the scanner telling you it could not decide, and they are the most useful triage queue on the site, but counting them as violations inflates every trend the moment somebody adds a rule that returns undefined often.
The fingerprint is the identity of the defect. node.target is not stable: a selector containing :nth-child(7) changes when a sibling is inserted, a generated id like #radix-r3f changes on every render, and a virtualised row’s key changes on every scroll. Hash a stripped selector together with the route, the rule id and the element’s opening tag, and the same defect keeps the same key for as long as the markup is genuinely the same markup.
// a11y/report/normalise.mjs
// usage: node a11y/report/normalise.mjs axe-raw/*.json > findings.ndjson
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
const RANK = { critical: 4, serious: 3, moderate: 2, minor: 1 };
// wcag143 -> 1.4.3 and wcag1412 -> 1.4.12; level tags contain letters and never match.
const CRITERION = /^wcag(\d)(\d)(\d+)$/;
const LEVEL = /^wcag2\d?a{1,3}$/;
function criteria(tags) {
return tags.map((t) => CRITERION.exec(t)).filter(Boolean)
.map((m) => `${m[1]}.${m[2]}.${m[3]}`);
}
function routeOf(url) {
const u = new URL(url);
// Query and hash are dimensions, not identity: keep the route key comparable.
return u.pathname.replace(/\/+$/, '') || '/';
}
function stableSelector(sel) {
return sel
.replace(/:nth-child\(\d+\)/g, ':nth-child(*)') // sibling order is not identity
.replace(/#[A-Za-z0-9_-]*\d[A-Za-z0-9_-]*/g, '#*') // ids with digits are generated
.replace(/\[data-key="[^"]*"\]/g, '[data-key=*]'); // list keys churn on every render
}
function fingerprint(route, ruleId, target, html) {
// Opening tag only: inner text churns on copy edits without changing the defect.
const shape = html.replace(/>[\s\S]*$/, '>').replace(/\s(?:id|data-testid)="[^"]*"/g, '');
return createHash('sha1')
.update([route, ruleId, stableSelector(target), shape].join('|'))
.digest('hex').slice(0, 12);
}
function rulesetHash(results) {
// Every rule that executed, in any outcome bucket: the effective rule set.
const ids = [...results.violations, ...results.passes,
...results.incomplete, ...results.inapplicable].map((r) => r.id);
return createHash('sha1').update([...new Set(ids)].sort().join(',')).digest('hex')
.slice(0, 12);
}
function rows(results, status, meta) {
const list = status === 'violation' ? results.violations : results.incomplete;
const route = routeOf(results.url);
return list.flatMap((v) => v.nodes.map((n) => {
// target is an array; inside a frame it is an array of arrays.
const target = n.target.flat(Infinity).join(' >>> ');
const impact = v.impact ?? n.impact ?? 'minor'; // experimental rules report null
return {
...meta, route, status, impact, rank: RANK[impact],
ruleId: v.id, help: v.help, criteria: criteria(v.tags),
levels: v.tags.filter((t) => LEVEL.test(t)),
target, summary: (n.failureSummary ?? '').split('\n').slice(0, 2).join(' '),
fingerprint: fingerprint(route, v.id, target, n.html ?? ''),
};
}));
}
const files = process.argv.slice(2);
const expected = Number(process.env.A11Y_EXPECTED_ROUTES ?? files.length);
for (const file of files) {
const results = JSON.parse(readFileSync(file, 'utf8'));
const meta = {
runId: `${process.env.GITHUB_RUN_ID ?? 'local'}.${process.env.GITHUB_RUN_ATTEMPT ?? '1'}`,
commit: process.env.GITHUB_SHA ?? 'uncommitted',
branch: process.env.GITHUB_REF_NAME ?? 'local',
startedAt: results.timestamp,
axeVersion: results.testEngine.version,
rulesetHash: rulesetHash(results),
scanStatus: files.length === expected ? 'complete' : 'partial',
};
for (const row of [...rows(results, 'violation', meta),
...rows(results, 'incomplete', meta)]) {
process.stdout.write(JSON.stringify(row) + '\n');
}
}
Note that rulesetHash is the one thing the full report is genuinely needed for. It reads passes and inapplicable to establish which rules actually executed, which is the difference between “this criterion had no failures” and “this criterion was never checked” — the distinction that the whole compliance view depends on. So keep the raw JSON as a short-lived artifact and read it once at normalise time; just do not make it the store. If the scan is sharded, normalise after the shards are merged, using the artifact-merging pattern described in merging sharded accessibility reports into one artifact, or the ruleset hash will differ per shard.
NDJSON, one JSON object per line, is deliberate. It streams, it appends, it survives a truncated write with the loss of one row instead of the whole file, and wc -l is a valid row count.
2. The Storage Schema, Identity and Retention
Two tables, because a run and a finding have different lifetimes and different owners. The runs table is the evidence unit: it records what was scanned, by which scanner version, against which rule set, and whether the scan completed. A compliance export cites a run id; without a runs row, a finding is an assertion with no provenance. The findings table holds observations, keyed on (run_id, fingerprint) so a retried job that re-uploads the same rows is a no-op rather than a double count.
Criteria get a third table. The rule-to-criterion relationship is genuinely many-to-many — aria-required-attr carries 4.1.2 alone, while link-name carries 4.1.2 and 2.4.4 — and flattening it into a comma-joined string in the findings row makes every criterion query a LIKE scan that also matches 1.4.3 when you asked for 1.4.30. Storing criteria denormalised is the single most common cause of a compliance number that cannot be reproduced.
-- a11y/report/schema.sql — applied on every load, idempotent.
CREATE TABLE IF NOT EXISTS runs (
run_id TEXT PRIMARY KEY,
commit_sha TEXT NOT NULL,
branch TEXT NOT NULL,
started_at TEXT NOT NULL,
axe_version TEXT NOT NULL,
ruleset_hash TEXT NOT NULL, -- changes when rules are added or disabled
routes_scanned INTEGER NOT NULL,
scan_status TEXT NOT NULL -- complete | partial
);
CREATE TABLE IF NOT EXISTS findings (
run_id TEXT NOT NULL REFERENCES runs(run_id),
route TEXT NOT NULL,
rule_id TEXT NOT NULL,
impact TEXT NOT NULL,
rank INTEGER NOT NULL, -- 4 critical .. 1 minor, for MAX() aggregates
status TEXT NOT NULL, -- violation | incomplete
fingerprint TEXT NOT NULL,
target TEXT NOT NULL,
summary TEXT,
PRIMARY KEY (run_id, fingerprint) -- a retried upload is idempotent
);
CREATE TABLE IF NOT EXISTS finding_criteria (
run_id TEXT NOT NULL,
fingerprint TEXT NOT NULL,
criterion TEXT NOT NULL, -- '1.4.3', never 'wcag143'
PRIMARY KEY (run_id, fingerprint, criterion)
);
CREATE INDEX IF NOT EXISTS findings_rule_idx ON findings (rule_id, route);
CREATE INDEX IF NOT EXISTS findings_fp_idx ON findings (fingerprint);
CREATE INDEX IF NOT EXISTS criteria_idx ON finding_criteria (criterion);
The loader applies the schema, inserts one run row, and streams the findings inside a single transaction. Positional binding rather than named binding keeps it working when the normaliser gains a field the table does not have yet.
// a11y/report/load.mjs — usage: node a11y/report/load.mjs findings.ndjson a11y.db
import { readFileSync } from 'node:fs';
import Database from 'better-sqlite3';
const [ndjson, dbPath] = process.argv.slice(2);
const rows = readFileSync(ndjson, 'utf8').split('\n').filter(Boolean).map(JSON.parse);
if (rows.length === 0) {
console.log('No findings to load; run row still required for provenance.');
}
const db = new Database(dbPath);
db.pragma('journal_mode = WAL'); // concurrent readers during a load
db.exec(readFileSync('a11y/report/schema.sql', 'utf8'));
const run = rows[0] ?? JSON.parse(process.env.A11Y_EMPTY_RUN_META ?? '{}');
const insertRun = db.prepare(`INSERT OR REPLACE INTO runs
(run_id, commit_sha, branch, started_at, axe_version, ruleset_hash,
routes_scanned, scan_status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
const insertFinding = db.prepare(`INSERT OR IGNORE INTO findings
(run_id, route, rule_id, impact, rank, status, fingerprint, target, summary)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const insertCriterion = db.prepare(`INSERT OR IGNORE INTO finding_criteria
(run_id, fingerprint, criterion) VALUES (?, ?, ?)`);
db.transaction(() => {
insertRun.run(run.runId, run.commit, run.branch, run.startedAt, run.axeVersion,
run.rulesetHash, new Set(rows.map((r) => r.route)).size, run.scanStatus);
for (const r of rows) {
insertFinding.run(r.runId, r.route, r.ruleId, r.impact, r.rank, r.status,
r.fingerprint, r.target, r.summary);
for (const c of r.criteria) insertCriterion.run(r.runId, r.fingerprint, c);
}
})();
console.log(`Loaded ${rows.length} findings for run ${run.runId}`);
Retention is where most stores either explode or lose the thing that mattered. The decision is not “how long do we keep data” but “at what grain does each question need to be answerable a year from now”. Node-level detail answers “which element” and is worthless after the element is fixed. Rule-level daily counts answer “is this getting better” and are needed for as long as anyone charts a trend. Criterion snapshots answer “what did we claim on the release date” and must outlive the code.
| Tier | Grain | Retention | Rows after 12 months | Answers |
|---|---|---|---|---|
findings |
run, route, rule, node | 30 days | ~1.2 M | which element, on which route, right now |
rule_daily |
day, route, rule | 18 months | ~46 k | which rule dominates, is it shrinking |
criterion_snapshot |
release, criterion | indefinite | ~2.4 k | what was the conformance claim |
The nightly job re-derives the last two days of rollup — enough to absorb a late-arriving run or a re-run — and only then deletes expired node detail. Getting that order backwards loses a day of trend data permanently.
-- a11y/report/aggregate.sql — run nightly, in this order.
CREATE TABLE IF NOT EXISTS rule_daily (
day TEXT, route TEXT, rule_id TEXT, worst_rank INTEGER,
nodes INTEGER, runs INTEGER,
PRIMARY KEY (day, route, rule_id)
);
INSERT OR REPLACE INTO rule_daily (day, route, rule_id, worst_rank, nodes, runs)
SELECT date(r.started_at), f.route, f.rule_id, MAX(f.rank),
COUNT(DISTINCT f.fingerprint), COUNT(DISTINCT f.run_id)
FROM findings f JOIN runs r USING (run_id)
WHERE f.status = 'violation'
AND r.branch = 'main'
AND r.scan_status = 'complete' -- partial scans skew counts down
AND date(r.started_at) >= date('now', '-2 day') -- re-derive a small window
GROUP BY 1, 2, 3;
DELETE FROM finding_criteria WHERE run_id IN (
SELECT run_id FROM runs WHERE started_at < datetime('now', '-30 day'));
DELETE FROM findings WHERE run_id IN (
SELECT run_id FROM runs WHERE started_at < datetime('now', '-30 day'));
Keep the runs rows forever. They are tiny, and a criterion snapshot that cites a run id no longer present in the store is not evidence.
3. The Per-Run Summary an Author Can Act On
The three read paths differ in grain, in latency and in what they are allowed to omit. An author needs node grain within a minute of pushing, and is entitled to see nothing except what this branch introduced. A team needs rule grain over weeks and does not care about individual selectors. A compliance owner needs criterion grain per release and cares about coverage far more than counts.
The author summary lives in the run summary rather than in a comment, because a comment on every push turns into forty comments on a long-lived branch. The one column that changes behaviour is new versus pre-existing: a summary that lists 214 findings on a legacy route gets ignored, while a summary that lists the three findings this branch introduced gets fixed. The base set comes from the store, taking every fingerprint seen on the base branch in the recent past rather than from a single run, so one flaky base scan cannot mark twenty existing defects as new.
// a11y/report/pr-summary.mjs
// usage: node a11y/report/pr-summary.mjs findings.ndjson a11y.db main >> "$GITHUB_STEP_SUMMARY"
import { readFileSync } from 'node:fs';
import Database from 'better-sqlite3';
const [ndjson, dbPath, baseBranch] = process.argv.slice(2);
const head = readFileSync(ndjson, 'utf8').split('\n').filter(Boolean)
.map(JSON.parse).filter((r) => r.status === 'violation');
const db = new Database(dbPath, { readonly: true });
const base = new Set(db.prepare(
`SELECT DISTINCT f.fingerprint FROM findings f JOIN runs r USING (run_id)
WHERE r.branch = ? AND f.status = 'violation'
AND r.started_at >= datetime('now', '-14 day')`
).all(baseBranch).map((row) => row.fingerprint));
const introduced = head.filter((r) => !base.has(r.fingerprint));
introduced.sort((a, b) => b.rank - a.rank || a.route.localeCompare(b.route));
// A bare pipe inside a cell splits the row even when the text is in backticks.
const cell = (s) => '`' + String(s).replaceAll('|', '\\|') + '`';
const CAP = 15;
console.log(`### Accessibility: ${introduced.length} new, ` +
`${head.length - introduced.length} pre-existing`);
console.log('');
if (introduced.length === 0) {
console.log('No new findings against the base branch. Pre-existing debt unchanged.');
} else {
console.log('| Impact | Route | Rule | Selector |');
console.log('|---|---|---|---|');
for (const r of introduced.slice(0, CAP)) {
console.log(`| ${r.impact} | ${r.route} | ${cell(r.ruleId)} | ${cell(r.target)} |`);
}
if (introduced.length > CAP) {
console.log('');
console.log(`${introduced.length - CAP} more rows in the \`a11y-findings\` artifact.`);
}
}
The same records drive the chat and pull-request payloads, which have their own hard limits on block counts, annotation counts and message length; those transforms are worked through in structuring JSON violation output for Slack and GitHub annotations. Keep the formatting there and the querying here — a formatter that runs its own SQL is a formatter that will eventually report a different total.
4. The Aggregate View a Team Can Act On
A team cannot act on 1,200 findings. It can act on “three rules account for 71% of them, two of which are one design-token change”. The aggregate view therefore counts distinct fingerprints, not rows: a rule that fires on 400 table cells on one route is one problem, and counting rows makes it look like four hundred.
-- Rule leaderboard: last 30 days on main, ranked by distinct defects.
SELECT f.rule_id,
MAX(f.rank) AS worst_rank, -- 4 critical .. 1 minor
COUNT(DISTINCT f.fingerprint) AS defects,
COUNT(DISTINCT f.route) AS routes,
MIN(date(r.started_at)) AS first_seen,
MAX(date(r.started_at)) AS last_seen
FROM findings f
JOIN runs r USING (run_id)
WHERE r.branch = 'main'
AND r.scan_status = 'complete'
AND f.status = 'violation'
AND r.started_at >= datetime('now', '-30 day')
GROUP BY f.rule_id
ORDER BY worst_rank DESC, defects DESC
LIMIT 12;
first_seen is the column that changes conversations. A rule whose first_seen is yesterday is a regression somebody can still remember writing; a rule whose first_seen is the day the store was created is debt, and belongs in a backlog rather than in a pull-request gate. Pair that split with the ratchet described in progressive threshold management so the budget only tightens against defects the team has actually retired.
Route hotspots are the second question, and they need the newest complete run rather than a window, because a route that was fixed last week should not still appear.
-- Route hotspots on the newest complete main run, worst impact first.
WITH latest AS (
SELECT run_id FROM runs
WHERE branch = 'main' AND scan_status = 'complete'
ORDER BY started_at DESC LIMIT 1
)
SELECT f.route,
SUM(CASE WHEN f.rank = 4 THEN 1 ELSE 0 END) AS critical,
SUM(CASE WHEN f.rank = 3 THEN 1 ELSE 0 END) AS serious,
COUNT(DISTINCT f.fingerprint) AS defects,
SUM(CASE WHEN f.status = 'incomplete' THEN 1 ELSE 0 END) AS needs_review
FROM findings f
WHERE f.run_id = (SELECT run_id FROM latest)
GROUP BY f.route
ORDER BY critical DESC, serious DESC, defects DESC
LIMIT 10;
Two guards keep these aggregates honest. Filter on scan_status = 'complete' everywhere, or a partial scan that only reached eight of forty routes will read as a spectacular improvement. And record the ruleset_hash alongside every chart: a count that rises the day the hash changes is coverage, not regression, and a trend line without that annotation will be argued about for a whole sprint. The sprint-level arithmetic on top of these rollups is covered in tracking accessibility violation trends across sprints, and the panel layer that renders them for a standing dashboard is covered in visualizing WCAG compliance trends with Grafana.
5. The Criterion Rollup a Compliance Owner Can Sign
The compliance view inverts the query. Instead of starting from findings and grouping upward, it starts from an inventory of every WCAG 2.2 success criterion in scope and joins findings onto it. That inversion is the whole point: a criterion with no findings is invisible in a findings-first query, and invisibility is exactly what must not be reported as a pass.
-- Criterion rollup for the newest complete main run, criteria first.
WITH latest AS (
SELECT run_id FROM runs
WHERE branch = 'main' AND scan_status = 'complete'
ORDER BY started_at DESC LIMIT 1
)
SELECT c.criterion,
c.level,
CASE WHEN c.automated_rules = 0 THEN 'not-tested'
WHEN COUNT(fc.fingerprint) > 0 THEN 'fail'
ELSE 'no-automated-failure' END AS status,
COUNT(fc.fingerprint) AS failing_nodes,
(SELECT run_id FROM latest) AS evidence_run
FROM criterion_inventory c
LEFT JOIN finding_criteria fc
ON fc.criterion = c.criterion AND fc.run_id = (SELECT run_id FROM latest)
GROUP BY c.criterion, c.level, c.automated_rules
ORDER BY c.criterion;
The third status value is deliberate and non-negotiable. no-automated-failure is not pass: automated scanners reliably detect roughly 30–40% of WCAG failures, and for criteria such as SC 1.1.1 (Non-text Content) a scanner can prove an alt attribute exists while being completely unable to judge whether it describes the image. A rollup that collapses “no rule looked” and “a rule looked and found nothing” into a green cell produces a conformance claim the organisation cannot defend. Building that inventory, deciding which criteria genuinely have automated coverage, and shipping the export with its not-tested column intact is the subject of exporting accessibility results to compliance dashboards.
Pipeline Integration
Scanning and reporting belong in separate jobs. The scan job may fail; the report job must still run, because the runs that break are the runs whose data matters most. Split them with needs and if: always(), and let the raw JSON travel between them as a short-retention artifact.
name: a11y-findings-store
on:
push:
branches: [main]
pull_request:
concurrency:
group: a11y-findings-${{ github.ref }}
cancel-in-progress: true
jobs:
scan:
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Scan every route in the manifest
run: npx playwright test tests/a11y --reporter=line
- uses: actions/upload-artifact@v4
if: always()
with:
name: axe-raw
path: axe-raw/
retention-days: 7 # disposable once normalised
report:
needs: scan
if: always() # a red scan still owes a findings row
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- uses: actions/download-artifact@v4
with:
name: axe-raw
path: axe-raw
- name: Restore the findings store
uses: actions/cache@v4
with:
path: a11y.db
key: a11y-store-${{ github.run_id }}
restore-keys: a11y-store- # newest prior store wins
- name: Normalise
env:
A11Y_EXPECTED_ROUTES: '40' # from the route manifest; drives scan_status
run: node a11y/report/normalise.mjs axe-raw/*.json > findings.ndjson
- name: Load
run: node a11y/report/load.mjs findings.ndjson a11y.db
- name: Author summary
run: |
node a11y/report/pr-summary.mjs findings.ndjson a11y.db main \
>> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v4
with:
name: a11y-findings
path: |
findings.ndjson
a11y.db
retention-days: 30
Nothing in the report job sets the job status. The blocking decision stays in the scan job’s assertion, or in the dedicated gate step described in auto-fail vs warning workflows, so a Slack outage or a locked database can never turn a green build red. Wrap any network call in the report job so its failure is logged and swallowed.
Troubleshooting and Flaky-Test Mitigation
Fingerprint churn on virtualised lists. A defect that reappears with a new fingerprint every run makes first_seen always today and the new-versus-existing split useless. Confirm it by grouping the last ten runs by (route, rule_id) and comparing the distinct fingerprint count against the per-run node count; if they differ by an order of magnitude, the stripping rules are too narrow. Add the framework’s generated-id pattern to stableSelector and, for genuinely windowed content, scan the row component in isolation rather than the list.
Duplicate rows after a re-run. GITHUB_RUN_ID alone is not unique across re-runs of the same workflow, so a re-run overwrites the first attempt’s row while its findings insert alongside. Compose the run id from GITHUB_RUN_ID and GITHUB_RUN_ATTEMPT, as the normaliser above does, and treat the attempts as separate runs.
Zero violations that are actually zero routes. A crashed scan and a clean scan produce the same empty violations array, and the empty one will be charted as a triumph. That is what scan_status and routes_scanned exist for: assert routes_scanned = A11Y_EXPECTED_ROUTES before writing a rollup, and exclude partial runs from every aggregate.
Trend steps the day a rule set changes. Every count is conditional on the enabled rules. Store ruleset_hash per run, and when a chart shows a step change, check the hash before opening a bug. Enabling one new serious rule across forty routes routinely adds several hundred findings with no code change at all.
Incomplete results leaking into violation counts. A rule that returns undefined frequently — colour contrast against a background image is the classic — will double a chart if status is not filtered. Every aggregate in this guide filters status = 'violation' explicitly; the incomplete rows stay in the table as a review queue.
Route labels drifting after a router change. When a framework starts appending a trailing slash or a locale prefix, yesterday’s /pricing and today’s /en/pricing are two routes and every route trend breaks. Derive the route from a route manifest the application already owns rather than from the scanned URL when the app has one, and treat a sudden jump in distinct route count as a data bug rather than a coverage win.
Clock skew across shards. Ordering runs by started_at from results.timestamp mixes clocks from several containers. Order by run id where possible, and use started_at only for date bucketing, where a few seconds of skew cannot change the answer.
Common Pitfalls
- Storing the whole result object per run, then discovering the first trend question requires re-parsing every blob in the bucket.
- Using the raw
node.targetselector as the identity of a defect, so a sibling insertion reads as one fix and one new violation. - Comma-joining the criteria into the findings row, which turns every criterion query into a
LIKEthat also matches the wrong criterion. - Sorting or maximising over the impact string, where
criticalsorts beforeminorand every leaderboard silently lies. - Deleting node detail before the nightly rollup has run, which loses a day of trend data that cannot be recomputed.
- Counting rows instead of distinct fingerprints, so one rule firing on a 400-cell table outranks a critical failure on the checkout page.
- Reporting
no automated failureas a passing WCAG criterion, which converts a tooling limitation into a conformance claim. - Letting the reporting job set the build status, so a database lock or a webhook timeout blocks a merge that had no accessibility problem.
FAQ
Why store incomplete results at all if they never fail the build?
Because they are the highest-yield manual-review queue available for free. An incomplete result means a rule matched an element and could not reach a verdict, which is a much stronger signal than “somebody should audit this page”. Keeping them in the same table with status = 'incomplete' lets you query the twenty elements most worth a human’s twenty minutes, while every count-based aggregate filters them out.
SQLite or Postgres for the findings store?
SQLite for a single repository with one pipeline: it is one file, it caches and uploads as an artifact, and the queries in this guide run unchanged. Move to Postgres when several repositories write to one store, when two jobs can load concurrently, or when the dashboard needs to read while a load is in progress. The schema is identical apart from TEXT timestamps becoming timestamptz, so migrating is a dump and a load rather than a redesign.
How long should node-level detail actually live? Long enough to cover the slowest realistic fix cycle plus one sprint, which for most teams is three to six weeks; 30 days is a good default. The test is whether anyone has ever queried a selector older than the window. Nobody debugs a selector from four months ago — by then the element has been rewritten twice — but everybody charts rule counts from four months ago, which is why the rollup outlives the detail.
Can the same store hold Lighthouse or pa11y results?
Yes, if the normaliser maps them into the same record shape and the differences are recorded rather than hidden. Give each run a scanner column, keep the rule id in the tool’s own namespace so color-contrast from axe and Lighthouse’s contrast audit stay distinguishable, and never mix the two in one count. The comparison between what the tools actually detect is set out in the accessibility testing fundamentals and tool selection section; the store’s job is to keep them separable, not to reconcile them.
Does the fingerprint need to be a cryptographic hash?
No — collision resistance against an adversary is irrelevant here, and a truncated SHA-1 is used only because node:crypto makes it a one-liner. What matters is that the hash inputs are chosen so that the same defect produces the same digest across runs and two different defects do not collide within one route. If a shorter human-readable key is preferable for debugging, concatenating route, rule id and the stripped selector works identically; it is just verbose in a table cell.
Related
- CI/CD Integration & Automated Quality Gating — the parent section covering gates, thresholds and branch policies end to end.
- Exporting Accessibility Results to Compliance Dashboards — turning the criterion rollup into a signed export with a not-tested column.
- Structuring JSON Violation Output for Slack and GitHub Annotations — the two message payloads these records feed.
- Tracking Accessibility Violation Trends Across Sprints — sprint arithmetic on top of the rule-level rollup.
- Visualizing WCAG Compliance Trends with Grafana — panels and alert rules over the same store.
- Progressive Threshold Management — ratcheting a budget using the new-versus-pre-existing split.