Structuring JSON Violation Output for Slack and GitHub Annotations
Raw axe-core output is a deeply nested array of violations, each with multiple nodes, and posting it directly to a chat channel or a pull request is useless noise. This page is part of Reporting, Dashboards & Violation Tracking, and it shows the exact transform from results.violations into two human-facing surfaces: GitHub Actions annotations that render inline on changed files, and Slack Block Kit messages grouped by impact (critical, serious, moderate, minor).
- Read axe-core
results.violationsand flatten to one record per affected node - Emit
::error file=...workflow commands so GitHub annotates the diff - Build a Slack Block Kit payload bucketed by impact level
- Validate both outputs locally before wiring them into CI
Root Cause / Context
The default axe-core reporters are built for machines, not reviewers. The json reporter nests every rule violation under violations[].nodes[], where the actionable detail — the CSS selector and the failure summary — lives two levels deep. GitHub annotations and Slack messages each want a flat, severity-ordered list, and neither accepts the nested structure as-is.
A second mismatch is the file pointer. GitHub annotations attach to a file and line, but a DOM scanner only knows a CSS selector, not a source line. The honest approach is to anchor annotations to a stable scan-manifest file and surface the selector in the message body, rather than fabricate line numbers that drift on every edit.
Configuration
The transformer reads the axe JSON, sorts violations by a fixed impact order, and writes both outputs. Run it after your scan step. Severity ordering ties directly into how you set blocking versus non-blocking behavior in auto-fail vs warning workflows.
// transform.js — axe results.violations -> GitHub annotations + Slack blocks
const fs = require('fs');
const ORDER = ['critical', 'serious', 'moderate', 'minor'];
const EMOJI = { critical: ':red_circle:', serious: ':large_orange_circle:',
moderate: ':large_yellow_circle:', minor: ':white_circle:' };
const results = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const violations = [...results.violations].sort(
(a, b) => ORDER.indexOf(a.impact) - ORDER.indexOf(b.impact)
);
// (a) GitHub annotations — anchor to the manifest, surface selector in the message
// GitHub renders only ~10 annotations of each level, so cap the loud ones.
const MANIFEST = 'a11y-scan.json';
const lines = [];
let emitted = 0;
for (const v of violations) {
for (const node of v.nodes) {
if (v.impact === 'critical' && emitted >= 10) break; // avoid annotation truncation
const sel = node.target.join(' ').replace(/\n/g, ' ');
// ::error sets a red annotation; title shows in the PR "Files changed" tab
lines.push(`::error file=${MANIFEST},title=${v.id} (${v.impact})::${v.help} — selector: ${sel}`);
emitted++;
}
}
fs.writeFileSync('annotations.txt', lines.join('\n') + '\n');
// (b) Slack Block Kit — one section per impact bucket
const buckets = ORDER.map(impact => ({
impact,
items: violations.filter(v => v.impact === impact),
})).filter(b => b.items.length);
const blocks = [{
type: 'header',
text: { type: 'plain_text', text: `Accessibility scan: ${results.violations.length} rules failed` },
}];
for (const b of buckets) {
const count = b.items.reduce((n, v) => n + v.nodes.length, 0);
const rules = b.items.map(v => `• \`${v.id}\` (${v.nodes.length})`).join('\n');
blocks.push({
type: 'section',
text: { type: 'mrkdwn', text: `${EMOJI[b.impact]} *${b.impact}* — ${count} nodes\n${rules}` },
});
}
fs.writeFileSync('slack-blocks.json', JSON.stringify({ blocks }, null, 2));
console.log(`Wrote ${lines.length} annotations and ${blocks.length} Slack blocks`);
Wire it into the workflow. Reading annotations.txt back to stdout is what makes the runner render them on the diff.
- name: Transform a11y violations
if: always()
run: node transform.js axe-results.json
- name: Emit GitHub annotations
if: always()
run: cat annotations.txt # printing the ::error lines triggers annotation render
- name: Post Slack message
if: always()
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
curl -sS -X POST -H 'Content-Type: application/json' \
--data @slack-blocks.json "$SLACK_WEBHOOK_URL" # -sS keeps logs quiet but shows errors
Validation
Confirm both artifacts have the expected shape before trusting them in CI. The Slack payload must be valid JSON with a non-empty blocks array, and the annotation file must start with ::error.
# 1. Slack payload parses and has blocks
node -e "const b=require('./slack-blocks.json'); if(!b.blocks.length) throw new Error('no blocks'); console.log('blocks OK:', b.blocks.length)"
# 2. Every annotation line is a well-formed workflow command
grep -cE '^::error file=' annotations.txt # count must match emitted annotations
! grep -vE '^::error file=' annotations.txt # exit non-zero if any malformed line exists
Edge Cases & Conditional Guards
- Zero violations: the transform writes an empty
annotations.txtand a header-only Slack block; guard the Slack post with a check so you do not spam a channel with “0 failed” on green builds unless you want the heartbeat. - Selectors containing commas or newlines: axe targets from virtualized or shadow-DOM trees can include characters that break the
::errorcommand parser; the transform replaces newlines and you should additionally strip commas inside the title. - Impact
null: experimental rules sometimes reportimpact: null; bucket these asminorrather than dropping them so they still appear in the report.
Pipeline Impact
These steps are reporting-only and use if: always() so they run even after the gate fails. They do not change the job’s exit code — the blocking decision stays in your gate step. The annotations.txt and slack-blocks.json files are worth uploading as artifacts so a reviewer can inspect the exact payload that produced a given PR’s annotations.
Common Pitfalls
- Printing the raw JSON to stdout and expecting annotations — only lines matching the
::error/::warningcommand syntax are rendered. - Exceeding GitHub’s per-level annotation cap, which silently truncates; cap output and link to the full artifact.
- Forgetting to escape selectors, producing malformed workflow commands that the runner ignores without warning.
- Posting more than ~50 blocks to Slack, which Block Kit rejects; summarize counts per rule instead of one block per node.
FAQ
Why can’t GitHub annotations point at the real DOM element? Annotations attach to a source file and line, but a DOM scanner only knows a runtime CSS selector. Anchor the annotation to a stable scan-manifest file and put the selector in the message so reviewers still get the locator.
How do I group by impact without losing the node count?
Sort violations by a fixed impact order, then for each bucket sum nodes.length across its rules. The rule list shows which checks failed; the node count shows how widespread each one is.
Can I send the same payload to Microsoft Teams?
Teams uses a different card schema (Adaptive Cards), so the Slack blocks array will not render. Keep the normalized buckets and write a second small adapter that maps each bucket to a Teams TextBlock.
Related
- Reporting, Dashboards & Violation Tracking — the parent guide tying normalization to all four destinations.
- Auto-Fail vs Warning Workflows — using the same impact buckets to decide what blocks a build.
- axe-core Configuration & Setup — producing the
results.violationsJSON this transform consumes.