Codemod-Driven Accessibility Fixes
When a scanner reports the same mechanical violation across hundreds of files — missing alt, an unlabeled input, a redundant role — hand-editing each one is slow and error-prone. A codemod fixes the whole class at once by parsing source into an abstract syntax tree (AST), editing the tree, and printing it back, producing a deterministic diff a reviewer can trust. This guide is part of Automated Remediation & Accessibility Fixing Patterns, which covers the broader detect-fix-verify loop.
The problem codemods solve is scale with safety. A regex find-and-replace cannot tell an <img> inside a comment from a real element, cannot respect JSX expression boundaries, and will happily corrupt a template literal. An AST transform operates on the parsed structure, so it edits only genuine nodes and preserves everything else — which is precisely the property you need when a single transform touches an entire monorepo.
Key implementation targets:
- A jscodeshift transform that adds missing
alt,aria-label, andhtmlForattributes, run with--parser=tsx. - A dry-run mode that prints the diff without writing, so the blast radius is reviewed before any file changes.
- A jest-axe fixture proving the transform fixes the target case and leaves correct code untouched.
- A CI job that runs the codemod, opens a PR with the diff, and never pushes to the default branch.
Prerequisites
1. Write a jscodeshift transform
A jscodeshift transform is a module exporting a function that receives the file source and an API object. You query the AST with the jQuery-like collection API, mutate matching nodes, and return the printed source. This transform adds a missing alt="" to any <img> that has neither alt nor aria-label — a starting point you tighten in the focused walkthroughs.
// codemods/add-missing-alt.js
module.exports = function transformer(file, api) {
const j = api.jscodeshift; // the jscodeshift API, bound to the parser
const root = j(file.source);
root
.find(j.JSXOpeningElement, { name: { name: "img" } }) // match <img ...>
.forEach((path) => {
const attrs = path.node.attributes;
const hasAlt = attrs.some(
(a) => a.type === "JSXAttribute" && a.name.name === "alt"
);
const hasAria = attrs.some(
(a) => a.type === "JSXAttribute" && a.name.name === "aria-label"
);
if (hasAlt || hasAria) return; // already named — leave it alone
// Insert alt="" so the element is no longer flagged; a reviewer fills real text.
attrs.push(
j.jsxAttribute(j.jsxIdentifier("alt"), j.literal(""))
);
});
return root.toSource({ quote: "double" }); // preserve double-quote style
};
The same shape extends to aria-label on icon-only buttons and htmlFor on labels — the bulk-fixing form-label associations with jscodeshift walkthrough builds the label transform in full, and automating alt-text remediation with codemods handles the decorative-vs-meaningful distinction properly.
2. Dry-run before you apply
Never let a transform write on its first run. jscodeshift defaults to writing files in place, so use --dry plus --print to see the exact diff against zero risk. Review the printed output, confirm the match count is what you expect, and only then drop the flags.
# Dry-run: print transformed source to stdout, write nothing
npx jscodeshift \
-t codemods/add-missing-alt.js \
src/ \
--parser=tsx \ # parse TypeScript + JSX
--dry \ # do not write files
--print # emit transformed source so you can diff it
# When the diff looks right, apply for real (drop --dry and --print)
npx jscodeshift -t codemods/add-missing-alt.js src/ --parser=tsx
jscodeshift prints a summary line — N ok, M skipped, K errors. If errors is non-zero, a file failed to parse (often an unsupported syntax or wrong --parser); fix that before trusting the run.
3. Verify the transform with jest-axe
A codemod is only trustworthy if it has a test proving two things: it fixes the broken case, and it does not touch already-correct code. Run the transform programmatically over fixtures, render the result, and assert with jest-axe.
// codemods/__tests__/add-missing-alt.test.js
const { applyTransform } = require("jscodeshift/dist/testUtils");
const transform = require("../add-missing-alt");
test("adds alt to an unlabeled img", () => {
const input = `export const A = () => <img src="/x.png" />;`;
const output = applyTransform(transform, { parser: "tsx" }, { source: input });
expect(output).toContain('alt=""'); // fix applied
});
test("leaves an already-labeled img untouched", () => {
const input = `export const B = () => <img src="/x.png" alt="A cat" />;`;
const output = applyTransform(transform, { parser: "tsx" }, { source: input });
expect(output).toBe(input); // idempotent, no churn
});
For rendered assertions, mount the component output and run axe() from jest-axe to confirm the image-alt rule no longer fires. This guards against transforms that produce syntactically valid but semantically wrong fixes.
4. ts-morph, ESLint autofix, and PostCSS variants
jscodeshift is the default, but three siblings cover cases it handles poorly. ts-morph is type-aware — use it when a fix depends on a component’s prop types (for example, knowing an Image wrapper requires an alt prop). ESLint autofix is ideal for rules you already lint: jsx-a11y rules can be paired with custom fixers so eslint --fix repairs violations during the normal lint pass. PostCSS handles stylesheets — swapping a sub-4.5:1 color token for a compliant one to satisfy WCAG 2.2 SC 1.4.3.
// codemods/contrast-token.js — PostCSS plugin swapping a non-compliant token
module.exports = () => ({
postcssPlugin: "a11y-contrast-token",
Declaration(decl) {
// Replace the known-failing token with the compliant one
if (decl.value.includes("var(--text-muted-old)")) {
decl.value = decl.value.replace("var(--text-muted-old)", "var(--text-muted-aa)");
}
},
});
module.exports.postcss = true;
Pick the tool by what the fix depends on: structure → jscodeshift, types → ts-morph, existing lint rule → ESLint autofix, styles → PostCSS.
Pipeline Integration
Run codemods in a dedicated workflow that opens a PR rather than gating a merge. The job applies the transform, and if a diff exists, create-pull-request opens a branch for review. Upload the dry-run summary and the before/after scanner reports as artifacts so reviewers see what changed and why.
name: a11y-codemods
on: workflow_dispatch # run on demand
permissions:
contents: write
pull-requests: write
jobs:
codemod:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- name: Apply transforms
run: |
npx jscodeshift -t codemods/add-missing-alt.js src/ --parser=tsx
npx jscodeshift -t codemods/associate-labels.js src/ --parser=tsx
- name: Run transform tests
run: npx jest codemods/ # the fixtures from step 3 gate the codemod itself
- uses: peter-evans/create-pull-request@v6
with:
branch: a11y/codemod-sweep
title: "Codemod: accessibility remediation sweep"
labels: accessibility, needs-review
The exit code that matters is npx jest codemods/: if the transform’s own fixtures fail, the job stops before opening a PR. The downstream PR is then gated by your normal pull-request checks, described in Pull Request Gating & Branch Policies.
Troubleshooting & Flaky-Test Mitigation
Codemods are deterministic, so flakiness usually comes from the environment, not the transform. The common failure modes:
- Parser mismatch. A TS file run with the default
babelparser throws on type annotations. Always pass--parser=tsxfor TypeScript + JSX,--parser=tsfor plain.ts. - Formatting churn.
toSource()can reflow code, swamping the real diff. Pass{ quote: "double" }(or your repo’s style) and run Prettier after the codemod so the diff shows only semantic changes. - Idempotency drift. Running a transform twice should be a no-op. If the second run produces a diff, your match condition is too broad — add a guard like the
hasAlt || hasAriacheck above. - Non-deterministic scan in verify. If the post-codemod re-scan flakes, the scanner is racing hydration, not the codemod. Wait on
networkidlebefore scanning.
Common Pitfalls
- Running without
--dry --printfirst, so a misfiring transform silently rewrites hundreds of files. - Skipping the no-op fixture, so a transform that corrupts already-correct code ships unnoticed.
- Forgetting
--parser=tsx, causing every TypeScript file to error out and the run to under-report its coverage. - Treating the inserted placeholder (
alt=""on a meaningful image) as a real fix instead of a reviewable stub. - Letting the codemod push to the default branch instead of opening a PR for human review.
FAQ
Why use an AST codemod instead of find-and-replace?
Regex cannot understand syntax. It will edit matches inside comments, strings, and unrelated attributes, and it has no concept of “an <img> that lacks an alt.” An AST transform operates on parsed nodes, so it targets only real elements and preserves the surrounding structure exactly — the safety you need when one run touches a whole repo.
Should a codemod ever merge automatically? No. Even a deterministic transform needs a reviewer to confirm intent, especially when it inserts placeholders that intentionally keep a scan red. Run codemods in a workflow that opens a PR, gated by the transform’s own jest-axe fixtures, and let a human merge.
When do I reach for ts-morph instead of jscodeshift?
When the fix depends on type information — for example, only adding an alt prop to components whose type signature requires one. jscodeshift sees syntax; ts-morph sees the type system, at the cost of a slower, project-aware run.
How do I keep the diff reviewable across hundreds of files? Run Prettier after the codemod so formatting noise is normalized, scope each transform to one violation class, and upload the before/after scanner reports as PR artifacts so reviewers can see the violation count drop.
Related
- Automated Remediation & Accessibility Fixing Patterns — the parent section covering the full detect-fix-verify loop.
- automating alt-text remediation with codemods — a complete alt-text transform with decorative/meaningful handling.
- bulk-fixing form-label associations with jscodeshift — wiring orphan labels and inputs together.
- axe-core Configuration & Setup — the scanner that tells you which violations to codemod.