Automated Remediation & Accessibility Fixing Patterns

A pipeline that reports 214 accessibility violations and stops there has moved the work rather than done it: the report becomes a ticket, the ticket becomes a quarter, and the same rule ids fire again on the next release. This section covers the layer that runs after detection — deciding which findings carry a deterministic repair, applying that repair to source, proving it cleared the exact finding it claimed to clear, and putting the diff in front of a human who can be held responsible for the words in it.

Key implementation targets:

  • A normalised violation report where every finding carries a stable fingerprint plus a source file and line, so a fix is attributable to code rather than to a brittle CSS selector.
  • A classifier that routes each rule id to one of four strategies: deterministic codemod, gated text suggestion, component-level default, or a scored ticket with an owner.
  • Report-driven transforms — jscodeshift for JSX attribute surgery, ts-morph when the fix has to cross a component boundary — with a dry-run diff, a blast-radius cap, and one commit per rule id so a rollback is a single git revert.
  • A verification step that refuses to open a pull request unless every claimed-fixed fingerprint has disappeared and no new rule id has appeared anywhere in the scanned routes.
  • A generated regression spec per healed violation and an accessibility-tree snapshot per repaired component, so today’s fix is tomorrow’s assertion.
  • A debt ledger with an impact score per finding, an owning team resolved from CODEOWNERS, and two separate quarterly series: new violations introduced, and backlog remaining.
From one violation report to a merged or discarded fix Two hundred and fourteen tracked findings are classified by rule id and evidence, split across a codemod path, a gated LLM draft path, a component-default path and a triage queue, then collected into a single verification gate whose exit code decides whether a pull request is opened or the sweep is thrown away. One report, four strategies, one gate Violation report — 214 tracked findings fingerprint · rule id · route · source location Classify by rule id and evidence mechanical · text needed · structural · unknown Codemod AST rewrite 41 findings LLM draft gated text 23 findings Component default prop 88 findings Triage queue owner + score 62 findings Verification gate — re-scan the same routes with the same rule set every claimed fingerprint gone · no new rule id · snapshots refreshed exit 0 — open the PR review + regression specs exit 1 or 2 — no branch sweep discarded, logs kept
The gate is the only thing standing between a machine edit and a branch, which is why it re-scans rather than trusting the transform's own report of success.

Detection is not this section’s subject. Which rules run, how thresholds ratchet and how a failing scan blocks a merge belong to CI/CD integration and automated quality gating, and the rule set itself is configured in axe-core configuration and setup. Remediation consumes what those produce: a machine-readable report with rule ids, impacts and node targets. Everything below assumes that report already exists and is trustworthy, because a remediation pipeline built on a noisy scan will confidently repair things that were never broken.

Five approaches compete for each finding, and they are not interchangeable. The table compares them on the four axes that decide where a rule id gets routed: how much of WCAG the approach can touch at all, what it costs to wire into a pipeline, the risk that it produces a passing scan with a worse user experience, and whether the approach can be extended with repairs of your own.

Tool / approach WCAG coverage CI integration effort False-positive / regression risk Custom-rule support
Hand fix by a developer Any criterion, including intent None — it is a normal pull request Lowest; a human saw the screen Not applicable — the reviewer is the rule
Report-driven codemod Mechanical SC 1.1.1, 1.3.1, 3.3.2, 4.1.2 Medium — transform, fixtures, dry-run job Low if idempotent; high if the selector drifts Full — the transform is your code
AI-suggested fix + verification Name and text criteria only, ~10% of findings High — prompt, schema, gate, ledger Highest; a wrong name silences the rule Prompt constraints, not rules
Design-system default Prevents whole classes at every call site High upfront, near zero ongoing Very low; one review covers N call sites Full, at the type and prop level
Lint rule (jsx-a11y) Source-visible markup only, pre-render Low — runs in the existing lint job Moderate; spread props defeat it Full, but AST-level not DOM-level

Core Principles

Detection is cheap and fixing is not, and every design decision in this section follows from that asymmetry. A full axe pass over twelve routes costs about ninety seconds of runner time and produces a complete list of what is wrong; adding one aria-label costs a decision about what a control does, which needs someone who knows the product. Teams routinely scale the cheap half — more routes, more locales, nightly crawls — and then discover the expensive half does not scale with it: the report grows, the fix rate does not, and the backlog becomes a number nobody believes. The only durable answers are to make a subset of fixes genuinely mechanical, and to stop the inflow at the component, which is what the rest of this page is about.

Never merge a machine-generated fix without a verifying assertion. The rule is stronger than “run the scanner again at the end”: the pipeline must be able to name, per finding, the exact assertion that proves that finding is gone. That is why every finding gets a fingerprint before any transform runs — a hash of route, rule id and source location — and why the verification step compares fingerprint sets rather than violation counts. Counts lie in both directions: a transform can heal three findings and introduce two, and the total drops by one while the page got worse. A fingerprint-level diff makes both halves visible, and it is what lets the workflow distinguish “the fix did not work” (exit 1) from “the fix broke something new” (exit 2).

Fix upstream at the component rather than at every call site. When 88 of 214 findings trace back to six components, a codemod that patches 88 call sites is the wrong instrument even though it works: the next feature branch adds call site 89, and the sweep runs forever. Patching the component instead converts a recurring remediation cost into a one-time migration plus a type error at every future misuse. The exception is the case where the component is correct and the call sites are wrong — an IconButton that accepts an optional label prop nobody passes — and even then the right sequence is to make the prop required first, let the compiler enumerate the call sites, and then codemod them with the type error as the worklist.

The automated-versus-manual split sets the ceiling on all of this. Automated scanners reliably detect roughly 30–40% of WCAG failures; the rest — whether alt text is meaningful, whether the focus order matches the visual order, whether an error message explains how to recover — needs a person. Safe automatic repair is a strict subset of that subset. Of the tracked rule ids in a typical report, the ones with a deterministic repair (duplicate-id-aria, aria-allowed-attr, presentation-role-conflict, label where a visible label already exists) usually account for a quarter to a third of findings by volume, which puts machine-fixable failures somewhere around 10% of all WCAG failures on a real codebase. Plan the pipeline around that number rather than around a demo where every violation happened to be a missing htmlFor.

An aria-label guess is worse than a visible failure, and this is the principle teams learn the hard way. A missing accessible name is detected by button-name on every scan, in every environment, forever; it is a loud, cheap, permanent signal. A wrong accessible name passes every automated check ever written, because no scanner has an opinion about whether “Submit” is the right name for a delete button. Guessing therefore converts a detectable defect into an undetectable one and simultaneously removes the pressure to fix it — the report goes green and the screen-reader user is worse off than before, because a confidently wrong name is harder to work around than no name at all. Anything that writes user-facing words gets a human signature; anything that only moves existing words around can be automated.

The safe-to-automate spectrum by rule id A left-to-right axis runs from repairs provable from the DOM to repairs that need a human sentence. The left zone holds duplicate-id-aria, aria-allowed-attr, presentation-role-conflict and decorative image-alt. The middle zone holds label, label-title-only, form-field-multiple-labels and select-name. The right zone holds button-name, link-name, informative image-alt and th-has-data-cells. What is safe to automate, by rule id provable from the DOM needs a human sentence Rewrite and merge no semantics involved Rewrite, then verify context decides the fix Never auto-fix words must be written duplicate-id-aria aria-allowed-attr presentation-role-conflict image-alt (decorative) fix inside the sweep label label-title-only form-field-multiple-labels select-name fix, prove, then review button-name (icon only) link-name (read more) image-alt (informative) th-has-data-cells score it and assign it Volume never moves a rule id to the left; it only changes when the fix gets scheduled.
The zone is a property of the rule id and the evidence available in the DOM, not of how many instances a sweep happens to find.

Codemod-Driven Accessibility Fixes

A codemod is the only remediation strategy that is reproducible by construction: parse source to a syntax tree, edit named nodes, print the tree back. The same input produces the same diff on every machine, which is what makes a 200-file pull request reviewable at all — a reviewer who has verified the transform’s behaviour on three files can reason about the rest. Codemod-driven accessibility fixes covers the toolchain in depth; the two questions that matter at this level are which tool fits the edit and how the edit is targeted.

jscodeshift is the right tool when the fix is attribute surgery inside one file: add alt="", add htmlFor, delete a role that duplicates an implicit role. It prints through recast, so untouched nodes keep their original formatting and the diff contains only the lines that changed. ts-morph earns its extra weight when the fix crosses a boundary — adding a required prop to a component’s interface and then threading it through every consumer, or renaming a prop that carries an accessible name — because it holds the whole project graph and can resolve which JSX elements are that component rather than any element with a matching tag name. Reach for ESLint’s own autofix when the failure is already expressible as a lint rule, since that puts the repair in the editor instead of in a sweep.

Targeting is the part teams get wrong. A blanket transform over src/ finds every element matching a syntactic pattern, including the ones that were already correct in ways the pattern cannot see, and its diff is proportional to the codebase rather than to the problem. A report-driven transform starts from the violation report and only visits the files and lines that a scanner actually flagged, which shrinks the diff by an order of magnitude and — more importantly — gives every hunk a fingerprint that the verification step can check. Getting from a DOM node to a source line needs help from the build: a small Vite or Babel plugin that stamps data-src-loc="src/Signup.tsx:41:7" on JSX elements in non-production builds is enough, and it is the single highest-leverage piece of infrastructure in this whole section.

Report-driven label association, line by line The left panel shows a label and an input at line 41 of Signup.tsx with no shared identifier and a critical label violation. The right panel shows the same two elements after the transform, with htmlFor and id both set to email-9c4a. Two bands below explain how the id suffix is derived and why the edit is additive and revertible. One finding, two attributes, no moved nodes before — src/Signup.tsx:41 <label>Email address</label> <input type="email" name="email" /> no htmlFor — rule id: label critical · SC 1.3.1 and SC 4.1.2 ts-morph one node after — two attributes added <label htmlFor="email-9c4a"> Email address</label> <input id="email-9c4a" type="email" name="email" /> label now names the control accessible name: Email address id suffix = sha1(file + line).slice(0, 4) — stable across reruns, unique across files the transform bails out on aria-label, aria-labelledby, or a wrapping label purely additive: two attributes, no nodes moved, one commit to revert verification requires fingerprint 4b91c0e2f7a1 to be absent from the after report
Because the edit only adds attributes and derives the id from the file and line, running the sweep twice produces the same identifier and the second run produces no diff at all.

Dry-run review is a process requirement, not a courtesy. Run the transform with --dry --print, save the printed output as a build artifact, and read it before anything touches a branch — the failure mode a codemod has is not crashing, it is succeeding on nodes you did not intend. Cap the blast radius per run: one rule id per invocation, one commit per rule id, and a hard ceiling on files touched (40 is a workable default) so a misfire is a small revert rather than an archaeology project. The two focused walkthroughs under this topic are worth reading in order — automating alt-text remediation with codemods for the case where the transform deliberately leaves CI red until a human writes real text, and bulk-fixing form-label associations with jscodeshift for the guard conditions that stop a transform from “fixing” already-correct markup.

Two properties make a transform safe to ship: idempotence and formatting discipline. Prove idempotence in CI by running the codemod twice and asserting that git diff --quiet succeeds after the second run; a transform that keeps appending suffixes or re-wrapping nodes will fail this immediately and would otherwise produce mystifying diffs three sweeps later. For formatting, never run a project-wide formatter after a sweep — pipe git diff --name-only into the formatter so only the files the transform touched get reformatted, otherwise the accessibility diff drowns in whitespace and the reviewer stops reading.

AI-Assisted Accessibility Remediation

There is a residue that codemods cannot touch: an icon-only button with no text anywhere near it, an informative chart image, a link whose visible text is “read more”. These need a sentence, and no amount of AST work invents one. AI-assisted accessibility remediation is where a model drafts that sentence — and where the pipeline treats the draft as an untrusted input that has to earn its way into a diff.

Constrain the prompt until the model has almost no room to be creative. Send the failing element’s subtree, its nearest visible text, the component name, the rule id and the scanner’s own failure message; ask for a single JSON object keyed by fingerprint with a name field and a mandatory evidence field quoting the text in the subtree that justifies the name. Forbid role nouns in the output — a name of “Close button” is announced as “Close button, button” — cap the length at around 80 characters, require the language to match the document’s lang, and define an explicit refusal token so the model can say NEEDS_HUMAN when the subtree contains no evidence at all. That refusal path is the most valuable part of the prompt: a model that never refuses is a model that always guesses, and the guesses are indistinguishable from the good answers until a screen-reader user hits one.

The gate then re-derives everything it can rather than trusting the response. Validate the JSON against a schema; reject any name containing a role noun, exceeding the length cap, duplicating a sibling’s accessible name, or failing to contain the visible label text when one exists (WCAG 2.2 SC 2.5.3, Label in Name, applies the moment there is visible text); apply the patch on a scratch branch; re-scan and require both that the original fingerprint is gone and that no new rule id appeared; and diff the accessibility-tree snapshot so the reviewer sees the computed name change rather than the source change. Only then does the suggestion surface — and it surfaces as a review suggestion attributed to the model in a commit trailer, never as an anonymous edit inside a large sweep.

An AI-drafted accessible name passing its verification gate Four lifelines: the remediation orchestrator, the model, the verification gate and the reviewer. Messages run in order: subtree and rule id to the model, a candidate JSON back, the patch applied on a scratch branch, mechanical checks and a re-scan inside the gate, then either a suggestion to the reviewer on success or a discard with a NEEDS_HUMAN marker back to the orchestrator on failure. An AI-drafted name passing the gate remediate.mjs orchestrator model name candidate verify gate checks + re-scan reviewer from CODEOWNERS 1 subtree + rule id 2 candidate JSON 3 patch on a scratch branch 4 checks + re-scan 5 pass — suggestion 6 fail — discard, mark NEEDS_HUMAN all four gate checks must pass: name under 80 chars no role noun matches document lang re-scan: rule cleared
Step six is the step that keeps the pipeline honest: a discarded candidate leaves the original violation in the report, where it stays visible until a person fixes it.

Keep a ledger of what the model proposed and what humans did with it. Two numbers matter: acceptance rate (suggestions merged unchanged) and rewrite rate (suggestions merged after a human changed the words). If acceptance drops below roughly half, the step is generating review work instead of saving it and should be switched off for that rule id — an outcome worth measuring rather than assuming. The mechanics of the constrained prompt are worked through in using LLMs to suggest ARIA labels safely, and the CI half — schema validation, re-scan, and the exit codes that stop a candidate from reaching a branch — in validating AI-generated ARIA fixes in CI.

One structural pattern is worth adopting whatever model you use: let the codemod own the code and the model own the prose. The transform inserts the attribute with a placeholder that keeps the scanner red (alt="TODO: describe this image" fails image-alt on purpose), the model’s candidate text lands in the pull-request body as a suggested change, and a reviewer accepts it with one click. The machine has then done all the mechanical work — finding the node, editing the tree, wiring the id, opening the branch — while every user-facing word still carries a human’s name in the git history.

Regression Prevention After Fixes

A remediation sweep that is not followed by an assertion is a loan, not a payment. The component gets refactored, the wrapper <label> becomes a <div>, the icon button loses its aria-label in a props cleanup, and the finding comes back — often on a route the pull-request scan does not cover, so it reappears silently and lands in the next quarterly report as if nobody had ever fixed it. Regression prevention after fixes is the discipline that turns each fix into a permanent invariant.

The strongest guard is the accessibility tree itself, because that is the thing the fix actually changed. A snapshot of computed names, roles and states fails loudly on any change that alters what assistive technology is told, including changes that leave every axe rule green. Scope snapshots to a component subtree rather than a whole page: a page-level snapshot churns on every marketing copy edit and gets updated reflexively with --update-snapshots, which is exactly how a guard becomes a rubber stamp. The serialisation details and the update workflow are covered in snapshot testing accessibility trees to prevent regressions.

Alongside the snapshot, generate one narrow regression test per healed finding, straight from the sweep’s manifest. The value is traceability: the spec name carries the rule id, the source location and the fingerprint, so when it fails in eight months the failure message points at the original remediation pull request instead of at a mystery. Generating them is a twenty-line script, and the generated file is committed so the tests survive independently of the sweep that produced them.

// a11y/remediate/emit-tests.mjs — one narrow spec per healed finding
import { readFileSync, writeFileSync } from 'node:fs';

const manifest = JSON.parse(readFileSync('a11y/reports/applied.json', 'utf8'));
const healed = manifest.filter((entry) => entry.fix !== 'needs-human');

const cases = healed.map((entry) => `
test('${entry.rule} stays fixed at ${entry.source} [${entry.id}]', async ({ page }) => {
  await page.goto('${entry.route}');
  const results = await new AxeBuilder({ page })
    .withRules(['${entry.rule}'])              // only the rule this sweep healed
    .include(${JSON.stringify(entry.target)})  // and only the node it touched
    .analyze();
  expect(results.violations).toEqual([]);
});`);

writeFileSync(
  'tests/a11y/remediated.spec.ts',
  `// GENERATED by a11y/remediate/emit-tests.mjs — commit this file.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
${cases.join('\n')}
`,
);
console.log(`wrote ${healed.length} regression specs`);

The cheapest guard runs before any browser starts. eslint-plugin-jsx-a11y catches the source-visible half of the failure classes a sweep just healed, in the editor, on every keystroke, at zero pipeline cost — and after a remediation sweep you know exactly which rules to promote to errors, because they are the rule ids the sweep touched. Promote those and leave the rest as warnings; the lint rules that reason about spread props (role-has-required-aria-props in particular) cannot resolve {...props} and will otherwise produce failures nobody can act on. The complementary per-violation approach, including how to keep the generated specs from becoming an unmaintained pile, is in adding a regression test for every fixed violation.

// eslint.config.js — the source-visible half of the guard
import jsxA11y from 'eslint-plugin-jsx-a11y';

export default [
  {
    files: ['src/**/*.{jsx,tsx}'],
    plugins: { 'jsx-a11y': jsxA11y },
    rules: {
      // Errors: these mirror the rule ids the last sweep actually healed.
      'jsx-a11y/alt-text': ['error', { elements: ['img', 'object', 'area'] }],
      'jsx-a11y/label-has-associated-control': ['error', { assert: 'either' }],
      'jsx-a11y/anchor-has-content': 'error',
      'jsx-a11y/aria-props': 'error',            // catches attribute typos outright
      // Warning: unresolvable through {...props}, so an error would be noise.
      'jsx-a11y/role-has-required-aria-props': 'warn',
    },
  },
];

Do not mistake a violation baseline for a guard. A baseline file records the failures you have agreed to tolerate, which is a scheduling artifact; it lets every one of those failures through on every run. Baselines belong next to the ratchet described in progressive threshold management, where the tolerated count only ever goes down. The snapshot and the generated specs are what make a specific fix non-negotiable.

Design-System Accessibility Defaults

The cheapest violation is the one a component cannot emit. When Image will not compile without either an alt string or an explicit decorative flag, image-alt stops appearing in reports — not because the scanner got quieter but because the failing markup can no longer be written. Design-system accessibility defaults is remediation moved as far left as it goes: one review of one component replaces a recurring sweep over hundreds of call sites.

Type-level guards do the work that lint rules cannot, because the compiler sees through indirection that an AST walker does not. A discriminated union is usually all it takes: make the accessible name a required field in one branch and forbid it in a branch that explicitly opts into decoration. The result is that “I forgot the alt text” becomes a build error at the call site, with a message pointing at the line, before any scanner runs.

// packages/ui/src/Image.tsx — alt is a decision the caller must make
type Decorative = { decorative: true; alt?: never };
type Informative = { decorative?: false; alt: string };

export type ImageProps = (Decorative | Informative) & {
  src: string;
  width: number;
  height: number;
};

export function Image(props: ImageProps) {
  const { src, width, height } = props;
  // alt="" is the only alt value a machine may choose, and only because the
  // caller declared the image decorative at the type level rather than by
  // omitting a prop. Omission is a compile error, not a silent empty string.
  const alt = props.decorative ? '' : props.alt;
  return <img src={src} width={width} height={height} alt={alt} />;
}

Types stop at the boundary of what the compiler can see, so pair them with a rendered audit. Every component state gets a story, the static Storybook build is scanned in CI, and the job fails on any serious or critical finding in a component the design system owns — which catches the failures that only exist once styles and portals are applied: a focus ring removed by a reset, a dialog rendered outside the landmark structure, a disabled control that is invisible to the accessibility tree. Auditing a component library with Storybook and axe covers the runner setup and the per-story allowlist that lets an in-progress component ship without disabling the gate for everyone. The prop contracts, required-name patterns and focus-management defaults themselves are in enforcing accessible component defaults in a design system.

Sequence the migration so the compiler writes your worklist. Ship the stricter type in a minor release with the old shape deprecated, run tsc to enumerate every failing call site, feed that list into a report-driven codemod, and only then remove the deprecated shape. This is also the cleanest prioritisation signal available: a component with 88 call sites carrying findings is worth a week of design-system work, while a component with two is worth two hand fixes. Counting call sites cleared per component fix keeps the design-system backlog ordered by something other than enthusiasm.

Accessibility Debt Triage & Prioritization

Some findings will not be fixed this quarter, and pretending otherwise is how a remediation programme loses credibility. What separates managed debt from an ignored report is that every item has a score, an owner and a decision. Accessibility debt triage and prioritization covers turning the leftover pile — the 62 findings the sweep classified as needing a human — into a queue that teams actually work.

Score with a formula that combines severity with reach and cost, and recompute it every sweep so the ordering tracks reality rather than the day someone filed the ticket. Impact supplies the base weight, traffic supplies reach on a log scale so one popular route does not swamp everything, a multiplier marks findings that sit on a task the user cannot complete another way, and fix cost divides — a codemod-able finding is cheaper than one needing a written sentence and should sort above it at equal impact.

// a11y/triage/score.mjs — recomputed for every finding on every sweep
const IMPACT = { critical: 10, serious: 6, moderate: 3, minor: 1 };
const BLOCKS_TASK = 2.5; // the user cannot complete this step another way

export function score(finding, weeklySessions, funnelRoutes) {
  // log10 keeps a 400k-session route from burying every internal admin page.
  const reach = Math.log10(1 + (weeklySessions[finding.route] ?? 0));
  const blocking = funnelRoutes.has(finding.route) ? BLOCKS_TASK : 1;
  const cost = finding.fix === 'needs-human' ? 2 : 1;
  const raw = (IMPACT[finding.impact] * reach * blocking) / cost;
  return Math.round(raw * 10) / 10;
}

export function rank(findings, weeklySessions, funnelRoutes) {
  return findings
    .map((f) => ({ ...f, score: score(f, weeklySessions, funnelRoutes) }))
    .sort((a, b) => b.score - a.score);
}

Ownership is the step that decides whether the queue moves. Because every finding carries a source path, the path can be matched against CODEOWNERS to resolve an owning team, and the ticket can be filed in that team’s queue with the score, the route, the rule id and the fingerprint attached. Findings whose path matches no owner are the most useful output of the whole exercise: unowned code is where debt accumulates fastest, and routing those to the platform or design-system backlog turns an orphan list into a component roadmap. The matching rules, including how to handle glob precedence and multi-team ownership, are in assigning violation ownership with CODEOWNERS.

Track two series, never one. New violations introduced per quarter measures prevention and belongs at zero, enforced by the pull-request gate; backlog remaining measures reduction and is expected to fall. Reporting only the total confuses the two and hides the failure mode that kills programmes: a team fixing 40 findings a quarter while introducing 47 is working hard and going backwards. Get prevention flat first — that is what the lint rules, component defaults and generated specs above are for — and only then does a burn-down commitment mean anything. Burning down an accessibility backlog across quarters covers the cadence, and the trend plumbing lives in reporting, dashboards and violation tracking.

Backlog burn-down against new-violation prevention Violet columns show open backlog violations per quarter: 214, 168, 121, 64 and 31. A row of chips below the axis shows violations newly introduced in each quarter: 47, 22, 9, 3 and 1, coloured from rose through amber to green. An amber vertical marker between the first and second quarter shows when the prevention gate was enabled. Backlog burn-down against new-violation prevention open violations in the backlog 200 150 100 50 0 214 168 121 64 31 gate enabled Q1 26 Q2 26 Q3 26 Q4 26 Q1 27 new/qtr 47 22 9 3 1 Burn-down is only real once prevention is flat — new findings refill the backlog faster than fixes drain it.
The columns are the visible number and the chips are the one that matters: the backlog only falls at this rate because the inflow was cut from 47 a quarter to single digits first.

Reference Pipeline

The five files below are a complete remediation sweep: a scanner that writes a fingerprinted report with source locations, a report-driven ts-morph transform that rewrites only the flagged nodes, a verifier that compares fingerprint sets and chooses an exit code, and a workflow that opens a pull request on exit 0 and abandons the sweep on anything else. The generated regression specs come from emit-tests.mjs shown earlier in this page.

The scan step is deliberately narrow. It runs only the rule ids the pipeline knows how to route, so the report is a worklist rather than an audit, and it reads the data-src-loc attribute stamped by the build so every finding is anchored to a file and line.

// a11y/remediate/scan.mjs — usage: node a11y/remediate/scan.mjs before
import { createHash } from 'node:crypto';
import { mkdirSync, writeFileSync } from 'node:fs';
import { chromium } from 'playwright';
import { source as axeSource } from 'axe-core';

const PHASE = process.argv[2] ?? 'before'; // "before" or "after"
const BASE = process.env.A11Y_BASE_URL ?? 'http://127.0.0.1:4173';
const ROUTES = ['/', '/signup', '/account/profile', '/reports/quarterly'];
// Only rule ids this pipeline can route to a strategy. Anything else is
// detection's business and is gated elsewhere, not remediated here.
const TRACKED = [
  'image-alt', 'label', 'label-title-only', 'button-name',
  'link-name', 'aria-allowed-attr', 'duplicate-id-aria',
];

// Fingerprint over source location where available, target as a fallback.
const fingerprint = (route, rule, anchor) =>
  createHash('sha1').update(`${route}|${rule}|${anchor}`).digest('hex').slice(0, 12);

const browser = await chromium.launch();
const page = await browser.newPage();
await page.addInitScript({ content: axeSource }); // axe present before app JS
const findings = [];

for (const route of ROUTES) {
  await page.goto(`${BASE}${route}`, { waitUntil: 'load' });
  await page.getByRole('main').waitFor(); // app shell rendered, not just DOM ready
  const results = await page.evaluate(
    (rules) => axe.run(document, { runOnly: { type: 'rule', values: rules } }),
    TRACKED,
  );
  for (const violation of results.violations) {
    for (const node of violation.nodes) {
      const target = node.target.join(' ');
      // The build plugin stamps data-src-loc="src/Signup.tsx:41:7" off-prod.
      const source = await page.locator(target).first()
        .getAttribute('data-src-loc')
        .catch(() => null); // detached node between scan and read: no anchor
      findings.push({
        id: fingerprint(route, violation.id, source ?? target),
        rule: violation.id,
        impact: violation.impact,
        route, target, source,
      });
    }
  }
}

await browser.close();
mkdirSync('a11y/reports', { recursive: true });
writeFileSync(`a11y/reports/${PHASE}.json`, JSON.stringify(findings, null, 2));
console.log(`${PHASE}: ${findings.length} tracked findings`);

The transform reads that report and handles exactly two repairs: wiring a visible <label> to its control, and marking a provably decorative image. Everything else is recorded as needs-human so triage can score it. Note what the code does not do — it never invents text, and it skips any node whose source line has drifted since the scan, because a line number that no longer matches is a stale anchor and editing on it would corrupt an unrelated element.

// a11y/remediate/apply.mjs — usage: node a11y/remediate/apply.mjs
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { Project, SyntaxKind } from 'ts-morph';

const findings = JSON.parse(readFileSync('a11y/reports/before.json', 'utf8'));
const project = new Project({ tsConfigFilePath: 'tsconfig.json' });
const applied = [];
const CONTROLS = new Set(['input', 'select', 'textarea']);
const suffix = (file, line) =>
  createHash('sha1').update(`${file}:${line}`).digest('hex').slice(0, 4);

const attr = (element, name) => element.getAttribute(name);
const openingAt = (sourceFile, line) =>
  [
    ...sourceFile.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
    ...sourceFile.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
  ].find((el) => el.getStartLineNumber() === line);

for (const finding of findings) {
  const record = { ...finding, fix: 'needs-human' };
  if (!finding.source) { applied.push(record); continue; }
  const [file, rawLine] = finding.source.split(':');
  const sourceFile = project.getSourceFile(file);
  const element = sourceFile && openingAt(sourceFile, Number(rawLine));
  if (!element) { applied.push(record); continue; } // stale anchor: hands off

  const tag = element.getTagNameNode().getText();

  if (finding.rule === 'label' && CONTROLS.has(tag)) {
    // Only wire up a control that has no name of its own yet.
    if (attr(element, 'aria-label') || attr(element, 'aria-labelledby')) {
      applied.push(record); continue;
    }
    const label = element.getParent()
      ?.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)
      .find((el) => el.getTagNameNode().getText() === 'label');
    if (!label || attr(label, 'htmlFor')) { applied.push(record); continue; }
    const id = `${attr(element, 'name')?.getInitializer()?.getLiteralText()
      ?? tag}-${suffix(file, rawLine)}`;
    if (!attr(element, 'id')) element.addAttribute({ name: 'id', initializer: `"${id}"` });
    label.addAttribute({ name: 'htmlFor', initializer: `"${id}"` });
    applied.push({ ...record, fix: 'label-association', generatedId: id });
    continue;
  }

  if (finding.rule === 'image-alt' && tag === 'img') {
    // Decorative only when the author already said so in the markup.
    const decorative = attr(element, 'role')?.getInitializer()?.getLiteralText()
      === 'presentation' || attr(element, 'aria-hidden') !== undefined;
    if (!decorative) { applied.push(record); continue; } // a human writes words
    element.addAttribute({ name: 'alt', initializer: '""' });
    applied.push({ ...record, fix: 'decorative-alt' });
    continue;
  }

  applied.push(record);
}

project.saveSync();
writeFileSync('a11y/reports/applied.json', JSON.stringify(applied, null, 2));
const healed = applied.filter((entry) => entry.fix !== 'needs-human');
console.log(`applied ${healed.length} fixes, ${applied.length - healed.length} for triage`);

Verification is a set comparison, not a count comparison, and its three exit codes carry three different meanings for the workflow: the sweep is good, the sweep did not deliver, or the sweep caused harm. Only the first opens a pull request.

// a11y/remediate/verify.mjs — usage: node a11y/remediate/verify.mjs
import { readFileSync } from 'node:fs';

const read = (name) => JSON.parse(readFileSync(`a11y/reports/${name}.json`, 'utf8'));
const before = read('before');
const after = read('after');
const applied = read('applied');

const afterIds = new Set(after.map((f) => f.id));
const beforeIds = new Set(before.map((f) => f.id));
const claimed = applied.filter((entry) => entry.fix !== 'needs-human');

const unhealed = claimed.filter((entry) => afterIds.has(entry.id));
const introduced = after.filter((f) => !beforeIds.has(f.id));

console.log(`claimed fixes : ${claimed.length}`);
console.log(`still failing : ${unhealed.length}`);
console.log(`new findings  : ${introduced.length}`);
for (const entry of unhealed) console.log(`  unhealed ${entry.rule} ${entry.source}`);
for (const f of introduced) console.log(`  new      ${f.rule} ${f.target}`);

// 2 = the sweep made the page worse; 1 = it claimed fixes it did not deliver.
if (introduced.length > 0) process.exit(2);
if (unhealed.length > 0) process.exit(1);
console.log('verified: every claimed fingerprint cleared, nothing new introduced');
process.exit(0);

The workflow ties it together. It runs on a schedule rather than per pull request, because a sweep rewrites source and belongs in its own branch; the pull-request path is where the gate lives, configured separately in pull-request gating and branch policies. If verify exits non-zero the job fails, the pull-request step never runs, and the reports are still uploaded so someone can read what the transform believed it had done.

name: a11y-remediation-sweep
on:
  workflow_dispatch:
  schedule:
    - cron: '17 4 * * 2'        # Tuesday 04:17 UTC, off-peak for the runner pool
permissions:
  contents: write               # push the sweep branch
  pull-requests: write          # open the sweep PR
concurrency:
  group: a11y-remediation-sweep
  cancel-in-progress: false     # never interrupt a sweep mid-rewrite
jobs:
  sweep:
    runs-on: ubuntu-24.04
    timeout-minutes: 35
    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: Build and serve with source stamps
        run: |
          A11Y_SRC_STAMPS=1 npm run build      # plugin adds data-src-loc
          npm run preview -- --port 4173 --strictPort &
          npx wait-on http://127.0.0.1:4173
      - name: Scan before the sweep
        run: node a11y/remediate/scan.mjs before
      - name: Apply report-driven transforms
        run: node a11y/remediate/apply.mjs
      - name: Format only the files the transform touched
        run: git diff --name-only -z | xargs -0 -r npx prettier --write
      - name: Prove the transform is idempotent
        run: |
          node a11y/remediate/apply.mjs
          git diff --quiet   # a second run must produce no further changes
      - name: Rebuild and scan after the sweep
        run: |
          pkill -f 'vite preview' || true       # free the port from the first serve
          A11Y_SRC_STAMPS=1 npm run build
          npm run preview -- --port 4173 --strictPort &
          npx wait-on http://127.0.0.1:4173
          node a11y/remediate/scan.mjs after
      - name: Verify every claimed fix
        run: node a11y/remediate/verify.mjs   # exit 1 = undelivered, 2 = regression
      - name: Generate and run the regression specs
        run: |
          node a11y/remediate/emit-tests.mjs
          npx playwright test tests/a11y/remediated.spec.ts
      - name: Open the remediation pull request
        uses: peter-evans/create-pull-request@v6
        with:
          branch: a11y/sweep-${{ github.run_number }}
          commit-message: 'fix(a11y): label associations and decorative alt'
          title: 'a11y sweep: verified label and decorative-image fixes'
          body-path: a11y/reports/applied.json
          labels: accessibility, verified-sweep
      - uses: actions/upload-artifact@v4
        if: always()                            # keep evidence even on exit 1 or 2
        with:
          name: a11y-sweep-reports
          path: a11y/reports/
          retention-days: 30

Two details in that workflow are load-bearing. The idempotence step runs the transform a second time and asserts a clean tree, which catches the whole family of bugs where a transform re-edits its own output. And body-path puts the manifest — every fingerprint, its rule id, its source location and whether it was healed or deferred — into the pull-request description, so the reviewer’s checklist is generated rather than remembered.

WCAG 2.2 Coverage Mapping

The point of mapping remediation to success criteria is that it makes the boundary explicit: for each criterion there is a repair a machine may perform and a judgement it may not. Auditors ask which criteria the automation covers, and the honest answer is a per-criterion split rather than a percentage.

Success criterion Safe to automate Needs human judgement Rule ids involved
SC 1.1.1 Non-text Content alt="" where the markup already declares the image decorative Any description of an informative image, chart or logo image-alt, role-img-alt, input-image-alt, area-alt
SC 1.3.1 Info and Relationships htmlFor/id wiring when a visible label exists; removing a duplicate role Table header structure; which heading level a section deserves label, form-field-multiple-labels, td-headers-attr, presentation-role-conflict
SC 2.4.4 Link Purpose (In Context) Hoisting existing visible text into the name; deleting an aria-label that hides better text Rewriting “read more” into a purpose the user can act on link-name, aria-allowed-attr
SC 3.3.2 Labels or Instructions Promoting a title-only label into a real associated <label> Writing format hints, required-field wording, error recovery text label-title-only, select-name
SC 4.1.2 Name, Role, Value Removing disallowed ARIA attributes; de-duplicating referenced ids Naming an icon-only control; choosing the right role for a custom widget button-name, aria-allowed-attr, duplicate-id-aria, aria-valid-attr-value

Read the middle column as the specification for what the triage queue must carry. Every row’s human-judgement half is work that gets scored, assigned and scheduled — it never disappears because a transform ran, and a pipeline that reports it as fixed is lying to the next audit.

Common Pitfalls

  • Auto-merging a machine fix. The sweep opens a pull request; a workflow with push access to the default branch turns a transform bug into a production accessibility regression with nobody’s name on it.
  • Verifying with counts instead of fingerprints. A sweep that heals three findings and introduces two looks like progress in a count and like a regression in a set comparison, and only one of those readings is true.
  • Letting a model write committed strings. A generated aria-label that lands in a 200-file diff will be reviewed as formatting noise; keep candidate text in the pull-request body where it has to be accepted explicitly.
  • Blanket transforms over src/. The diff scales with the codebase rather than the report, the reviewer stops reading at file 30, and nothing ties a hunk to a violation that verification can check.
  • Anchoring fixes to CSS selectors. div:nth-child(4) > button survives until the next layout change; anchor to a stamped source location and skip the finding when the line has drifted.
  • Skipping the idempotence check. A transform that re-edits its own output produces email-1-2-3 ids and a diff that grows every sweep, and it always looks correct on the single file you tested by hand.
  • Reformatting the whole repository after a sweep. Whitespace-only changes across 900 files hide the twelve lines that matter and guarantee the accessibility review is skipped.
  • Treating a baseline file as a regression guard. A baseline permits its contents forever; only a snapshot, a generated spec or a lint error makes a specific fix non-negotiable.
  • Fixing call sites when the component is the defect. Patching 88 usages leaves the component free to produce usage 89 on the next feature branch, so the sweep becomes a permanent chore.
  • Reporting one debt number. Combining new violations with backlog remaining hides the case where a team fixes 40 findings a quarter and introduces 47.

FAQ

Can an automated remediation pipeline merge fixes without human review? Not safely, and the exception people reach for — “surely alt="" on a decorative image is fine” — is exactly the case where the machine cannot tell decoration from omission unless the markup already says so. The pipeline in this section only writes alt="" when the author had already marked the image role="presentation" or aria-hidden, which means a human made the semantic call earlier. Everything else, including every deterministic transform, lands on a branch and goes through review like any other change.

What share of a real violation report can actually be auto-fixed? Expect a quarter to a third of findings by volume to have a deterministic repair, which on top of the 30–40% of WCAG failures scanners detect at all works out to roughly 10% of the total. The distribution matters more than the average: a codebase whose report is dominated by label and duplicate-id-aria will see most of its findings healed by one sweep, while a report dominated by button-name and informative image-alt will see almost none, because those need sentences. Measure your own split from one report before promising a fix rate.

Why fingerprint findings instead of just re-running the scanner and comparing counts? Because counts cannot distinguish the three outcomes the workflow has to act on: fixed, unfixed, and newly broken. A fingerprint over route, rule id and source location survives unrelated DOM churn, lets the verifier assert that this specific finding is gone, and gives the generated regression spec a name that points back to the sweep that healed it. It also makes the “made things worse” case a hard exit code rather than a judgement call.

Should a remediation sweep run on every pull request? No. A sweep rewrites source, so it belongs on a schedule with its own branch; running it per pull request means every contributor’s branch grows unrelated accessibility edits and the review becomes impossible to reason about. Pull requests get the gate — the detection job, the lint rules and the generated regression specs — which is fast, read-only, and configured in GitHub Actions a11y pipeline setup.

How do we stop a codemod from breaking markup that was already correct? Three habits handle nearly all of it. Drive the transform from the report so it only visits nodes a scanner flagged; write explicit bail-out conditions for every alternative correct pattern — an existing aria-label, aria-labelledby, a wrapping <label>, a control with an id already in use — and test them as fixtures asserting the source is returned unchanged; and cap the run at one rule id and a bounded file count so a mistake is small enough to revert in one commit.

In This Section