Codemod-Driven Accessibility Fixes

A codemod fixes an accessibility violation by parsing source into a syntax tree, editing named nodes, and printing the tree back — so the same input always produces the same diff, and a reviewer who has checked three hunks can reason about the remaining ninety. This guide is part of Automated Remediation & Accessibility Fixing Patterns, and it deals with the two decisions that come before any transform is written: which violations are eligible for a mechanical fix at all, and how to run a blanket sweep over a package without producing a diff nobody can review.

Problem Statement

The trap in codemod work is not a crash. A transform that throws is discovered in seconds. The transform that ships damage is the one that succeeds — on nodes it was never meant to touch, in a way no scanner will ever report again. Adding alt="" to a product photograph makes image-alt pass forever while a blind user gets nothing; adding aria-label="Button" to an icon control satisfies button-name and tells the user precisely nothing. Both edits are mechanically trivial, both produce green pipelines, and both are worse than the violation they replaced, because a loud failure has been converted into a silent one and simultaneously deleted from the backlog.

That is why eligibility comes first and the transform second. The line is not about how many instances a sweep finds, nor about how confident the pattern-matching feels: it is about whether the source contains enough information to derive the correct output. Associating a <label> that already reads “Email address” with the <input> sitting next to it is derivable — the text exists, the relationship is implied by position, and the fix only makes the implication machine-readable. Writing the sentence that describes a photograph is not derivable, and no amount of AST sophistication changes that, because the missing information was never in the repository.

The second problem is targeting strategy. The parent section works through report-driven transforms, where a data-src-loc stamp maps each DOM finding back to a file and line so the transform only visits nodes a scanner actually flagged. This guide takes the complementary approach: a blanket transform run per package, where a deliberately narrow matcher does the work the report would otherwise do. Blanket sweeps earn their place in two situations the report cannot cover — when the source predicate is exactly equivalent to the DOM predicate (an <img> with no alt attribute in the source is an <img> with no alt attribute in the DOM), and when the scanner never reached the code path at all, because the component only renders behind a feature flag, on an authenticated route, or in an error state no crawl produces.

Key implementation targets:

  • An eligibility manifest keyed by rule id, recording for each rule whether the correct output is derivable, which transform owns it, and which rule ids verify it.
  • A jscodeshift transform whose matcher is narrow enough that every node it edits is provably correct, and which records a reason for every node it refuses.
  • A dry run that produces a real patch file plus a refusal ledger, both read before anything is committed.
  • A per-package apply loop with a hard file ceiling, one commit per rule id, and formatting confined to the files the transform touched.
  • A verification run that compares before-and-after counts per rule id, so a sweep that trades one violation class for another fails the job.

Prerequisites

1. Choose the eligible rule IDs

Start from the rule ids in the scan report, not from the transforms you would enjoy writing. For each rule id that appears more than a handful of times, run three tests, and require all three to pass before the rule is eligible.

The first test is derivability: is the correct output a pure function of the matched node and its lexical neighbours in the same file? Adding type="button" to a <button> that sits outside any form passes, because the default of submit is meaningless there. Adding type to a button inside a <form> fails, because whether that button is the submit control is a product decision living nowhere in the markup.

The second test is the silent-lie test, and it is the one teams skip. If the transform gets a node wrong, does the scanner still report a failure? A wrong htmlFor target produces a label violation on the next scan, or a form-field-multiple-labels violation, so the mistake is loud and self-correcting. A wrong alt="" produces nothing at all, on any scanner, ever. Any rule where a plausible mistake yields a green result is disqualified regardless of how derivable it looks, because the verification step has no way to catch the sweep’s own errors.

The third test is revertibility: is the edit additive, and is one commit enough to undo it? Adding an attribute passes. Replacing a <div onClick> with a <button type="button"> also passes when the div has no interactive descendants and no className that positions it as a block, but it changes the element tree, so it belongs in a separate sweep with a much smaller ceiling and a reviewer who knows the component. Anything that moves nodes across a boundary — hoisting a control inside a wrapping label, splitting a component — fails the test and belongs in a hand-written pull request.

The derivability line, by rule id The left column lists five rule ids whose fix is a pure function of the source node and its neighbours, so a codemod is the right instrument. The right column lists five rule ids whose fix needs knowledge that only a person has. A band below states the three tests a rule id must pass to reach the left column. Can the correct output be derived from the source? Derivable from source a codemod is the right instrument Needs outside knowledge a person supplies the meaning label — point htmlFor at a sibling id image-alt — empty alt on a 1x1 spacer presentation-role-conflict — drop role duplicate-id-aria — rename one of two button-has-type — add type outside forms image-alt — what the photograph shows button-name — what the icon control does link-name — where read more leads color-contrast — which token design wants heading-order — the intended outline All three tests must pass: the output is a pure function of the node and its neighbours, a wrong answer still fails the scan, and the edit is additive and revertible in one commit.
The same rule id can appear on both sides: image-alt is derivable for a proven spacer and undecidable for a photograph, which is why eligibility is a property of the node, not only of the rule.

Record the outcome as a file the pipeline reads, not as tribal knowledge. The manifest below is the single source of truth for what a sweep is allowed to do: a rule id absent from it cannot be swept, and the verification step reads the same verify array to decide which counts must fall.

{
  "label": {
    "derivable": true,
    "transform": "codemods/associate-labels.js",
    "ceiling": 40,
    "verify": ["label", "form-field-multiple-labels", "duplicate-id-aria"],
    "note": "Only when the label text already exists in the same file."
  },
  "image-alt": {
    "derivable": "partial",
    "transform": "codemods/decorative-alt.js",
    "ceiling": 25,
    "verify": ["image-alt", "presentation-role-conflict"],
    "note": "Writes only for proven decorative nodes; everything else becomes a worklist row."
  },
  "button-has-type": {
    "derivable": true,
    "transform": "codemods/add-button-type.js",
    "ceiling": 60,
    "verify": [],
    "note": "ESLint rule id, not an axe rule; refuses any button inside a form element."
  },
  "button-name": {
    "derivable": false,
    "transform": null,
    "ceiling": 0,
    "verify": ["button-name"],
    "note": "Needs a written name; route to the AI-assisted path with a human reviewer."
  }
}

Two entries in that manifest deserve attention. button-has-type is an ESLint rule id rather than a scanner rule id, because the eligible list is keyed by whatever tool reported the failure — a lint rule that reports a keyboard-operability problem is as good a source of work as an axe rule, and it often has better source-level precision. And image-alt is marked partial, which is the honest answer for every rule whose eligibility depends on the individual node; the transform for it, worked through in automating decorative alt text safely, writes for one bucket and files the rest.

2. Write the transform with a narrow matcher

A matcher is narrow when it is the conjunction of a positive predicate and the absence of every escape hatch. The positive predicate is the easy half — the tag name plus the missing attribute. The escape hatches are what separate a safe transform from a destructive one: a JSX spread that might supply the attribute at runtime, a conditional expression as the attribute value, an ancestor whose presence changes the correct answer, a surrounding .map() that will render the node many times.

Refuse rather than guess, and record every refusal with a reason. A refusal ledger is the most useful artifact a codemod produces, for two reasons: it is the input to the next iteration of the matcher — a reason appearing eleven times is a pattern worth handling — and it is the evidence that the sweep’s coverage gap is known rather than accidental. A transform with no refusals on a real codebase is not precise, it is careless.

The transform below implements the button-has-type case end to end. It is short deliberately: the interesting code is the guard section, not the edit.

// codemods/add-button-type.js
// A <button> with no type attribute defaults to type="submit". Outside a form
// that default is meaningless, so type="button" is derivable. Inside a form the
// intent is a product decision and the transform must refuse.
const { appendFileSync } = require('node:fs');

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

  // Every refusal is one JSON line: file, line, reason. Appends are line-sized,
  // which keeps them intact across jscodeshift's worker processes.
  const refuse = (node, reason) => {
    if (!options.refusals) return;
    const line = node.loc ? node.loc.start.line : 0;
    appendFileSync(options.refusals, JSON.stringify({ file: file.path, line, reason }) + '\n');
  };

  const isForm = { openingElement: { name: { name: 'form' } } };

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

      // Escape hatch 1: a spread may carry `type` at runtime. Unprovable.
      if (attrs.some((a) => a.type === 'JSXSpreadAttribute')) {
        return refuse(open, 'spread-may-carry-type');
      }
      // Already correct — including type={cond ? 'submit' : 'button'}.
      if (attrs.some((a) => a.type === 'JSXAttribute' && a.name.name === 'type')) return;

      // Escape hatch 2: inside a form, the missing type means submit, and
      // whether that is intended is not derivable from this file.
      if (j(path).closest(j.JSXElement, isForm).size() > 0) {
        return refuse(open, 'inside-form-intent-unknown');
      }

      attrs.push(j.jsxAttribute(j.jsxIdentifier('type'), j.literal('button')));
      edits += 1;
    });

  // Returning null marks the file skipped rather than rewritten, so recast never
  // reprints a file the transform did not actually change.
  if (edits === 0) return null;
  return root.toSource({ quote: 'double' });
};

Escape hatch 2 also marks the boundary where jscodeshift runs out of information. If the form is a <CheckoutForm> wrapper defined in another file, closest finds nothing and the transform will happily add type="button" to what was the submit control. jscodeshift sees one file and syntax only; that is its whole cost model and its whole limitation. ts-morph holds the project graph instead, so it can resolve whether the <Field> in this file is the design system’s Field or a local component that merely shares the name, read that component’s props interface to find which prop carries the accessible name, and follow a required prop through every consumer. It pays for that with a cold project load measured in minutes on a large workspace, and it reprints the nodes it edits rather than preserving untouched formatting byte for byte.

What each tool can see decides which edits it can make The upper lane is jscodeshift, which sees one file of syntax and can add attributes, empty alt values and htmlFor pairs. The lower lane is ts-morph, which sees the whole project graph and types and can resolve which component a JSX tag refers to, read its props interface, and thread a required prop through consumers. Visibility decides the edit, not preference jscodeshift + recast — one file at a time sees tag names, attributes, lexical ancestors; 1,400 files in about 9 s add type on a native tag no ancestor outside the file empty alt on a spacer src literal in the same tag htmlFor plus a new id label and input are siblings ts-morph — the whole project graph sees imports, declarations and types; same workspace in about 2 min which Field is ours resolve the import, not the name read the props interface find the labelling prop by type thread a required prop every consumer, across files Timings measured on one 1,400-file workspace; the ratio matters more than the numbers.
Reach for ts-morph only when the fix genuinely crosses a file boundary — a cross-file question answered with jscodeshift is the single most common source of a wrong edit.

There is a third option worth checking before writing either transform: if the failure is already expressible as an ESLint rule with a fixer, eslint --fix puts the repair in the editor on save instead of in a quarterly sweep, and the sweep then only has to clear the backlog that existed before the rule was enabled. Sweeps are for history; lint is for the future.

3. Dry-run and review the diff

--dry --print is the documented dry run, and it is the wrong artifact for review. It prints whole transformed files to stdout, so a 900-line component appears in full when three attributes changed, and there is no way to read it as a patch. Apply the transform to a detached git worktree instead: the real files are untouched, and git diff inside the worktree produces exactly the patch a reviewer wants, with a --stat summary that answers the only question that matters at first glance — how many files, how many hunks.

#!/usr/bin/env bash
# codemods/dry-run.sh codemods/add-button-type.js packages/checkout
set -euo pipefail
transform="$1"; pkg="$2"
mkdir -p artifacts
refusals="$PWD/artifacts/refusals.jsonl"
: > "$refusals"                       # truncate: one ledger per dry run

work="$(mktemp -d)"
git worktree add --detach "$work" HEAD >/dev/null
trap 'git worktree remove --force "$work"' EXIT   # scratch copy always removed

# --parser=tsx covers TypeScript JSX (use ts for plain .ts). --extensions matters
# because jscodeshift defaults to js only and silently skips every .tsx file.
# --refusals is a custom flag; jscodeshift forwards unknown flags into options.
npx jscodeshift \
  -t "$transform" \
  "$work/$pkg" \
  --parser=tsx \
  --extensions=tsx,ts,jsx,js \
  --ignore-pattern='**/dist/**' \
  --ignore-pattern='**/*.stories.tsx' \
  --refusals="$refusals"

git -C "$work" diff > "$PWD/artifacts/sweep.patch"
git -C "$work" diff --stat | tail -1
printf 'refusals: %s rows\n' "$(wc -l < "$refusals")"

Read the patch with a fixed protocol rather than by scrolling. Open every hunk in any file with more than three edits, because a file with many edits is either the jackpot or the misfire and both deserve attention. Sample a dozen hunks at random from the remainder. Then read the refusal ledger from the other direction: group it by reason, and for each reason ask whether it should have been a match. A reason that dominates the ledger is usually a matcher improvement waiting to be written, and a reason with a count of one is usually genuinely unusual code.

One dry run over one package, node by node Four stacked stages narrow 1,412 candidate nodes to 118 predicate matches, then to 96 nodes that survive the guards, then to 96 edits across 24 files. A side panel breaks the 22 refusals into three reasons, and a second side panel names the patch file and the number of hunks read before applying. The sieve What review reads 1,412 candidate JSX nodes button tags in packages/checkout 118 match the predicate tag present, type attribute absent 96 survive the guards 22 refused, each with a reason 96 edits across 24 files one rule id, one commit refusals.jsonl — 22 rows 11 spread may carry type 7 inside a form element 4 tag comes from a wrapper grouped by reason, read as a list sweep.patch — 96 hunks 12 sampled plus every dense file
Both artifacts are read before anything is applied: the patch shows what the transform did, and the ledger shows what it declined to do and why.

4. Apply per package

Run the sweep one workspace package at a time, and let each package produce its own commit and its own pull request. This is a review constraint, not an aesthetic one. A 200-file diff spanning six teams has no natural reviewer, so it either sits unread for a fortnight or gets approved without being read; the same edits split into six package-sized pull requests each land in front of the people whose CODEOWNERS entry covers the code and who can recognise a wrong edit on sight.

Enforce a file ceiling per package and treat breaching it as a stop, not a warning. The manifest’s ceiling value is the number above which the matcher is assumed to be wrong until proved otherwise: if a transform expected to touch a dozen files suddenly wants forty-one, something in the predicate has widened. Reverting the working tree costs nothing at that point, and finding out after the merge costs an afternoon of bisecting.

#!/usr/bin/env bash
# codemods/apply-per-package.sh label 40
set -euo pipefail
rule="$1"; ceiling="$2"
transform="$(node -p "require('./codemods/eligibility.json')['$rule'].transform")"
base="$(git rev-parse --abbrev-ref HEAD)"

# npm query reports one row per workspace; location is the package directory.
mapfile -t packages < <(npm query .workspace --json | node -e '
  let s = "";
  process.stdin.on("data", (c) => (s += c));
  process.stdin.on("end", () => {
    for (const w of JSON.parse(s)) console.log(w.location);
  });
' | sort)

for pkg in "${packages[@]}"; do
  npx jscodeshift -t "$transform" "$pkg" --parser=tsx \
    --extensions=tsx,ts,jsx,js --ignore-pattern='**/dist/**' >/dev/null

  changed="$(git diff --name-only | wc -l | tr -d ' ')"
  [ "$changed" -eq 0 ] && continue

  if [ "$changed" -gt "$ceiling" ]; then
    printf 'STOP %s: %s files exceeds the ceiling of %s\n' "$pkg" "$changed" "$ceiling" >&2
    git checkout -- .                 # discard, then fix the matcher
    continue
  fi

  # Format only the files the transform touched, never the whole workspace.
  git diff --name-only | xargs npx prettier --write
  git switch -c "a11y/$rule/$(basename "$pkg")"
  git commit -am "fix($pkg): $rule via codemod, $changed files"
  git switch "$base"
done

Formatting discipline is what keeps the diff readable. Running a workspace-wide formatter after a sweep buries eleven meaningful attribute additions under nine hundred whitespace lines, and a reviewer who opens that diff closes it again. Piping git diff --name-only into the formatter keeps the ratio of signal to noise roughly where the transform left it. If the repository is not formatted consistently to begin with, fix that in its own commit before the sweep rather than as part of it.

5. Verify with a rescan

A sweep is not finished when the transform succeeds; it is finished when the scanner agrees. Scan each target package before the transform and again after it, then compare counts per rule id — never totals. A total that falls by thirty tells you nothing about whether the thirty that disappeared are the thirty you were aiming at, and it hides the case that matters most: the target rule fell by thirty-four while a different rule rose by four.

The comparison has two assertions. Every rule id in the manifest’s verify array must have fallen. Every other rule id must not have risen. The second assertion is the one that catches real damage — a label sweep that generates a duplicated id raises duplicate-id-aria, an alt sweep that adds alt="" next to an existing role="presentation" raises presentation-role-conflict, and both are the transform’s fault even though neither is the rule it was told to fix.

// codemods/verify-sweep.mjs — node codemods/verify-sweep.mjs before.json after.json label
import { readFileSync } from 'node:fs';

const [beforePath, afterPath, ...targets] = process.argv.slice(2);

// Each report is an array of axe result objects, one per scanned URL.
const countByRule = (path) => {
  const counts = new Map();
  for (const page of JSON.parse(readFileSync(path, 'utf8'))) {
    for (const v of page.violations) {
      counts.set(v.id, (counts.get(v.id) ?? 0) + v.nodes.length);
    }
  }
  return counts;
};

const before = countByRule(beforePath);
const after = countByRule(afterPath);
let failed = false;

console.log('| rule id | before | after | delta |');
console.log('|---|---|---|---|');
for (const id of [...new Set([...before.keys(), ...after.keys()])].sort()) {
  const b = before.get(id) ?? 0;
  const a = after.get(id) ?? 0;
  console.log(`| ${id} | ${b} | ${a} | ${a - b} |`);
  if (targets.includes(id) && a >= b) {
    console.error(`FAIL ${id} did not fall: ${b} -> ${a}`);
    failed = true;
  }
  // A sweep that trades one rule id for another has fixed nothing.
  if (!targets.includes(id) && a > b) {
    console.error(`FAIL ${id} rose from ${b} to ${a}: collateral damage`);
    failed = true;
  }
}
process.exit(failed ? 1 : 0);

Add two cheap checks alongside the rescan. Run the transform a second time on the already-swept tree and assert git diff --quiet succeeds, which proves idempotence and catches any matcher that appends instead of checking. And run tsc --noEmit plus the existing unit suite, because an attribute that is valid JSX can still be invalid for the component’s prop types, and a codemod is exactly the kind of change that finds out in production otherwise.

Per-package violation counts, before and after one sweep Four package groups each show a before column and an after column for the combined label and image-alt counts: checkout falls from 62 to 0, account from 34 to 0, marketing from 48 to 3, and legacy-admin stays at 27 because every candidate node there was refused by the guards. label and image-alt nodes per package, one sweep before after 0 20 40 60 62 0 checkout 34 0 account 48 3 marketing 27 27 legacy-admin legacy-admin did not move: its labels are rendered by a parent package, so every node was refused. No other rule id rose in any package — that assertion is the second half of the verification.
A flat column is a result, not a failure: it says the guards held and those nodes need a different instrument.

Pipeline Integration

Codemods do not belong in the pull-request gate. They are triggered deliberately, they produce a branch rather than a verdict, and their exit code decides whether a pull request is opened at all — the sweep either proves it improved the rule-id counts or it throws its own work away. The workflow below takes a rule id and a package as inputs, refuses any rule id absent from the manifest, and only reaches create-pull-request if the transform’s fixtures pass, the rescan shows the target rule falling, and no other rule id rose.

name: a11y-codemod-sweep
on:
  workflow_dispatch:
    inputs:
      rule:
        description: Rule id from codemods/eligibility.json
        required: true
      package:
        description: Workspace directory, for example packages/checkout
        required: true
permissions:
  contents: write
  pull-requests: write
jobs:
  sweep:
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    env:
      # Inputs go through the environment, never inline into a shell command.
      RULE: ${{ inputs.rule }}
      PKG: ${{ inputs.package }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm
      - run: npm ci
      - name: Refuse an ineligible rule id
        run: node codemods/assert-eligible.mjs "$RULE" # exits 1 if derivable is false
      - name: Unit-test the transform itself
        run: npx jest codemods/__tests__ --ci
      - name: Baseline scan
        run: npm run a11y:scan -- --package "$PKG" --out artifacts/before.json
      - name: Dry run into a scratch worktree
        run: |
          transform=$(node -p "require('./codemods/eligibility.json')['$RULE'].transform")
          bash codemods/dry-run.sh "$transform" "$PKG"
      - name: Apply and commit
        run: |
          ceiling=$(node -p "require('./codemods/eligibility.json')['$RULE'].ceiling")
          bash codemods/apply-per-package.sh "$RULE" "$ceiling"
      - name: Prove idempotence
        run: |
          transform=$(node -p "require('./codemods/eligibility.json')['$RULE'].transform")
          npx jscodeshift -t "$transform" "$PKG" --parser=tsx --extensions=tsx,ts,jsx,js
          git diff --quiet # a second run must produce no diff at all
      - name: Rescan and compare per rule id
        run: |
          npm run a11y:scan -- --package "$PKG" --out artifacts/after.json
          verify=$(node -p "require('./codemods/eligibility.json')['$RULE'].verify.join(' ')")
          node codemods/verify-sweep.mjs artifacts/before.json artifacts/after.json $verify \
            >> "$GITHUB_STEP_SUMMARY"
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: sweep-${{ inputs.rule }}
          path: artifacts/
          retention-days: 14
      - uses: peter-evans/create-pull-request@v6
        with:
          branch: a11y/sweep-${{ inputs.rule }}
          title: "Codemod sweep: ${{ inputs.rule }} in ${{ inputs.package }}"
          labels: accessibility, codemod

The pull request that comes out of this workflow is then gated by the ordinary checks described in pull request gating and branch policies, which is the right division of labour: the sweep proves it did what it claimed, and the branch protection proves the branch is still shippable. Attach both sweep.patch and refusals.jsonl to the pull request body so the reviewer sees the coverage gap without opening the run.

Troubleshooting and Flaky-Test Mitigation

  • The transform reports 0 ok on a TypeScript project. jscodeshift’s default extension list is js only, so every .tsx file is skipped silently and the run looks like a clean no-op. Always pass --extensions=tsx,ts,jsx,js together with --parser=tsx.
  • The whole file reprints and the diff is a thousand lines. recast preserves original formatting only for nodes it did not touch; replacing a JSXElement wholesale reprints its entire subtree. Mutate the attributes array in place instead of constructing a replacement element, and return null from files with no edits.
  • The sweep rewrote build output. A package with a committed dist/ or .next/ directory will be traversed like any other source. Pass explicit --ignore-pattern values; do not rely on .gitignore, which jscodeshift does not read by default.
  • The rescan fails but the patch looks right. Confirm the scanner is stable before blaming the transform: rescan the pre-sweep commit in the same job and diff the two baseline reports. If the baseline itself moves, the scan is racing hydration rather than reacting to the codemod, and the wait needs fixing first.
  • Counts fall on the target rule and rise on duplicate-id-aria. The transform is generating identifiers that collide, usually because it is numbering per file while the component renders inside a list. Derive identifiers from field names, and refuse nodes inside an array callback.
  • ts-morph exhausts memory in CI. Loading a whole monorepo graph in a 7 GB runner will fail. Construct the Project per package with skipAddingFilesFromTsConfig: true and add only the source globs that package owns.
  • Two transforms in the same run fight over one file. The second transform parses source the first has already rewritten, so a refusal reason recorded by one may no longer apply. Run one rule id per invocation and one commit per rule id, as the apply loop does.

Common Pitfalls

  • Judging eligibility by how easy the edit is instead of by whether a wrong edit would still fail a scan.
  • Treating a large match count as success; a blanket sweep that touches 400 files has almost certainly stopped being derivable somewhere in the middle.
  • Reviewing --dry --print output instead of a patch, so the diff is read as whole files and the dense ones get skipped.
  • Discarding refusals, which throws away both the coverage map and the best available list of matcher improvements.
  • Running a workspace-wide formatter after the sweep, drowning the accessibility diff in whitespace churn.
  • Numbering generated identifiers with a per-file counter, so inserting one field above renumbers everything below it and the next sweep produces a mystifying diff.
  • Comparing total violation counts before and after instead of counts per rule id, which hides a rule traded for another rule.
  • Letting the sweep push to the default branch, or merging it without a reviewer who owns the package.
  • Sweeping a rule id that a lint rule with a fixer could have prevented at the keyboard, so the same backlog regrows after the next feature branch.

FAQ

When is a blanket sweep better than a report-driven transform? When the source predicate and the DOM predicate are the same statement, and when the scanner cannot reach the code. An <img> with no alt in the source is the same node the scanner flags, so the report adds nothing but a filter. Components that render only behind a feature flag, on an authenticated route, or in an error state are invisible to a crawl, and a blanket run over the package is the only way to find them — at the cost of a diff the matcher, rather than the report, has to keep honest.

Should a codemod ever merge without review? No, and the reason is not caution about the tool. A merged sweep is a claim that every edited node was correct, and only a person who knows the package can falsify that claim for the nodes the guards let through. Keep the automation on everything up to the pull request — matching, dry run, apply, rescan, artifact upload — and require a human approval as the last step, which also keeps the git history attributable.

How large should one sweep be? One rule id, one package, one commit, and a file ceiling in the tens rather than the hundreds. That shape gives the reviewer a single question to answer and gives a revert a single commit to remove. Sweeps that span rule ids make the rescan comparison ambiguous, because a count that moved cannot be attributed to a transform.

What happens to the nodes the transform refuses? They become work, tracked with their reason. Refusals that need a written sentence go to the AI-assisted remediation path, where a model drafts the text and a reviewer accepts it. Refusals that repeat across a component are a signal to fix the component instead, which is the argument for design-system accessibility defaults — the same edit made once, upstream, permanently.

Does a successful sweep need a regression test? Yes, and not one per node. Add a single test per rule id that asserts the fixed shape at the component level, so the next refactor cannot quietly reintroduce the class, and let regression prevention after fixes own the mechanics. A sweep with no test behind it is a temporary state, and the counts will drift back within two quarters.

In This Section