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.violations and 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
Violation transform results.violations is grouped by impact, then split into GitHub error annotations and a Slack Block Kit payload. results.violations nested JSON Group by impact critical → minor GitHub annotations ::error file=... Slack Block Kit blocks[]
One pass over violations feeds both the PR annotation and the Slack message.

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.txt and 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 ::error command parser; the transform replaces newlines and you should additionally strip commas inside the title.
  • Impact null: experimental rules sometimes report impact: null; bucket these as minor rather 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/::warning command 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.