Per-Directory Accessibility Budgets in Legacy Code

One repository rarely deserves one standard. A design-system package written this quarter can be held at zero violations without inconveniencing anybody, while the admin area inherited from the 2019 rewrite carries 402 findings that will take three quarters to clear — and a single shared ceiling forces those two facts into the same number, where the legacy allowance silently pays for the new package’s first regression. This guide is part of Progressive Threshold Management, and it covers keying the budget file by path glob, attributing a DOM finding back to the source directory that owns it, resolving overlapping globs deterministically, and reporting per area so each team reads only its own number.

Root Cause

Accessibility scanners report the DOM, and budgets are owned by directories. That mismatch is the entire difficulty. A label violation arrives as a CSS selector, a fragment of HTML and a rule id; nothing in that payload says whether the offending input came from packages/design-system/src/Field.tsx or from a hand-rolled form in apps/admin-legacy/src/pages/Users.tsx. Without attribution, the only budget the pipeline can enforce is a global one.

A global ceiling then fails in a specific and predictable way. Suppose the repository holds 541 findings against a ceiling of 541. The platform team fixes eleven contrast findings in the design system, and a week later somebody adds an unlabelled filter input to the admin area. Total unchanged, gate green, and the design system’s clean record has been spent on a regression in a different package by a different team. Splitting the ceiling by path makes each of those two events visible on its own: the design-system area’s count drops to a new floor, and the admin area’s count rises above its own ceiling.

There is a second, quieter benefit. Per-area budgets are the only version of this mechanism that produces a number a team can be held to. “The repository has 541 findings” is nobody’s objective; “apps/admin-legacy is at 402 against a ceiling of 420 and owns the eight new findings in this pull request” is a task with an owner, especially when the owner is derived from the same globs that define the areas.

Five areas of one repository with separate ceilings A directory tree lists five budget areas. The design-system package has a ceiling of zero and a count of zero, its legacy-table subdirectory has a ceiling of fourteen and a count of fourteen, the storefront app has forty-five against forty-one, the legacy admin app has four hundred and twenty against four hundred and two, and the catch-all unattributed area has sixty against fifty-eight. One budget file, five ceilings count ceiling owner packages/design-system/** 0 0 platform …/src/legacy-table/** 14 14 platform apps/storefront/** 41 45 retail apps/admin-legacy/** 402 420 internal ** (unattributed) 58 60 web The amber row is the exception: a known-bad subtree carved out of a zero-tolerance package.
The design-system package can be held at zero precisely because its one legacy subdirectory has its own ceiling instead of raising the package's.

Configuration

The budget file lists areas rather than rules. Each area is a glob, a ceiling, an owner, and optionally a per-rule refinement for the handful of rules that need a different bar inside that area.

{
  "attribution": { "unattributedArea": "**", "maxUnattributedShare": 0.15 },
  "areas": {
    "packages/design-system/**": {
      "max": 0,
      "owner": "@acme/team-platform"
    },
    "packages/design-system/src/legacy-table/**": {
      "max": 14,
      "owner": "@acme/team-platform",
      "note": "Pre-rewrite grid, deleted in Q4; see PLT-2210"
    },
    "apps/storefront/**": {
      "max": 45,
      "owner": "@acme/team-retail",
      "rules": { "color-contrast": 0 }
    },
    "apps/admin-legacy/**": {
      "max": 420,
      "owner": "@acme/team-internal"
    },
    "**": {
      "max": 60,
      "owner": "@acme/team-web",
      "note": "Findings no marker could attribute; must shrink every sprint"
    }
  }
}

Attribution is the part that has to be built. The scanner sees DOM, so the DOM has to carry the source path, and the cheapest reliable way to put it there is a build-time transform that runs only when an environment variable is set — never in the production bundle, where it would ship dead attributes to users.

// a11y/babel-plugin-a11y-source.cjs
// Adds data-a11y-src="<repo-relative file>" to the outermost JSX element per module.
// Enabled only when A11Y_ATTRIBUTION=1, i.e. in the CI scan build.
module.exports = function a11ySourcePlugin({ types: t }) {
  return {
    name: 'a11y-source',
    visitor: {
      JSXOpeningElement(path, state) {
        if (!process.env.A11Y_ATTRIBUTION) return;
        // Only the outermost element of the file: one marker per component is enough,
        // and the resolver walks up the DOM to find it.
        if (path.findParent((p) => p.isJSXElement())) return;
        const name = path.node.name;
        // A host element (div, button) accepts unknown attributes; a component may not.
        if (!t.isJSXIdentifier(name) || /^[A-Z]/.test(name.name)) return;
        const file = state.file.opts.filename.replace(`${process.cwd()}/`, '');
        path.node.attributes.push(
          t.jsxAttribute(t.jsxIdentifier('data-a11y-src'), t.stringLiteral(file)),
        );
      },
    },
  };
};

Server-rendered pages, template-driven areas and anything the transform skipped need a second source of truth: a route-to-package map emitted by the build. Generate it from the router manifest rather than writing it by hand, so a new route cannot appear without an owner.

{
  "/": "apps/storefront/src/routes/home.tsx",
  "/product/:sku": "apps/storefront/src/routes/product.tsx",
  "/admin/users": "apps/admin-legacy/src/pages/Users.tsx",
  "/admin/reports/:id": "apps/admin-legacy/src/pages/Report.tsx",
  "/help/:slug": "apps/storefront/src/routes/help.tsx"
}

Resolution then runs in the page, immediately after the scan, and follows one rule: the nearest ancestor carrying data-a11y-src owns the finding, unless that ancestor has explicitly delegated upward.

// a11y/attribute.mjs — evaluated in the page after AxeBuilder().analyze()
export function attributeInPage(violations, routePath, routeMap) {
  return violations.flatMap((violation) =>
    violation.nodes.map((node) => {
      const el = document.querySelector(node.target.join(' '));
      let source = null;
      for (let cur = el; cur; cur = cur.parentElement) {
        // A design-system component whose failure comes from a consumer-supplied
        // prop marks itself inherit, so ownership passes to the calling code.
        if (cur.hasAttribute?.('data-a11y-src-inherit')) continue;
        const src = cur.getAttribute?.('data-a11y-src');
        if (src) { source = src; break; }
      }
      return {
        rule: violation.id,
        impact: node.impact ?? violation.impact ?? 'minor',
        route: routePath,
        // Document-scoped rules belong to whoever owns the route, not to a node.
        source: source ?? routeMap[routePath] ?? null,
        target: node.target.join(' '),
      };
    }),
  );
}
How a DOM finding acquires a source path and an owner Attribution is tried in three ordered ways: the nearest ancestor carrying a source marker accounts for most findings, the route-to-package map covers document-scoped rules, and anything left falls into the catch-all area. All three paths feed a specificity resolver that returns the owning area and team. Tried in order, first hit wins 1 nearest data-a11y-src ancestor 88% of findings on this repository 2 route → package map 9% — html-has-lang, bypass, region 3 catch-all ** area 3% — must stay under the 15% cap specificity resolver area + owner counted, then gated A data-a11y-src-inherit marker makes step 1 keep walking, so consumer bugs land on consumers. Step 3 growing means a component stopped forwarding unknown props onto its root element.
Attribution coverage is a number worth watching on its own: as step 3 grows, the per-area budgets converge back into the global budget they were meant to replace.

Glob Precedence

Two globs in that file match packages/design-system/src/legacy-table/Row.tsx, and the answer has to be the same on every run and on every machine. Object key order is not a specification, so compute a specificity score instead: the number of literal path segments dominates, and the total literal character length breaks ties. When two globs still tie, fail the config load rather than pick one — an ambiguous budget is worse than a missing one because it will be enforced inconsistently after the next edit.

// a11y/areas.mjs
export function toRegExp(glob) {
  if (glob === '**') return /^.*$/;
  let rx = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&'); // escape, but leave * alone
  rx = rx.replace(/\/\*\*$/, '(?:/.*)?');  // trailing /** matches the dir and below
  rx = rx.replace(/\*\*\//g, '(?:.*/)?');  // mid-path globstar
  rx = rx.replace(/\*/g, '[^/]*');         // single-segment wildcard
  return new RegExp(`^${rx}$`);
}

export function specificity(glob) {
  const literalSegments = glob.split('/').filter((s) => !s.includes('*')).length;
  const literalChars = glob.replace(/\*/g, '').length;
  return literalSegments * 10_000 + literalChars; // segments dominate, chars break ties
}

export function buildResolver(areas) {
  const compiled = Object.entries(areas)
    .map(([glob, area]) => ({ glob, area, rx: toRegExp(glob), score: specificity(glob) }))
    .sort((a, b) => b.score - a.score);

  return function resolve(sourcePath) {
    if (!sourcePath) return { glob: '**', area: areas['**'] };
    const matches = compiled.filter((c) => c.rx.test(sourcePath));
    if (matches.length === 0) return { glob: '**', area: areas['**'] };
    if (matches.length > 1 && matches[0].score === matches[1].score) {
      throw new Error(
        `Ambiguous budget areas for ${sourcePath}: ` +
        `"${matches[0].glob}" and "${matches[1].glob}" score equally. ` +
        'Make one of them more specific.',
      );
    }
    return { glob: matches[0].glob, area: matches[0].area };
  };
}
Resolving two overlapping globs by specificity The path packages/design-system/src/legacy-table/Row.tsx is tested against four globs. The catch-all double star and the storefront glob do not decide it; the design-system glob matches with a score of twenty thousand and twenty-three, and the legacy-table glob matches with a higher score of forty thousand and thirty-nine, so the legacy-table ceiling of fourteen applies. packages/design-system/src/legacy-table/Row.tsx source path of the failing node candidate globs, sorted by specificity score packages/design-system/src/legacy-table/** 40039 · match · wins packages/design-system/** 20023 · match · shadowed apps/storefront/** 20015 · no match ** 0 · catch-all only Score = literal segments × 10,000 + literal characters; an exact tie throws at config load.
Specificity is computed, never inherited from key order, so moving a line in the JSON file cannot change which ceiling a file is judged against.

Validation

Attribution is the part that goes wrong, so validate it directly with an --explain mode that prints where each finding landed and why. Run it before the gate is enabled and again after any change to the transform.

# 1. Build with attribution markers on, then scan and explain.
A11Y_ATTRIBUTION=1 npm run build
node a11y/area-gate.mjs --explain | head -20

# Expected shape of each line:
# label            apps/admin-legacy/src/pages/Users.tsx  -> apps/admin-legacy/**
# color-contrast   packages/design-system/src/Badge.tsx   -> packages/design-system/**
# html-has-lang    (route /admin/users)                   -> apps/admin-legacy/**
# link-name        (no marker, no route match)             -> ** unattributed

# 2. Assert the unattributed share is under the configured limit.
node a11y/area-gate.mjs --check-attribution; echo "exit=$?"   # expect exit=0

# 3. Enforce every area ceiling.
node a11y/area-gate.mjs --enforce; echo "exit=$?"

The second command is the one to watch over time. maxUnattributedShare: 0.15 fails the run when more than 15% of findings could not be traced to a path, because an unattributed bucket that grows is a budget that is quietly becoming global again. Two causes account for nearly all of it: a component that does not spread unknown props onto its root element, so the marker never reaches the DOM, and a route missing from the generated map. Both are fixable, and both are invisible without this check.

// a11y/area-gate.mjs (enforcement half) — usage: node a11y/area-gate.mjs --enforce
import { readFileSync } from 'node:fs';
import { buildResolver } from './areas.mjs';

const budget = JSON.parse(readFileSync('a11y/budgets.json', 'utf8'));
const findings = JSON.parse(readFileSync('a11y/runs/attributed.json', 'utf8'));
const resolve = buildResolver(budget.areas);

const byArea = new Map();
for (const finding of findings) {
  const { glob } = resolve(finding.source);
  const bucket = byArea.get(glob) ?? { count: 0, byRule: new Map() };
  bucket.count += 1;
  bucket.byRule.set(finding.rule, (bucket.byRule.get(finding.rule) ?? 0) + 1);
  byArea.set(glob, bucket);
}

let failed = false;
console.log('| area | owner | count | ceiling | verdict |');
console.log('|---|---|---|---|---|');
for (const [glob, area] of Object.entries(budget.areas)) {
  const bucket = byArea.get(glob) ?? { count: 0, byRule: new Map() };
  const over = bucket.count > area.max;
  // A per-rule refinement inside an area is stricter than the area ceiling.
  const ruleBreaches = Object.entries(area.rules ?? {})
    .filter(([rule, max]) => (bucket.byRule.get(rule) ?? 0) > max)
    .map(([rule, max]) => `${rule} ${bucket.byRule.get(rule)}/${max}`);
  if (over || ruleBreaches.length > 0) failed = true;
  const verdict = over ? `OVER by ${bucket.count - area.max}`
    : ruleBreaches.length > 0 ? ruleBreaches.join(', ') : 'ok';
  console.log(`| \`${glob}\` | ${area.owner} | ${bucket.count} | ${area.max} | ${verdict} |`);
}
process.exit(failed ? 1 : 0);

Edge Cases and Conditional Guards

  • A shared component broken by its consumer. A design-system Field renders an input whose label comes from a prop; when the admin app passes no label, the nearest marker is the design system’s and the platform team gets the failure. Mark the element that renders consumer-supplied content with data-a11y-src-inherit so the resolver keeps walking upward and the finding lands on the calling code. Apply it narrowly — a component that delegates everything upward is untestable in its own area.
  • Portals and modals. Content rendered into document.body has no DOM ancestry back to the component that opened it, so every finding inside a dialog attributes to whatever wraps the portal root, usually nothing. Put an explicit data-a11y-src on the portal content root at render time; it is one extra attribute and it is the only thing that keeps a whole modal out of the unattributed bucket.
  • Document-scoped rules. html-has-lang, landmark-one-main, page-has-heading-one and bypass fail on <html> or <body>, which no component owns. Route them through the route-to-package map so the owner of the page carries them, and never let them fall into the catch-all — they are the four rules most likely to be nobody’s problem for a year.

Pipeline Impact

Run one scan and one gate, then split the reporting. A matrix of one job per area would rescan the same pages several times over; a single job that emits a per-area table is faster and produces one artifact that every team can read. Publish the table into the job summary, and post a comment that mentions only the owners of areas that moved.

name: a11y-area-budgets
on:
  pull_request:
    branches: [main]
permissions:
  contents: read
  pull-requests: write
jobs:
  area-budgets:
    runs-on: ubuntu-24.04
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - name: Build with attribution markers
        run: npm run build
        env:
          A11Y_ATTRIBUTION: '1'   # never set for a production build
      - name: Serve and scan
        run: npx serve -s dist -l 4173 & node a11y/scan-attributed.mjs
      - name: Fail if attribution coverage dropped
        run: node a11y/area-gate.mjs --check-attribution
      - name: Enforce per-area ceilings
        run: node a11y/area-gate.mjs --enforce | tee area-report.md
      - name: Per-area table in the job summary
        if: always()
        run: cat area-report.md >> "$GITHUB_STEP_SUMMARY"
      - name: Mention the owners of breached areas
        if: failure()
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          # Only owners of OVER rows get pinged; nobody reads a comment that tags everyone.
          grep 'OVER' area-report.md > breaches.md || true
          [ -s breaches.md ] && gh pr comment "${{ github.event.number }}" \
            --body-file breaches.md

Two follow-on choices matter more than the YAML. First, decide whether every area is a blocking check or only some — a repository that holds the design system at zero and the legacy admin at 420 usually wants the design-system breach to block and the legacy breach to warn for the first month, which is the exact split described in auto-fail versus warning workflows. Second, keep the area globs and the CODEOWNERS patterns in sync; if apps/admin-legacy/** is owned by one team in the budget file and another in CODEOWNERS, the comment tags the wrong people and the number stops being anybody’s. Making the whole job a required status check is covered in pull request gating and branch policies, and the per-area counts are the natural series to chart in tracking accessibility violation trends across sprints — one line per area shows which team is actually burning down.

Common Pitfalls

  • Shipping the attribution transform in the production build, which puts a source path for every component into the bundle users download.
  • Relying on object key order in the budget file instead of a computed specificity score, so reformatting the JSON changes which ceiling a directory is judged against.
  • Leaving no catch-all area, which makes an unattributed finding crash the gate or, worse, be silently dropped from every count.
  • Letting the unattributed bucket grow without a share limit, at which point the per-area budgets are decoration and the effective policy is global again.
  • Attributing a design-system component’s consumer-supplied failure to the design system, which teaches the platform team that the gate blames them for other teams’ code.
  • Splitting the scan into one job per area, tripling runner time to produce numbers a single run already had.
  • Setting a legacy area’s ceiling to its current count and never ratcheting it, which converts a temporary allowance into a permanent exemption.

FAQ

Is a data-testid convention enough, instead of a build-time transform? It works when test ids are mandatory, unique per component and mapped to file paths in a generated registry — but those three conditions rarely hold in a legacy repository, which is exactly where this is needed. A test id also describes the component, not the file, so a component moved between packages keeps attributing to its old area until somebody updates the registry. The transform derives the path from the file being compiled, so it cannot drift.

How should the ceiling for a legacy area be chosen initially? Measure it, add nothing, and write the ticket that will delete the area. Setting a legacy ceiling above the current count to “leave room” guarantees the room gets used; setting it at the current count means the next regression in that area fails, which is the correct outcome even in code nobody likes. Then put the area under the same automated tightening as everything else, described in ratcheting violation budgets down each sprint, so the allowance shrinks without anyone remembering to shrink it.

What happens when a file moves between areas? The finding moves with it: the next run resolves the new path, the source area’s count falls and the destination area’s count rises, and if the destination has a tighter ceiling the pull request that moved the file fails. That is usually the right answer — moving a broken component into a zero-tolerance package is a decision worth blocking — but it does mean large refactors should move the ceiling and the files in the same pull request, with the diff in the budget file visible to both owning teams.