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.
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(' '),
};
}),
);
}
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 };
};
}
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
Fieldrenders 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 withdata-a11y-src-inheritso 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.bodyhas 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 explicitdata-a11y-srcon 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-oneandbypassfail 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.
Related
- Progressive Threshold Management — the parent guide on baselines, fingerprints and per-rule budgets.
- Ratcheting Violation Budgets Down Each Sprint — the scheduled job that lowers each area’s ceiling as its count falls.
- CI/CD Integration & Automated Quality Gating — how this job fits with sharding, reporting and branch policies.