Regression Prevention After Accessibility Fixes

A fix with no test is a fix with a scheduled expiry date. This guide is part of Automated Remediation & Accessibility Fixing Patterns, and it covers the narrow question of what has to be true before a closed accessibility finding can be called closed: which assertion holds the repaired behaviour in place, which artifact catches the structural change no rule fires on, which lint rule stops the same mistake from being written again somewhere else, and what the pull request that contains the fix must carry before it is allowed to merge.

The failure this prevents is specific enough to be worth describing in full. A serious aria-input-field-name finding on the saved-search combobox at src/search/SavedSearchBar.tsx:63 is fixed in March by wiring aria-labelledby to the panel’s heading. Five months later a props cleanup collapses two wrapper components and the attribute goes with them. The nightly scan stays green, the pull-request scan stays green, and nobody is lying: the control only exists after the Filters disclosure is opened, the suite only ever loads the closed state, and a node that is not in the DOM cannot produce a violation. The finding is back in the product and absent from every report until an auditor opens the panel by hand in November, files it as new, and the same engineer fixes the same line a second time.

Problem Statement

Three distinct mechanisms let a healed finding come back without anything turning red, and they need three different answers. The first is a coverage gap: the repaired node lives behind an interaction — a disclosure, a tab, a menu, the third step of a wizard — that no scan in the pipeline performs, so the rule that would catch the regression never sees the element. Adding routes does not help, because the problem is state, not URL.

The second is a change the rule catalogue has no opinion about. An aria-labelledby that keeps pointing at a real element but now points at the wrong one, a combobox whose wrapper stops being a group, a heading that drops from level 2 to level 4, an aria-live region downgraded from assertive to polite: each of those alters what assistive technology is told, and each leaves every axe rule green. A gate built only from rule results is blind to all of them by construction.

The third is class recurrence. The fix corrected one call site; the mistake was a pattern. Twelve Combobox instances were missing a labelling prop, the sweep repaired all twelve, and instance thirteen arrives in a feature branch four weeks later written by someone who never read the remediation pull request. A perfect test on the twelve original nodes passes, because none of them regressed.

Underneath all three sits a process failure that is easier to fix than any of them: the fix and its test travel separately. The fix is urgent, the test is “a follow-up”, the follow-up is never filed, and the pipeline ends up protecting the fixes that happened to be made by careful people. A lock that is optional is a lock that is absent from exactly the pull requests that needed it most.

Key implementation targets:

  • A lock plan generated from the sweep’s own manifest, which fails the sweep when any healed finding has no lock assigned to it.
  • One targeted assertion per healed finding: the interaction that reaches the repaired node, a durable locator, the single rule id that was violated, and a count guard so the assertion cannot pass vacuously.
  • One accessibility-tree snapshot per repaired subtree, scoped to the component the fix touched rather than the page it lives on.
  • One promoted lint rule per source-visible failure class the sweep healed, plus a syntax restriction for the classes no published lint rule knows about.
  • A fix pull request that carries a machine-readable A11y-Fixes trailer per closed finding, and a CI job that refuses the merge when a claimed fix has no new test or a snapshot changed with no stated reason.
  • Named, blocking status checks whose failure output points at the original remediation pull request rather than at a mystery.

Prerequisites

Where each lock stops a regression Three rose-labelled regression classes move left to right through three vertical lock bands. A props cleanup that drops an attribute is stopped by the lint band. A label lost inside a prop spread passes lint and is stopped by the targeted assertion band. A fieldset grouping removed with no rule failure passes both and is stopped by the tree snapshot band, so the clean-main box on the right is never reached. Three regressions, three different locks lint rule commit targeted assertion tree snapshot props cleanup drops aria-labelledby label lost inside a prop spread fieldset grouping silently removed main stays clean A filled mark is a lock that catches the change; a dash is a lock that is blind to it.
None of the three locks is a superset of another, which is why a fix that only gets one of them is protected against one third of the ways it can break.

1. Deriving the Lock Plan from the Violation Report

Everything needed to decide what a fix owes is already in the violation record. axe reports id (the rule), impact, and a nodes array in which each entry carries target (a CSS path to the element), html (the element’s opening tag), failureSummary (prose naming the specific check that failed) and any/all/none arrays holding the individual check results. A report-driven sweep adds three more fields that matter more than any of those: a stable fingerprint, a source location resolved back to a file and line, and — if the sweep scanned a state other than first paint — the reachedBy interaction it performed to get there.

The routing decision is per rule id, not per finding. A rule whose failure is a missing attribute value on a single element earns an assertion; a rule whose fix restructured a subtree earns a snapshot as well; a rule whose failure is visible in the JSX source earns a lint promotion. The table below is the routing used for the nine rule ids this sweep healed, and the reason each column is what it is.

Rule id healed Assertion Subtree snapshot Lint promotion Why
aria-input-field-name yes yes no Name arrives from another node; the reference can move
aria-required-children yes yes no The fix changed the shape of the subtree, not one attribute
label yes no yes Source-visible in JSX and covered by a published lint rule
button-name yes no yes Same, and the name is invisible when it disappears
image-alt yes no yes Fully static; the lint rule is the cheaper guard
aria-allowed-attr yes no yes AST-checkable once the role is literal in the source
duplicate-id-aria yes no no Collision depends on the rendered page, not one file

Generate the plan rather than curating it. A twenty-line script reads the manifest, attaches the lock list, and exits non-zero when any healed finding ends up with an empty list — which is the whole point, because a rule id nobody has classified is a fix nobody has decided how to protect.

// a11y/locks/plan-locks.mjs
// Turn the sweep's manifest into one lock plan per healed finding.
import { readFileSync, writeFileSync } from 'node:fs';

// `lint` appears only for rule ids whose failure is visible in the JSX
// source; `snapshot` only where the repair changed the shape of a subtree
// rather than the value of one attribute.
const LOCKS_BY_RULE = {
  'aria-input-field-name': ['assertion', 'snapshot'],
  'aria-required-children': ['assertion', 'snapshot'],
  'aria-required-parent': ['assertion', 'snapshot'],
  'label': ['assertion', 'lint'],
  'button-name': ['assertion', 'lint'],
  'image-alt': ['assertion', 'lint'],
  'aria-allowed-attr': ['assertion', 'lint'],
  'duplicate-id-aria': ['assertion'],
  'aria-valid-attr-value': ['assertion'],
};

const applied = JSON.parse(readFileSync('a11y/reports/applied.json', 'utf8'));

const plan = applied
  .filter((finding) => finding.status === 'healed')
  .map((finding) => ({
    fingerprint: finding.fingerprint,
    rule: finding.rule,
    route: finding.route,
    source: finding.source,
    // The interaction that makes the repaired node exist. Null means the
    // node is present at first paint; anything else must be replayed by
    // the assertion or the assertion will test an empty locator.
    reachedBy: finding.reachedBy ?? null,
    locks: LOCKS_BY_RULE[finding.rule] ?? [],
  }));

const unclassified = plan.filter((entry) => entry.locks.length === 0);
writeFileSync('a11y/locks/plan.json', JSON.stringify(plan, null, 2));

console.log(`${plan.length} healed findings; ${unclassified.length} unclassified`);
for (const entry of unclassified) {
  console.error(`  no lock for ${entry.rule} at ${entry.source}`);
}
// Every fix owes at least one lock, so an unclassified rule id fails here.
process.exit(unclassified.length === 0 ? 0 : 1);
From one violation record to three locks A record card lists rule id, impact, target selector, failure summary, source location and fingerprint. Three cards on the right show the targeted assertion derived from the rule id and target, the subtree snapshot derived from the element HTML and its container, and the lint promotion derived from the rule id class. A footer band states that plan-locks.mjs writes one plan per fingerprint and exits non-zero when a finding has no lock. the healed record what it earns id: aria-input-field-name impact: serious target: .sb__panel input failureSummary: no name source: SavedSearchBar:63 reachedBy: open Filters fingerprint: 7f21ac0b targeted assertion — always from rule id + reachedBy + a durable locator subtree snapshot — when shape changed from the repaired node's nearest named container lint promotion — when source-visible from the rule id's class, not this instance plan-locks.mjs writes one plan entry per fingerprint an entry with an empty lock list exits 1 — an unclassified rule id is an unprotected fix The record already contains the routing information; the only new decision is per rule id.
Because the plan is derived rather than written by hand, a rule id that appears in a future sweep with no routing entry stops the sweep instead of quietly producing an unprotected fix.

2. The Targeted Assertion

The targeted assertion is the lock that answers “is this exact finding still fixed”, and it has four required parts. It replays the interaction that makes the repaired node exist. It resolves the node through a locator that survives refactors — a role plus accessible name, or a test id added as part of the fix, never the CSS path from the report. It asserts the specific repaired property. And it re-runs exactly one rule id, scoped to the repaired subtree.

Restricting the scan to one rule is not laziness, it is what keeps the lock alive. A spec that runs the full rule set over the panel will eventually fail because of an unrelated colour-contrast change in a button someone else owns, and a spec that fails for reasons its name does not mention gets skipped rather than debugged. withRules(['aria-input-field-name']) fails only when this finding regresses, which means its failure message needs no interpretation.

The count guard is the part most implementations omit. AxeBuilder.include() on a selector that matches nothing produces zero violations and a green test, so a lock whose locator has drifted reports success forever. Asserting the locator resolves to exactly one node converts silent rot into a loud failure. The mechanics of choosing that locator, and of proving the assertion actually fails on the pre-fix code, are the subject of adding a regression test for every fixed violation.

// tests/a11y/locks/saved-search.lock.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

// The fingerprint from the sweep manifest goes in the test name so a failure
// eight months from now points at the pull request that made the fix.
const FINGERPRINT = '7f21ac0b';

test.describe('@a11y-lock saved-search combobox', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/search?q=jacket');
    // reachedBy from the plan: the repaired control does not exist until the
    // filter panel is open, which is why the route-level scan stayed green
    // when the label was removed.
    await page.getByRole('button', { name: 'Filters' }).click();
    await expect(page.getByRole('dialog', { name: 'Filters' })).toBeVisible();
  });

  test(`aria-input-field-name stays fixed [${FINGERPRINT}]`, async ({ page }) => {
    const combobox = page.getByRole('combobox', { name: 'Saved search' });

    // Guard first: an empty locator makes the scan below pass vacuously,
    // which is the most common way one of these locks rots unnoticed.
    await expect(combobox).toHaveCount(1);
    await expect(combobox).toHaveAccessibleName('Saved search');

    const results = await new AxeBuilder({ page })
      .include('[data-testid="saved-search"]') // only the repaired subtree
      .withRules(['aria-input-field-name'])    // only the rule that was fixed
      .analyze();

    expect(results.violations).toEqual([]);
  });
});

One spec file per repaired component, not per finding, keeps this from turning into four hundred files. Findings that share a component share a describe block and its beforeEach, so the interaction that reaches the state is written once and the per-finding tests stay three lines long. Tag the whole file @a11y-lock so the locks can be run as their own Playwright project, reported separately, and timed separately from the broader scan configured in axe-core configuration and setup.

3. The Tree Snapshot Lock

The assertion above still passes when the combobox keeps its name but stops being inside the group that gave it context, when the help text that used to be its accessible description disappears, or when the panel’s heading drops two levels. Those changes alter what a screen-reader user is told and break no rule, which is the gap the accessibility-tree snapshot exists to fill. It is not a better version of the assertion; it is a different question — the assertion asks whether one property is still correct, the snapshot asks whether the shape of the repaired subtree is still what was reviewed.

Scope is the whole design. A snapshot of the page churns on every unrelated change, gets regenerated with an update flag rather than read, and ends up recording whatever the code currently does — which is the opposite of a lock. A snapshot of the fourteen nodes inside the repaired panel changes only when that panel changes, so every diff line is a sentence a reviewer can evaluate. Keep the snapshot in the same spec file as the assertion for the same component so the two move together in every future refactor.

// tests/a11y/locks/saved-search.lock.spec.ts (same describe block)
test(`filter panel subtree is unchanged [${FINGERPRINT}]`, async ({ page }) => {
  const panel = page.getByRole('dialog', { name: 'Filters' });

  // 14 nodes, one owning team, one reason to change. The external file is
  // reviewable in the diff; --update-snapshots must never run in CI.
  await expect(panel).toMatchAriaSnapshot({ name: 'filters-panel.aria.yml' });
});

Two properties decide whether this survives its first year: what the snapshot contains and what happens when it changes. Volatile values inside accessible names — result counts, relative timestamps, currency totals — have to be absorbed by patterns before the file is committed, or the lock fails on every run and gets deleted. And a diff in a snapshot file has to be treated as a claim that needs justifying rather than as an artifact of the tooling. Both halves, including the normalisation rules and the review gate that reads the pull-request body, are worked through in snapshot testing accessibility trees to prevent regressions.

4. The Lint Rule for the Class

The first two locks protect the instances that were fixed. Neither has anything to say about the next instance. When the sweep healed twelve Combobox call sites that were missing a labelling prop, the interesting number is not twelve — it is however many Combobox call sites get written next quarter by people who never saw the fix. That is a class problem, and the cheapest place to solve a class problem is a lint rule that runs in the editor before the markup can even be committed.

Two mechanisms cover the class. For failure classes that a published rule already understands, promote the matching eslint-plugin-jsx-a11y rule from warning to error — and promote only the ones the sweep actually healed, because a blanket promotion turns a lint job into a hundred unrelated errors and gets reverted the same afternoon. For classes that no published rule knows about, because the requirement is about a component in your own design system rather than about a DOM element, a no-restricted-syntax selector encodes it in about six lines.

// eslint.config.js — protect the class, not the instances
import jsxA11y from 'eslint-plugin-jsx-a11y';

// Our Combobox wrapper must receive one of two labelling props. No published
// rule knows this contract, so it is expressed as a syntax restriction.
const COMBOBOX_NEEDS_LABEL = [
  'JSXOpeningElement[name.name="Combobox"]',
  ':not(:has(JSXAttribute[name.name="labelledBy"]))',
  ':not(:has(JSXAttribute[name.name="aria-labelledby"]))',
].join('');

export default [
  {
    files: ['src/**/*.tsx'],
    plugins: { 'jsx-a11y': jsxA11y },
    rules: {
      // Promoted to error because the last sweep healed these rule ids.
      'jsx-a11y/label-has-associated-control': ['error', { assert: 'either' }],
      'jsx-a11y/aria-activedescendant-has-tabindex': 'error',
      'jsx-a11y/role-supports-aria-props': 'error',
      'jsx-a11y/aria-unsupported-elements': 'error',
      'no-restricted-syntax': [
        'error',
        {
          selector: COMBOBOX_NEEDS_LABEL,
          // Naming the lock id makes the error traceable to the fix that
          // motivated it, which is what stops a future reader deleting it.
          message: 'Combobox needs labelledBy or aria-labelledby (lock 7f21ac0b).',
        },
      ],
    },
  },
];

A lint rule has a hard ceiling, and it is worth stating plainly so nobody over-trusts it. An AST walker sees literal attributes in one file; it does not see {...props}, a prop threaded through three wrappers, a value computed from a translation catalogue, or anything rendered by a third-party component. Every one of those defeats the rule silently. When a class keeps escaping, the answer is to move it into the type system so the call site cannot compile without the labelling prop — which is where design-system accessibility defaults takes over, and why a lint rule should be read as a cheap first line rather than as the class’s real guarantee.

What the lint rule catches and what escapes it A violet box records twelve call sites healed by the sweep and a neutral box records six new call sites written in the following quarter by four teams. Both meet an amber lint barrier that blocks five of the six at commit time. One call site slips past because a prop spread hides the labelling prop, and it is stopped instead by a teal component default that makes the missing prop a compile error. The class outlives the twelve instances 12 call sites healed by the sweep a one-time cost 6 new call sites written next quarter by four teams lint rule commit time 5 of 6 blocked 1 call site slips past the rule a prop spread hides the labelling prop component default Combobox requires a labelling prop the sixth call site cannot compile Lint is the cheap barrier; the type-level default is the one nothing routes around.
The escape route is always the same — indirection the AST cannot follow — which is why a class that keeps recurring is a signal to move the constraint into the component's types.

5. The Pull-Request Checklist That CI Enforces

A checklist nobody can skip is a different artifact from a checklist in a wiki. Make the fix pull request declare what it closed in a machine-readable form, then have a job verify the declaration. One commit trailer per closed finding is enough: A11y-Fixes: aria-input-field-name@7f21ac0b. It is greppable, it survives squash merges, it appears in git log forever, and it gives the guard a set to compare against the set of tests the pull request adds.

The template’s job is to make the trailer and the locks feel like part of the fix rather than paperwork bolted on afterwards. Keep it short — four boxes and one required block — because a template with fifteen items gets its boxes ticked without being read.

<!-- .github/PULL_REQUEST_TEMPLATE/a11y_fix.md -->
## What was broken

Rule id, route, and the user-visible consequence in one sentence.

## Locks added

- [ ] Targeted assertion, named with the rule id and fingerprint
- [ ] Subtree snapshot committed, or noted below as not applicable
- [ ] Lint rule promoted, or noted below as not applicable
- [ ] Assertion verified red against the parent commit

## Snapshot changes

If a committed `.aria.yml` file changed, state which node changed and why.
Delete this section when no snapshot file is touched.

## Trailer

One line per closed finding, copied from `a11y/locks/plan.json`:

    A11y-Fixes: aria-input-field-name@7f21ac0b

The workflow then turns three of those boxes into checks. ESLint enforces the class, the tagged Playwright project runs the assertions and the snapshots, and two small scripts read the diff against the merge base: one requires a new or changed spec for every rule id claimed in a trailer, the other requires a stated reason whenever a committed snapshot file changed. Both need real history, which is why the checkout depth matters.

# .github/workflows/a11y-locks.yml
name: a11y-locks
on:
  pull_request:
    branches: [main]
jobs:
  locks:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0            # both guards diff against the merge base
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - name: class lock
        run: npx eslint src --max-warnings 0   # promoted rules are errors
      - run: npx playwright install --with-deps chromium
      - name: instance locks
        # Only the tagged project, so a lock failure is never mixed into the
        # broader scan's output and can be a separate required check.
        run: npx playwright test --grep @a11y-lock --reporter=list
      - name: every claimed fix carries a test
        run: node a11y/locks/fix-has-test.mjs --base "origin/$GITHUB_BASE_REF"
      - name: every snapshot change carries a reason
        run: node a11y/locks/snapshot-justified.mjs --base "origin/$GITHUB_BASE_REF"
        env:
          PR_BODY: ${{ github.event.pull_request.body }}
      - if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: a11y-lock-failures
          path: |
            test-results/
            a11y/locks/plan.json
The five checks on a fix pull request A header bar names the pull request and reports one blocking check. Five rows list the required check, what it proves, and its status: the targeted assertion, the aria snapshot, the lint job and the test-per-rule-id guard all pass, while the snapshot justification guard fails because no reason was given in the body. PR 4182 — fix(a11y): name the saved-search combobox 1 blocking check required check what it proves status locks / targeted assertion fingerprint 7f21ac0b is still fixed pass locks / aria snapshot the 14-node panel subtree is unchanged pass lint / jsx-a11y + syntax the class cannot be written again pass guard / test per rule id the trailer matches a new spec file pass guard / snapshot justified a second .aria.yml changed with no reason fail The fifth check is what keeps the other four honest: it is the one an update flag cannot satisfy.
Four of these five checks answer questions about code; the fifth answers a question about the author's intent, which is why it has to read the pull-request body.

Pipeline Integration

The lock job is deliberately separate from the scan job, and both are required status checks. The scan answers “does this branch have accessibility violations”, ratchets against a budget, and belongs with the thresholds described in progressive threshold management. The lock job answers “did anything we already fixed come back”, which is a different question with a different owner and a different remedy — nobody negotiates a budget for a regression. Registering them as two named checks in branch protection, as set out in requiring accessibility status checks in branch protection, means a red lock check cannot be waved through by pointing at a green scan.

Exit codes carry meaning worth keeping distinct. ESLint’s non-zero exit is a class violation caught before the browser starts. A Playwright failure inside the tagged project is either an instance regression or a subtree drift, and the test name says which. fix-has-test.mjs exits 1 when a claimed rule id has no accompanying spec, and posts a review comment rather than just failing, because the fix is a two-line addition the author can make immediately. snapshot-justified.mjs exits 1 when a committed .aria.yml changed and the body contains no explanation for it.

Runtime stays small because scope stays small. On a suite with 37 locked findings across 14 components, the tagged Playwright project runs in about 55 seconds on two workers — each spec loads one route, performs one interaction, and scans one subtree with one rule. ESLint over src adds around 11 seconds, and the two guard scripts are file-diff arithmetic measured in milliseconds. That total is what earns the job a place on every pull request rather than a nightly slot where a regression sits undiscovered for a day.

Upload the failures rather than only reporting them. test-results/ carries the received aria snapshot and the axe node data for any failing lock, and a11y/locks/plan.json tells a reviewer which fingerprint the failing spec belongs to. When the lock output is being routed into a dashboard or a Slack channel, normalise it into the same shape the rest of the pipeline emits, as described in structuring JSON violation output for Slack and GitHub annotations, so a regression and a new finding land in the same tooling with different labels.

Troubleshooting and Flaky-Test Mitigation

The lock passes but the fix is gone. The locator resolved to nothing. AxeBuilder.include() with a selector that matches zero nodes returns zero violations, and a getByRole that matches nothing is only an error when something is asserted against it. The toHaveCount(1) guard is the fix, and it belongs in every lock spec before any scan call. Run a deliberate check occasionally: delete the repaired attribute locally and confirm the lock goes red.

The interaction races the assertion. A disclosure with a CSS transition can be open in the DOM before its contents are registered with the accessibility tree, so an immediate scan sees a control with no name and the lock fails intermittently at three per cent. Never patch this with a fixed wait. Wait on a fact the application asserts — the panel’s own data-ready attribute, or expect(dialog).toBeVisible() followed by expect(combobox).toHaveCount(1) — and the race disappears because the assertion is now the wait.

Fingerprints churn on unrelated commits. A fingerprint derived from a file and a line number changes when someone adds an import at the top of the file, and the guard then reports a claimed fix with no matching test because the trailer no longer matches the plan. Derive the fingerprint from the rule id plus the durable locator plus the component name, never from a line number, and the identifier survives formatting.

Snapshots re-indent after a browser bump. Chromium’s accessibility tree collapses generic containers by a heuristic that changes between revisions, so an upgrade can add a generic node and shift a whole branch. Pin the browser version in the lockfile so this is always a deliberate upgrade, handle the regeneration in its own commit that touches nothing else, and read the diff before committing it.

Parallel shards fight over state. Locks that log in, open a panel and mutate a saved search will collide when two workers share a fixture account. Give each worker its own seeded account through a worker-scoped fixture, or mark the affected specs serial within their file. A lock that fails once every twenty runs will be marked skipped within a month, and a skipped lock protects nothing.

A translation build makes every name assertion red. Accessible names asserted as literals only hold in the locale the assertion was written for. Pin locale and timeZoneId in the Playwright project, and if a component genuinely ships different names per locale, assert against the message catalogue key’s resolved value rather than a hard-coded English string.

Common Pitfalls

  • Treating a violation baseline as a lock. A baseline records what is tolerated, so it permits every entry on every run; it is a scheduling artifact, not an assertion.
  • Locking the fix with a whole-page scan of the route. The route scan is what missed the original regression, and adding it back as the lock reproduces the coverage gap it was supposed to close.
  • Copying the report’s target CSS path into the spec. Those paths are generated from position and class names, and the first grid re-order or CSS-modules upgrade silently breaks the locator.
  • Running the full rule set inside a lock spec. It will fail on an unrelated finding, and a spec that fails for reasons its name does not mention is a spec that gets skipped.
  • Promoting every jsx-a11y rule to error at once. The unrelated failures bury the ones the sweep motivated, and the whole config gets reverted rather than tuned.
  • Letting --update-snapshots run anywhere in CI. A pipeline that regenerates its own expectations converts every regression into a new baseline.
  • Filing the test as a follow-up ticket. The follow-up competes with feature work and loses; the guard exists precisely so it cannot be deferred.

FAQ

Is one lock per fix ever enough? Yes, when the fix changed exactly one attribute on one node that is present at first paint and belongs to a rule id with no lint equivalent — duplicate-id-aria is the usual example. The routing table exists so that judgment is made once per rule id rather than argued per pull request. What is never enough is zero, which is why plan-locks.mjs exits non-zero on an unclassified rule id rather than defaulting to a lock list.

Why not just add the repaired route to the nightly full scan? Because the regression this page opens with happened on a route the scan already covered. The finding was invisible not because the URL was missing but because the control only exists in a state the scan never enters. A lock that replays the interaction is the only thing that closes that gap, and running it nightly rather than per pull request means the regression is found after it has merged instead of before.

How do locks age when a component is legitimately redesigned? They fail, loudly, in the pull request that does the redesign — which is the correct outcome. The redesign either preserves the accessible contract, in which case the locator and the name assertion still pass and only the snapshot needs a reviewed update, or it changes the contract, in which case the lock has just forced the author to state that in the pull-request body. Locks for components that are deleted get deleted with them; the fingerprint in the test name makes it easy to confirm the original finding is genuinely no longer reachable.

Does the lint promotion belong in the same pull request as the fix? Yes, and it is usually the cheapest of the three locks to add because the sweep already told you which rule ids to promote. The one exception is a promotion that lights up dozens of pre-existing warnings elsewhere in the codebase; in that case land the promotion as warn with the fix, open the errors-only change immediately after, and put the count of remaining warnings in the pull-request body so it does not quietly become permanent.

What stops someone deleting a lock instead of fixing the regression? Nothing technical, and that is deliberate — a lock for a genuinely obsolete requirement should be removable. What makes deletion visible is that the spec carries the rule id and the fingerprint in its name, so a diff removing it is legible as “this stops protecting finding 7f21ac0b” rather than as a test cleanup. Adding the lock directory to CODEOWNERS under the accessibility group turns every such deletion into a review by someone who will ask why.

In This Section