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, and htmlFor attributes, 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.
AST codemod pipeline Source files are parsed into an abstract syntax tree, a transform edits the tree, the tree is printed back to source, and a verification scan confirms the fix. Source files .tsx / .jsx Parse → AST Transform edit nodes Print → source Verify jest-axe dry-run prints diff here, before Print writes files
An AST codemod never touches raw text — it edits parsed nodes, so structure and formatting survive.

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 babel parser throws on type annotations. Always pass --parser=tsx for TypeScript + JSX, --parser=ts for 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 || hasAria check above.
  • Non-deterministic scan in verify. If the post-codemod re-scan flakes, the scanner is racing hydration, not the codemod. Wait on networkidle before scanning.

Common Pitfalls

  • Running without --dry --print first, 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.

In This Section