Bulk-Fixing Form Label Associations with jscodeshift

The text is already on the screen. A <label> reads “Email address”, the <input> sits directly beneath it, every sighted user understands the pairing, and a screen reader announces “edit text, blank” — because nothing in the markup states the relationship. This is the most rewarding class of accessibility violation to automate, since the transform adds no information: it only makes an existing relationship machine-readable. This guide is part of Codemod-Driven Accessibility Fixes, and it covers the three association shapes, the identifier-generation rule that keeps a sweep idempotent, and the ambiguities the transform must decline.

Root Cause

The label rule fires when a form control exposes no accessible name through any supported mechanism: an associated <label for>, a <label> that contains the control, aria-labelledby, aria-label, or a title attribute. Visual proximity is not a mechanism. Name computation walks the accessibility tree, not the layout, so a label positioned by CSS grid two rows above its input is as unassociated as one on a different page. That rule maps to WCAG 2.2 SC 4.1.2 (Name, Role, Value), and its companion form-field-multiple-labels maps to WCAG 2.2 SC 3.3.2 (Labels or Instructions) — which matters here because a careless transform trips the second while fixing the first.

Hand-written JSX produces this at scale. A component author writes the label and the input as siblings, the design system supplies the spacing, and nothing in the type system asks for an identifier. Six months later a scan reports 214 unlabelled controls across a checkout package, and each one is two attributes away from correct.

Unlike alt text, a wrong edit here is loud. Point htmlFor at an identifier that does not exist and the control is still unnamed, so label fires again. Point it at the wrong control and the other control loses its name, so label fires there. Generate an identifier twice and duplicate-id-aria fires. Every plausible mistake produces a scanner failure on the next run, which is what makes this rule id genuinely safe to sweep: the verification step can catch the transform’s own errors, which is never true of a generated text alternative.

Configuration

Three shapes create the association, and the transform treats them differently. <label htmlFor> pointing at a matching id is the shape it creates: purely additive, two attributes, no nodes moved. A wrapping <label> that already contains its control is a shape it preserves — the association exists by containment, and adding htmlFor on top of it is redundant noise while restructuring a sibling pair into a wrapper moves nodes and risks the layout. aria-labelledby is the fallback for the case where the visible text is not in a <label> element at all, which is common in design systems that style a <span> as a field label.

Three association shapes and what the transform may do with each The first panel shows a label with htmlFor and an input with a matching id, which the transform may create because the edit is additive. The second shows a label wrapping its input, which the transform preserves but never creates because it would move nodes. The third shows a span with an id referenced by aria-labelledby, which the transform creates only when no label element exists. Three shapes name a control; only two may be written 1 htmlFor and id <label htmlFor="email"> Email address </label> <input id="email" /> create: two attributes no node ever moves 2 wrapping label <label> Email address <input /> </label> preserve, never create wrapping moves nodes 3 aria-labelledby <span id="lbl"> Email address</span> <input aria-labelledby="lbl" /> create only if no label element exists nearby All three satisfy SC 4.1.2. Which one applies is decided by what the file already contains: a label element next to the control, a label element around it, or only styled text.
Shape 2 is the reason the transform reads the label's children before anything else: containment is already a valid association and must be left exactly as it is.

The identifier is where sweeps usually go wrong. Derive it from the field’s own name, prefixed by the enclosing <fieldset> legend when there is one, and suffixed so it cannot collide with an unrelated identifier that happens to share the word. shipping-email-field is a function of the field itself, so a second run produces the same string, a rerun after an unrelated edit above it produces the same string, and a reviewer reading the diff can tell at a glance whether the pairing is right. A per-file counter cannot do any of that: insert one field at the top of a form and every identifier below it renumbers, so the next sweep produces a diff full of changes that fix nothing.

Name-derived identifiers versus a per-file counter The left column derives an identifier in four steps: read the name attribute, take a prefix from the fieldset legend, join them into shipping-email-field, and refuse if the same identifier appears twice in the file. The right column shows a per-file counter producing email-1, phone-2 and card-3, then renumbering all three after a field is inserted above them. The identifier decides whether the sweep is repeatable derived from the field counted per file name="email" gives the base slug email the fieldset legend gives the prefix id = shipping-email-field same id twice in one file: refuse both sweep 1: email-1, phone-2, card-3 a new field is inserted at the top sweep 2: name-1, email-2, phone-3 three unrelated ids changed: churn A derived id is a function of the field, so any rerun produces the same string. That is what makes the second run a no-op and the diff proportional to the fix.
Ordinal identifiers are stable only until the form changes, which is the one thing forms reliably do.

The transform below implements shape 1 with every guard in place. Each refusal writes a row to a ledger rather than logging and moving on, because the refused controls are the ones that need a person and they should leave the run as a list, not as an absence.

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

const CONTROLS = new Set(['input', 'select', 'textarea']);
const NAMED = ['aria-label', 'aria-labelledby'];
const LOOPS = new Set(['map', 'flatMap', 'forEach']);

const slug = (s) =>
  String(s).replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();

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

// Literal values only: id={`${p}-email`} has no build-time value.
const literal = (open, name) => {
  const a = attr(open, name);
  if (!a || !a.value) return null;
  const v = a.value;
  return v.type === 'Literal' || v.type === 'StringLiteral' ? String(v.value) : null;
};

// Direct text children only, so {t('email')} never becomes part of an id.
const visibleText = (node) =>
  (node.children || [])
    .filter((c) => c.type === 'JSXText')
    .map((c) => c.value.trim())
    .filter(Boolean)
    .join(' ');

const isControl = (child) =>
  child.type === 'JSXElement' &&
  child.openingElement.name.type === 'JSXIdentifier' &&
  CONTROLS.has(child.openingElement.name.name);

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

  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');
  };

  // A static id inside a list callback duplicates once per row at run time.
  const inLoop = (path) => {
    for (let p = path.parent; p; p = p.parent) {
      const n = p.node;
      if (
        n.type === 'CallExpression' &&
        n.callee.type === 'MemberExpression' &&
        n.callee.property.type === 'Identifier' &&
        LOOPS.has(n.callee.property.name)
      ) {
        return true;
      }
    }
    return false;
  };

  const legendPrefix = (path) => {
    for (let p = path.parent; p; p = p.parent) {
      const n = p.node;
      if (n.type !== 'JSXElement') continue;
      if (n.openingElement.name.name !== 'fieldset') continue;
      const legend = (n.children || []).find(
        (c) => c.type === 'JSXElement' && c.openingElement.name.name === 'legend',
      );
      return legend ? slug(visibleText(legend)) : null;
    }
    return null;
  };

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

    if (attr(open, 'htmlFor')) return;                   // shape 1 already present
    if ((label.children || []).some(isControl)) return;  // shape 2: leave containment alone

    const siblings = path.parent.node.children || [];
    const after = siblings.slice(siblings.indexOf(label) + 1);
    // A following label ends this label's scope: fields past it belong to it.
    const nextLabel = after.findIndex(
      (c) => c.type === 'JSXElement' && c.openingElement.name.name === 'label',
    );
    const scope = nextLabel === -1 ? after : after.slice(0, nextLabel);
    const candidates = scope.filter(isControl);

    if (candidates.length === 0) return refuse(open, 'no-control-in-scope');
    if (candidates.length > 1) return refuse(open, 'two-candidate-controls');

    const control = candidates[0].openingElement;
    if (NAMED.some((n) => attr(control, n))) return refuse(control, 'control-already-named');
    if (control.attributes.some((a) => a.type === 'JSXSpreadAttribute')) {
      return refuse(control, 'spread-may-carry-id');
    }
    if (inLoop(path)) return refuse(control, 'inside-a-loop-id-would-duplicate');

    const existing = attr(control, 'id');
    let id = existing ? literal(control, 'id') : null;
    if (existing && !id) return refuse(control, 'id-is-a-runtime-expression');

    if (!id) {
      const base = literal(control, 'name') || visibleText(label);
      if (!base) return refuse(control, 'nothing-to-derive-an-id-from');
      const prefix = legendPrefix(path);
      id = `${prefix ? prefix + '-' : ''}${slug(base)}-field`;
      // Derived, never counted. A collision is an ambiguity, so refuse it.
      if (source.includes(`"${id}"`)) return refuse(control, 'derived-id-already-in-file');
      control.attributes.push(j.jsxAttribute(j.jsxIdentifier('id'), j.literal(id)));
    }

    open.attributes.push(j.jsxAttribute(j.jsxIdentifier('htmlFor'), j.literal(id)));
    edits += 1;
  });

  if (edits === 0) return null;
  return root.toSource({ quote: 'double' });
};

Shape 3 is a second pass over the same tree, and it only runs where shape 1 could not: a control whose nearest preceding element is styled text rather than a <label>. It gives that element an identifier and points aria-labelledby at it, which names the control without moving a single node — the same additive property that makes shape 1 safe.

// codemods/associate-labels.js, continued inside the same transformer
const TEXT_TAGS = new Set(['span', 'div', 'p']);

// Identifiers already claimed by a label, including the ones just written above.
const claimed = new Set(
  root
    .find(j.JSXOpeningElement, { name: { name: 'label' } })
    .nodes()
    .map((open) => literal(open, 'htmlFor'))
    .filter(Boolean),
);

root.find(j.JSXElement, { openingElement: { name: { name: 'input' } } }).forEach((path) => {
  const control = path.node.openingElement;
  if (NAMED.some((n) => attr(control, n))) return;
  const own = literal(control, 'id');
  if (own && claimed.has(own)) return; // shape 1 already named this control

  const siblings = path.parent.node.children || [];
  const before = siblings.slice(0, siblings.indexOf(path.node)).reverse();
  const previous = before.find((c) => c.type === 'JSXElement');
  if (!previous) return;
  const tag = previous.openingElement.name;
  if (tag.type !== 'JSXIdentifier' || !TEXT_TAGS.has(tag.name)) return;

  const text = visibleText(previous);
  if (!text) return refuse(control, 'adjacent-text-is-not-static');

  const labelId = literal(previous.openingElement, 'id') || `${slug(text)}-label`;
  if (!literal(previous.openingElement, 'id')) {
    if (source.includes(`"${labelId}"`)) return refuse(control, 'derived-label-id-in-use');
    previous.openingElement.attributes.push(
      j.jsxAttribute(j.jsxIdentifier('id'), j.literal(labelId)),
    );
  }
  control.attributes.push(
    j.jsxAttribute(j.jsxIdentifier('aria-labelledby'), j.literal(labelId)),
  );
  edits += 1;
});

Validation

Test the guards first and the happy path second, because the guards are the part that protects working code. Every refusal case gets a test that asserts the source came back byte-identical.

// codemods/__tests__/associate-labels.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('../associate-labels');

const refusals = join(mkdtempSync(join(tmpdir(), 'label-')), 'refusals.jsonl');
const run = (source) =>
  applyTransform(transform, { parser: 'tsx', refusals }, { source, path: 'Signup.tsx' });
const lastReason = () =>
  JSON.parse(readFileSync(refusals, 'utf8').trim().split('\n').pop()).reason;

test('a sibling pair is associated with a name-derived id', () => {
  const out = run('<div><label>Email address</label><input name="email" /></div>');
  expect(out).toContain('htmlFor="email-field"');
  expect(out).toContain('id="email-field"');
});

test('a fieldset legend prefixes the id', () => {
  const out = run(
    '<fieldset><legend>Shipping</legend>' +
      '<label>Email address</label><input name="email" /></fieldset>',
  );
  expect(out).toContain('id="shipping-email-field"'); // stable across reruns
});

test('two candidate controls are refused', () => {
  const source = '<div><label>Email</label><input name="a" /><input name="b" /></div>';
  expect(run(source)).toBe(source);
  expect(lastReason()).toBe('two-candidate-controls');
});

test('a runtime id is refused rather than overwritten', () => {
  const source = '<div><label>Email</label><input id={inputId} name="email" /></div>';
  expect(run(source)).toBe(source);
  expect(lastReason()).toBe('id-is-a-runtime-expression');
});

test('a control inside a loop is refused', () => {
  const source =
    '<div>{rows.map((r) => (<div><label>Qty</label><input name="qty" /></div>))}</div>';
  expect(run(source)).toBe(source);
  expect(lastReason()).toBe('inside-a-loop-id-would-duplicate');
});

test('a wrapping label is untouched and the run is idempotent', () => {
  const source = '<label>Email address<input name="email" /></label>';
  expect(run(source)).toBe(source);
});

Then prove the association resolves at run time, which is a different claim from “the attributes are present”. A query by label text is the closest a unit test gets to what a screen reader does, because it goes through the same name computation.

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

test('the input is reachable by its visible label text', () => {
  render(
    <div>
      <label htmlFor="email-field">Email address</label>
      <input id="email-field" name="email" type="email" />
    </div>,
  );
  // Fails if htmlFor and id disagree, or if two controls claim the same label.
  expect(screen.getByLabelText('Email address')).toHaveAttribute('name', 'email');
});

The last step is the rescan, scoped to the rules this sweep can move. label must fall to zero on the swept package, and the two rules a bad edit would trip must stay flat — a rise in either is the transform’s fault even though neither is the rule it was asked to fix.

| rule id                    | before | after | delta |
|----------------------------|--------|-------|-------|
| label                      |    214 |    46 |  -168 |
| form-field-multiple-labels |      3 |     3 |     0 |
| duplicate-id-aria          |      0 |     0 |     0 |

The 46 that remain are the refusals, and they are a result rather than a shortfall. Grouping the ledger by reason turns the sweep’s coverage gap into an ordered work queue: the largest group is usually a single architectural pattern, and fixing it upstream clears more controls than any further matcher tuning would.

The 46 refusals, grouped by reason Of 214 orphan controls in the checkout package, 168 were associated and 46 refused. The refusals break down as seventeen with two candidate labels, fourteen whose label is rendered by a parent component, nine whose id is a runtime expression, and six that are custom components rather than native controls. What the transform refused, and why 214 orphan controls in packages/checkout — 168 associated, 46 refused two candidate labels 17 label in a parent component 14 id is a runtime expression 9 a custom component control 6 0 5 10 15 The top two groups are one architectural fix each, not 31 individual tickets. Refusals are read as a queue, which is why every one of them carries a reason.
Ordering the ledger by count turns the sweep's blind spots into the next piece of work rather than a silent gap in coverage.

Edge Cases and Conditional Guards

  • Two candidate labels. A control with a label above it and helper text styled as a label below it has two plausible names, and choosing wrongly produces a form-field-multiple-labels violation on top of a misleading announcement. The transform refuses whenever more than one control falls inside a label’s scope, and whenever a control already carries aria-label or aria-labelledby.
  • The label lives in another component. A layout component renders the <label> and passes children, while the field component renders the <input>. jscodeshift sees one file, so the pair is invisible to it and both halves look like orphans. This needs the type-aware path or, better, a labelling prop the component cannot omit — the argument for design-system accessibility defaults.
  • Dynamically generated ids. id={useId()} or id={ followed by a template literal has no build-time value, so writing htmlFor="something" would point at a string that never exists in the DOM. The transform refuses on any non-literal id and leaves the pair for a hand fix that reuses the same runtime value on both attributes.
  • Controls rendered inside a list. A static id inside rows.map(...) becomes the same id on every row, which trades one label violation for a page full of duplicated identifiers. Any control under a map, flatMap or forEach callback is refused; the correct fix there is a per-row identifier derived from the row key.
  • Custom component controls. <TextField /> is not a native control, and the prop that carries the identifier could be id, inputId or fieldId. Matching on native tags only keeps the transform honest, and the residue is small enough to fix by hand once the component itself takes a required label prop.

Pipeline Impact

The sweep produces one branch per package with a purely additive diff, so review is fast and the revert is a single commit. Wire the rescan comparison into the job as the gate: if label did not fall, or if form-field-multiple-labels or duplicate-id-aria rose, the run fails and no pull request is opened. That branch is then checked by the ordinary workflow in configuring GitHub Actions for automated WCAG checks, which scans the proposed branch rather than trusting the transform’s own accounting.

Commit the refusal ledger with the branch. It is the only record of which controls a machine could not name, and it is the input to two follow-up decisions: which component needs a required label prop, and which forms need a hand fix. Finish by adding one component-level regression test per form the sweep touched, so the association cannot be removed by a later refactor without a failure — the mechanics of that guard belong to regression prevention after fixes, and without it the same 168 controls drift back over a few quarters.

Common Pitfalls

  • Writing for instead of htmlFor in JSX, which React drops silently, so the diff looks correct and the association never exists.
  • Numbering generated identifiers per file, so any inserted field renumbers the rest and the next sweep produces a diff full of no-op changes.
  • Adding htmlFor to a label that already wraps its control, which is redundant and can produce a second name for the same field.
  • Associating a control that already has aria-label, giving it two competing names and a form-field-multiple-labels violation where there was none.
  • Guessing an identifier for a control whose id is a runtime expression, which points htmlFor at a string that never appears in the DOM.
  • Discarding the refusal ledger, which hides the 46 controls that still have no name behind a headline number that fell by 168.

FAQ

Why prefer htmlFor and id over restructuring the markup into a wrapping label? Both satisfy WCAG 2.2 SC 4.1.2, but only one is safe to automate. Adding two attributes cannot change layout, focus order or styling, and it reverts cleanly. Moving an <input> inside a <label> changes the element tree, which can break a CSS grid placement, a sibling selector or a component that expects a specific child order — and it produces a diff a reviewer has to reason about rather than scan.

What if the field has no name attribute to derive an identifier from? The transform falls back to a slug of the label’s own static text, so <label>Email address</label> yields email-address-field. If the label text is a translation call rather than static text there is nothing to derive from, and the node is refused. That fallback is deliberately last, because a name is stable while visible copy is edited by anyone.

Should this transform ever run on the same package as an alt-text sweep? Not in the same invocation. One rule id per run and one commit per rule id keeps the rescan comparison attributable: if two transforms touch one file and duplicate-id-aria rises, there is no way to tell which one did it. Run them in sequence, and see automating decorative alt text safely for the sibling sweep, which needs a much tighter ceiling because its mistakes are silent.