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.
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);'
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 forwardsaltto an<img>or names it something else. Resolving that needs the type-aware path, and the better fix is usually a requiredaltprop in the component itself rather than a sweep over its call sites. - CSS background images. An image applied through
background-imagehas no element to match and noaltto 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
svgelement carryingrole="img"is named by itstitlechild, not byalt, and the governing rule issvg-img-alt. Addingaltthere does nothing at all, so keep the matcher onimgand let a separate transform handle SVG, witharia-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.
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-altas 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.
Related
- Codemod-Driven Accessibility Fixes — eligibility tests, dry-run discipline and the per-package apply loop this transform runs inside.
- Bulk-Fixing Form Label Associations — the sibling transform, where a wrong edit is loud instead of silent.
- Automated Remediation & Accessibility Fixing Patterns — how the worklist rows become owned, scored tickets rather than a file nobody reads.