Automating Alt-Text Remediation with Codemods

There is exactly one alt-text change a codemod may make: adding alt="" to an image the source proves is decorative. Everything else — the product photograph, the chart, the avatar, the icon that stands alone in a control — needs a sentence that does not exist anywhere in the repository, and a transform that invents one has made the problem permanently invisible instead of fixing it. This guide is part of Codemod-Driven Accessibility Fixes, and it builds the classifier, the transform, and the worklist that carries the undecidable cases out to people.

Root Cause

WCAG 2.2 SC 1.1.1 (Non-text Content) requires a text alternative that serves the same purpose as the image. That wording is why the criterion resists automation: “the same purpose” is a fact about the page’s intent, and the image-alt rule can only observe that an alternative is absent. The rule is a presence check, not a quality check, which means it is satisfied by any string — including a wrong one, including an empty one.

That asymmetry is the whole problem. An empty alt is not a missing value; it is a positive assertion that the image carries no information a user needs, and assistive technology honours it by skipping the element entirely. Write alt="" on a spacer and the assertion is true. Write it on a photograph of a product and the assertion is a lie that every scanner will now confirm as correct, on every run, forever. The violation has not been fixed; it has been converted from a reported failure into an unreported one, and simultaneously removed from whatever backlog was tracking it.

So the classifier does the real work and the transform is trivial. The classifier’s job is not to decide what an image depicts — it cannot — but to decide whether the source contains a proof that the image is decorative. Three proofs qualify. Literal spacer geometry: width={1} height={1}, or a src naming a known blank asset, which cannot be carrying meaning at that size. An icon inside a control that is already named: <button><img src="/icons/save.svg" /><span>Save</span></button> computes its accessible name from the text node, so the icon must be silent or the name becomes “save.svg Save”. And an author-declared presentation role: an element that already carries role="presentation", role="none" or aria-hidden="true" has been declared decorative by a person, and the transform is only completing the declaration — which also clears the presentation-role-conflict result that an unnamed role="presentation" image produces.

One classifier, three buckets, one write path A sweep over 341 image nodes with no accessible name feeds a classifier that produces three buckets: 128 decorative where a proof holds, 74 informative where positive evidence of meaning exists, and 139 unknown where neither is true. Only the decorative bucket results in an edit; the other 213 become worklist rows and leave the source untouched. One classifier, three buckets, one write path 341 image nodes with no accessible name one dry run over packages/marketing classify: proof, meaning, or neither decorative — 128 a proof holds informative — 74 evidence of meaning unknown — 139 no evidence either way write an empty alt 128 edits, one commit 213 worklist rows — the source is not touched image-alt stays red until a person writes the text Proofs: literal 1x1 geometry, an icon beside static text in one control, or an author-declared role.
Two of the three buckets have no write path at all, which is the property that makes the transform safe to run across a whole package.

Configuration

The transform matches native <img> elements only, applies the three proofs in order, and appends a JSON line to a worklist for every node it will not touch. Both the write path and the worklist path are driven by the same classify call, so a node can never be edited and filed at the same time.

// codemods/decorative-alt.js
const { appendFileSync } = require('node:fs');

// Assets that cannot carry information at their rendered size.
const SPACER_SRC = /(?:^|\/)(?:spacer|blank|pixel|1x1|tracking)[.\-_]/i;
// Directories whose contents are, by convention, load-bearing content.
const MEANINGFUL_SRC = /\/(?:photos|screenshots|charts|diagrams|avatars|logos)\//i;
const INTERACTIVE = new Set(['button', 'a', 'summary', 'label']);

const attr = (open, name) =>
  open.attributes.find((a) => a.type === 'JSXAttribute' && a.name.name === name);

// Literal attribute values only. {size} or {`${n}`} is not a proof of anything.
const literal = (open, name) => {
  const a = attr(open, name);
  if (!a || !a.value) return null;
  if (a.value.type === 'Literal' || a.value.type === 'StringLiteral') return String(a.value.value);
  if (a.value.type === 'JSXExpressionContainer' && a.value.expression.type === 'Literal') {
    return String(a.value.expression.value);
  }
  return null;
};

// Static text only: {t('save')} may resolve to an empty string at run time.
const staticText = (node) => {
  let out = '';
  const walk = (n) => {
    if (!n) return;
    if (n.type === 'JSXText') out += n.value.trim();
    if (n.type === 'JSXElement') (n.children || []).forEach(walk);
  };
  (node.children || []).forEach(walk);
  return out;
};

module.exports = function transformer(file, api, options) {
  const j = api.jscodeshift;
  const root = j(file.source);
  let edits = 0;

  const file_ = file.path;
  const record = (open, bucket, reason) => {
    if (!options.worklist) return;
    const line = open.loc ? open.loc.start.line : 0;
    const src = literal(open, 'src') || 'expression';
    appendFileSync(
      options.worklist,
      JSON.stringify({ file: file_, line, bucket, reason, src }) + '\n',
    );
  };

  const namedControlAncestor = (path) => {
    for (let p = path.parent; p; p = p.parent) {
      const node = p.node;
      if (node.type !== 'JSXElement') continue;
      const name = node.openingElement.name;
      if (name.type === 'JSXIdentifier' && INTERACTIVE.has(name.name)) {
        return staticText(node).length > 0;
      }
    }
    return false;
  };

  const classify = (open, path) => {
    const role = literal(open, 'role');
    const hidden = literal(open, 'aria-hidden');
    if (role === 'presentation' || role === 'none' || hidden === 'true') {
      return { bucket: 'decorative', reason: 'author-declared-role' };
    }
    const src = literal(open, 'src');
    if (literal(open, 'width') === '1' && literal(open, 'height') === '1') {
      return { bucket: 'decorative', reason: 'literal-1x1-geometry' };
    }
    if (src && SPACER_SRC.test(src)) {
      return { bucket: 'decorative', reason: 'spacer-asset' };
    }
    if (namedControlAncestor(path)) {
      return { bucket: 'decorative', reason: 'control-already-named-by-text' };
    }
    if (src && MEANINGFUL_SRC.test(src)) {
      return { bucket: 'informative', reason: 'content-directory' };
    }
    if (!src) return { bucket: 'unknown', reason: 'src-is-a-runtime-expression' };
    return { bucket: 'unknown', reason: 'no-proof-either-way' };
  };

  root.find(j.JSXElement, { openingElement: { name: { name: 'img' } } }).forEach((path) => {
    const open = path.node.openingElement;

    // A spread may supply alt at run time, so absence here proves nothing.
    if (open.attributes.some((a) => a.type === 'JSXSpreadAttribute')) {
      return record(open, 'unknown', 'spread-may-carry-alt');
    }
    // Any existing alt — empty or not — is a decision a person already made.
    if (attr(open, 'alt')) return;
    if (attr(open, 'aria-label') || attr(open, 'aria-labelledby')) return;

    const verdict = classify(open, path);
    if (verdict.bucket !== 'decorative') {
      return record(open, verdict.bucket, verdict.reason);
    }
    open.attributes.push(j.jsxAttribute(j.jsxIdentifier('alt'), j.literal('')));
    edits += 1;
  });

  if (edits === 0) return null; // skipped, not rewritten
  return root.toSource({ quote: 'double' });
};

Run it against a scratch worktree first so the worklist can be read before any file changes, and pass the worklist path as a custom option. jscodeshift forwards unknown flags into options, and because each row is written with a single append call, rows from parallel worker processes interleave without corrupting each other.

# Preview: apply in a detached worktree, keep the patch and the worklist
mkdir -p artifacts && : > artifacts/worklist.jsonl
work="$(mktemp -d)"
git worktree add --detach "$work" HEAD >/dev/null
# --extensions is not optional here: without it every .tsx file is skipped and the
# run reports a clean no-op. --worklist is a custom flag the transform reads.
npx jscodeshift -t codemods/decorative-alt.js "$work/packages/marketing" \
  --parser=tsx \
  --extensions=tsx,ts,jsx,js \
  --ignore-pattern='**/dist/**' \
  --worklist="$PWD/artifacts/worklist.jsonl"
git -C "$work" diff > artifacts/decorative-alt.patch
git worktree remove --force "$work"

# What went where, before deciding to apply for real
grep -c '"bucket":"decorative"' artifacts/worklist.jsonl || true   # expect 0 rows
node -e 'const rows=require("node:fs").readFileSync("artifacts/worklist.jsonl","utf8")
  .trim().split("\n").map(JSON.parse);
  const by={}; for (const r of rows) by[r.reason]=(by[r.reason]??0)+1;
  console.table(by);'
What an empty alt changes in the accessible name The left panel shows a button containing an unnamed icon image and a span reading Save, with a computed accessible name of save.svg Save and one image-alt violation. The right panel shows the same markup with an empty alt on the icon, a computed name of Save, and no violation. Two bands below state that the name already existed in the sibling text and name the proof the transform requires. The name already exists; the empty alt stops the filename leaking before — SaveBar.tsx:18 <button type="button"> <img src="/icons/save.svg" /> <span>Save</span> </button> name: save.svg Save image-alt: 1 violation proof C after — one attribute added <button type="button"> <img src="/icons/save.svg" alt="" /> <span>Save</span> </button> name: Save image-alt: 0 violations No new information is written: the accessible name comes from the span, both before and after. Some screen readers fall back to the file name, which is why the leak is worth removing. Proof C requires a static text node inside the same interactive ancestor as the image. A translated label such as {t('save')} is not static, so that node goes to the unknown bucket.
This is the only one of the three proofs that reasons about the surrounding tree, and it is also the most common in a component library.

Validation

Test the classifier, not the printer. Each bucket gets a case, and the two non-decorative buckets assert both that the source is unchanged and that a worklist row exists with the expected reason — a transform that silently drops a node is indistinguishable from one that had nothing to do.

// codemods/__tests__/decorative-alt.test.js
const { mkdtempSync, readFileSync } = require('node:fs');
const { join } = require('node:path');
const { tmpdir } = require('node:os');
const { applyTransform } = require('jscodeshift/dist/testUtils');
const transform = require('../decorative-alt');

const worklist = join(mkdtempSync(join(tmpdir(), 'alt-')), 'worklist.jsonl');
const run = (source) =>
  applyTransform(transform, { parser: 'tsx', worklist }, { source, path: 'Card.tsx' });
const rows = () =>
  readFileSync(worklist, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));

test('a literal 1x1 spacer gets an empty alt', () => {
  expect(run('<img src="/img/grid.png" width={1} height={1} />')).toContain('alt=""');
});

test('an icon beside static text in a button gets an empty alt', () => {
  const out = run('<button type="button"><img src="/icons/save.svg" /><span>Save</span></button>');
  expect(out).toContain('alt=""');
});

test('a photograph is filed, not edited', () => {
  const source = '<img src="/photos/team.jpg" />';
  expect(run(source)).toBe(source); // untouched: no proof, and evidence of meaning
  expect(rows().pop()).toMatchObject({ bucket: 'informative', reason: 'content-directory' });
});

test('a runtime src goes to the unknown bucket', () => {
  const source = '<img src={post.hero} />';
  expect(run(source)).toBe(source);
  expect(rows().pop()).toMatchObject({ bucket: 'unknown' });
});

test('an author-written alt is never overwritten', () => {
  const source = '<img src="/photos/team.jpg" alt="" />';
  expect(run(source)).toBe(source); // idempotent on a second sweep
});

Then confirm the accessible name is what the proof claimed, because a passing image-alt rule says nothing about the name a user hears. One rendered assertion per proof is enough, and the button case is the one worth locking down.

// codemods/__tests__/save-bar.test.jsx
import { render, screen } from '@testing-library/react';

test('the icon does not leak into the button name', () => {
  render(
    <button type="button">
      <img src="/icons/save.svg" alt="" />
      <span>Save</span>
    </button>,
  );
  // Exact match: "save.svg Save" or "Save Save" both fail this assertion.
  expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
});

The final check is the rescan. Compare counts per rule id before and after the sweep and expect image-alt to fall by exactly the number of edits, with presentation-role-conflict unmoved — a rise there means the transform added an empty alt beside a role it should have left alone.

| rule id                    | before | after | delta |
|----------------------------|--------|-------|-------|
| image-alt                  |    341 |   213 |  -128 |
| presentation-role-conflict |      4 |     0 |    -4 |
| role-img-alt               |     11 |    11 |     0 |

Edge Cases and Conditional Guards

  • Framework image components. <Image> from a meta-framework, or a design-system <Img> wrapper, is not matched: the transform cannot know whether that component forwards alt to an <img> or names it something else. Resolving that needs the type-aware path, and the better fix is usually a required alt prop in the component itself rather than a sweep over its call sites.
  • CSS background images. An image applied through background-image has no element to match and no alt to add. If it carries meaning it is a markup bug, not an attribute bug, so it belongs in a review task and should be called out in the pull request body — otherwise reviewers will assume the sweep covered every image on the page.
  • Inline SVG. An inline svg element carrying role="img" is named by its title child, not by alt, and the governing rule is svg-img-alt. Adding alt there does nothing at all, so keep the matcher on img and let a separate transform handle SVG, with aria-hidden="true" as its only mechanical edit.
  • Icons inside a control whose label is translated. {t('save')} is not static text, so proof C does not hold and the node lands in the unknown bucket. That is the correct default, and it is also the one place worth extending later: a classifier that can read the message catalogue can promote the node once it has confirmed the key resolves to a non-empty string.
  • Images inside a captioned figure element. A caption is visible content and does not name the image, so the node is still unnamed as far as the accessibility tree is concerned. Treat it as informative rather than decorative, because an image that earned a caption is almost never decoration, and the caption itself is a strong starting point for the person who writes the alternative.

Pipeline Impact

The sweep changes two numbers, and both need somewhere to live. image-alt falls by the decorative count and the corresponding branch is opened as an ordinary pull request. The worklist length — 213 rows in the run above — becomes a tracked quantity rather than an artifact that is thrown away when the job finishes: commit it, and let the accessibility budget count it, so the remaining work is visible to the same gate that watches everything else. Progressive threshold management is where that number gets a ratchet, and accessibility debt triage and prioritization is where the rows get owners.

Deliberately, nothing is written into the source for the undecidable buckets — no alt="TODO", no sentinel attribute. A placeholder only earns a place in a diff when something is already queued to replace it in the same pull request, which is the shape of the AI-assisted remediation path: a model drafts the sentence, the draft appears as a suggested change, and a reviewer accepts it before the branch merges. A codemod running on its own has no such partner, and a sentinel that ships is announced to users verbatim while a grep guard that was supposed to catch it gets disabled the first Friday it blocks a release. Leaving the node untouched keeps the scanner red, which is exactly the state that reflects reality.

Two ways to leave a photograph unfixed The upper track follows a photograph the sweep guessed as decorative: the scan turns green in sprint one, the node is absent from the report in sprint two, and absent from the backlog in sprint three while the screen reader stays silent. The lower track follows the same photograph left failing with a worklist row: it is counted in sprint one, still counted in sprint two, and written by a person in sprint three. Two ways to leave a photograph unfixed sprint 1 sprint 2 sprint 3 Guessed: the sweep wrote an empty alt the scan turns green 0 image-alt violations absent from the report nothing left to triage absent from the backlog the image stays silent Filed: one worklist row, source untouched 1 image-alt violation 1 row with an owner still counted inside the budget a person writes it the row closes for good Only one of these tracks ever reaches a user, and it is the one that looked worse in sprint 1. A red count that is true is a working queue; a green count that is false is a dead end.
The guessed track optimises the metric on day one and forecloses the fix; the filed track keeps the count honest long enough for someone to close it.

Common Pitfalls

  • Writing alt="" on anything the source has not proved decorative, which makes the failure permanently invisible and deletes it from every report at once.
  • Inferring “decorative” from a filename pattern such as /icons/ alone; an icon that is the only content of a control carries the whole meaning of that control.
  • Overwriting an existing alt="", which discards a decision a person made and breaks idempotence on the next sweep.
  • Treating a fall in image-alt as the measure of success, when the number that matters is how many images now have a correct alternative.
  • Leaving the worklist as a build artifact, so the undecidable nodes vanish when the run’s retention expires and the next sweep rediscovers them from scratch.

FAQ

Why not generate alt text from the file name or a nearby heading? Because both produce text that is plausible and wrong, which is the worst possible outcome: a reviewer skims it, a scanner endorses it, and the image is now permanently mislabelled instead of merely unlabelled. hero-2-final.jpg is not a description, and the nearest heading describes the section rather than the image. A node with no proof either way is worth more as a review task than as a guess.

Is an empty alt really better than no alt on a decorative image? Yes, and the difference is intent. With no alt, assistive technology has to guess and several screen readers fall back to announcing the file name, so a spacer becomes “grid dot p n g”. With alt="", the element is skipped entirely, which is exactly what should happen — and the same change removes the node from the image-alt count for a true reason rather than a false one.

How large should one alt sweep be? Small: one package, and a ceiling around 25 files, because this transform reasons about the surrounding tree and a widened matcher does invisible damage. The parent guide on codemod-driven fixes sets the per-package apply loop and the ceiling mechanics; alt text deserves the tightest ceiling of any eligible rule id, because it is the one rule where a wrong edit can never be detected by rerunning the scanner.