Assigning Violation Ownership with CODEOWNERS

A scan report identifies elements; a work queue needs teams. This guide is part of Accessibility Debt Triage & Prioritization, and it closes the gap between the two: a build-time marker that carries a source path into the rendered DOM, a route-to-package manifest for everything the marker cannot reach, and a resolver that turns each path into exactly one owning team by evaluating CODEOWNERS the way the forge does. The resolver exits non-zero when a finding cannot be attributed, because the alternative — a shared “unowned” label — is where accessibility debt goes to be forgotten.

Root Cause

Nothing in an axe result names a file. A violation node carries target, an array of CSS selectors, and html, a truncated snippet of the rendered element. Both describe the output of a build, and the build’s whole job was to erase the relationship between output and source: JSX became createElement calls, class names became hashes, and eleven components collapsed into one bundle. Handed div.sc-1x2y3z > button on /checkout/payment, a human can usually guess the team in thirty seconds and an automated router cannot guess at all.

Teams paper over this with the route. Routes look like ownership — /checkout/* surely belongs to the checkout team — and for page-local markup that is true. It breaks precisely where the volume is. In a component-based application the majority of findings are produced by shared components rendered on other teams’ pages: a Button with an icon and no accessible name fails on 34 routes owned by six teams, and route-based routing files six tickets against six teams for one defect in a seventh team’s package. Every one of those tickets gets closed as “not ours”, which is both correct and useless.

So ownership has to be resolved from a source path, and the path has to come from somewhere the DOM can carry it. There are exactly two reliable sources. A build-time marker stamped onto elements in non-production builds gives an exact file per element — the strongest signal available, and free to compute because the compiler already knows the filename. A route-to-package manifest gives a coarse but complete fallback for server-rendered templates, legacy pages and anything outside the component pipeline. Resolving the resulting path against CODEOWNERS reuses a file the organisation already maintains for code review, which matters more than any technical property: an ownership map maintained for one purpose stays current, and one maintained only for the accessibility programme does not.

From a DOM node to an owning team A finding enters at the left and is tested against a build-time data attribute first, then a route-to-package manifest. Either produces a source path which is matched against CODEOWNERS to yield a team bucket. A finding that matches neither source leaves through a failure branch that exits with code one. Two sources of truth, one path, one team, no shared bucket axe finding target + html data-a11y-src marker exact file, per element route to package map coarse, always present neither matched a routing bug, not a ticket 1st 2nd source path repo-relative exit 1 job fails, human fixes team bucket one per team via CODEOWNERS The marker is tried first because it survives shared components; the manifest only knows pages.
The order matters: the route manifest would attribute every shared-component defect to whichever page happened to render it, so it runs only after the element-level marker has failed.

Configuration

The marker is a Babel plugin that stamps the source file and enclosing component name onto every intrinsic DOM element. It is registered only in the preview build that CI scans — production bundles stay byte-identical, which matters both for payload size and because a source path in shipped HTML is information disclosure.

// tools/babel-plugin-a11y-source.cjs
// Adds data-a11y-src="<repo-relative file>#<Component>" to DOM elements.
// Registered in the preview build only; production output is untouched.
const { relative } = require('node:path');

module.exports = function a11ySource({ types: t }) {
  return {
    name: 'a11y-source',
    visitor: {
      JSXOpeningElement(path, state) {
        const name = path.node.name;
        // Intrinsic elements only (<button>, <div>). Components are covered
        // through whichever DOM elements they eventually render.
        if (!t.isJSXIdentifier(name) || !/^[a-z]/.test(name.name)) return;
        const already = path.node.attributes.some(
          (a) => t.isJSXAttribute(a) && a.name.name === 'data-a11y-src',
        );
        if (already) return; // a wrapper may have stamped it during an earlier pass
        const file = relative(state.cwd, state.filename);
        path.node.attributes.push(
          t.jsxAttribute(
            t.jsxIdentifier('data-a11y-src'),
            t.stringLiteral(`${file}#${enclosingComponentName(path)}`),
          ),
        );
      },
    },
  };
};

// The nearest named function, class or const declaration is the component.
function enclosingComponentName(path) {
  const holder = path.findParent(
    (p) =>
      p.isFunctionDeclaration() || p.isClassDeclaration() || p.isVariableDeclarator(),
  );
  return holder?.node?.id?.name ?? 'anonymous';
}

Wire it into the preview configuration behind an explicit flag rather than an NODE_ENV check, so a developer can reproduce a routing result locally without pretending to be CI:

// vite.config.mjs
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

const stampSource = process.env.A11Y_SOURCE_MARKERS === '1';

export default defineConfig({
  plugins: [
    react({
      babel: {
        // Empty array in normal builds: no marker, no bundle-size change.
        plugins: stampSource ? ['./tools/babel-plugin-a11y-source.cjs'] : [],
      },
    }),
  ],
  build: { sourcemap: stampSource },
});

The fallback manifest maps route templates to the package that renders them. Keep it next to the route manifest the scanner already uses, and keep it short — it only needs entries for routes whose markup does not go through the component pipeline:

{
  "/legal/terms": "apps/content/src/pages/legal",
  "/legal/privacy": "apps/content/src/pages/legal",
  "/admin/report-builder": "apps/admin/src/report-builder",
  "/status": "apps/platform/src/status",
  "/blog/:slug": "apps/content/src/blog"
}

Now the resolver. CODEOWNERS semantics are specific and easy to get wrong: patterns are gitignore-style, a leading slash anchors to the repository root, and the last matching line wins rather than the most specific one. Evaluating the file top-down and taking the first hit produces confidently wrong owners for every nested rule.

// a11y/owners/resolve.mjs
// Usage: node a11y/owners/resolve.mjs triage/findings.ndjson CODEOWNERS
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';

// Reversed on purpose: in CODEOWNERS the LAST matching pattern wins, so the
// first hit while scanning bottom-up is the effective owner.
function parseCodeowners(text) {
  return text
    .split('\n')
    .map((line) => line.replace(/#.*$/, '').trim())
    .filter(Boolean)
    .map((line) => {
      const [pattern, ...owners] = line.split(/\s+/);
      return { pattern, owners, re: globToRegExp(pattern) };
    })
    .reverse();
}

// Supported subset: leading '/', trailing '/**', and '*' inside one segment.
function globToRegExp(pattern) {
  const body = pattern
    .replace(/^\//, '')
    .split('/')
    .map((seg) =>
      seg === '**'
        ? '.*'
        : seg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*'),
    )
    .join('/');
  // An unanchored pattern matches at any depth, exactly like gitignore.
  const prefix = pattern.startsWith('/') ? '' : '(?:.*/)?';
  return new RegExp(`^${prefix}${body}$`);
}

const rules = parseCodeowners(readFileSync(process.argv[3], 'utf8'));
const routePackages = JSON.parse(
  readFileSync('a11y/owners/route-packages.json', 'utf8'),
);

// Rules whose cause is the caller's content, not the shared component's code.
const CALLER_OWNED = new Set([
  'image-alt', 'link-name', 'input-image-alt', 'document-title', 'html-has-lang',
]);
const SHARED_PREFIX = 'packages/design-system/';

function sourcePathFor(finding) {
  const stamped = finding.html.match(/data-a11y-src="([^"#]+)/);
  if (stamped) {
    const path = stamped[1];
    // A missing alt on a shared <Image> is the page author's copy, not the
    // component's markup: re-attribute those rules to the consuming package.
    if (path.startsWith(SHARED_PREFIX) && CALLER_OWNED.has(finding.rule)) {
      const consumer = routePackages[finding.route];
      if (consumer) return { path: consumer, via: 'caller-override' };
    }
    return { path, via: 'build-marker' };
  }
  const pkg = routePackages[finding.route];
  return pkg ? { path: pkg, via: 'route-manifest' } : null;
}

const buckets = new Map();
const unresolved = [];
for (const line of readFileSync(process.argv[2], 'utf8').trim().split('\n')) {
  const finding = JSON.parse(line);
  const source = sourcePathFor(finding);
  const rule = source && rules.find((r) => r.re.test(source.path));
  if (!source || !rule) {
    unresolved.push({ rule: finding.rule, route: finding.route, path: source?.path });
    continue;
  }
  const team = rule.owners[0]; // first owner is accountable; the rest review
  const list = buckets.get(team) ?? [];
  list.push({ ...finding, owner: team, sourcePath: source.path, via: source.via });
  buckets.set(team, list);
}

mkdirSync('triage/owners', { recursive: true });
for (const [team, findings] of buckets) {
  const file = `triage/owners/${team.replace(/[@/]/g, '_')}.ndjson`;
  writeFileSync(file, findings.map((f) => JSON.stringify(f)).join('\n') + '\n');
  console.log(`${team}\t${findings.length}\t${file}`);
}

if (unresolved.length) {
  console.error(`\n${unresolved.length} findings could not be attributed:`);
  for (const u of unresolved.slice(0, 20)) {
    console.error(`  ${u.rule} on ${u.route} (path: ${u.path ?? 'none'})`);
  }
  console.error('\nAdd a CODEOWNERS entry or a route-packages mapping. Not a ticket.');
  process.exit(1); // an unowned pile is not an acceptable output
}
Last matching CODEOWNERS line wins Five CODEOWNERS patterns are listed in file order against the path apps/storefront/src/checkout/CartSummary.tsx. Three of them match: the catch-all web-platform line, the storefront line and the checkout line. The checkout line is last in the file, so it is the effective owner, and the two earlier matches are overridden. Resolving apps/storefront/src/checkout/CartSummary.tsx CODEOWNERS pattern (file order, top to bottom) matches? effective * @acme/web-platform yes overridden /packages/design-system/** @acme/design-system no /apps/storefront/** @acme/storefront yes overridden /apps/storefront/src/checkout/** @acme/checkout yes winner /apps/search/** @acme/search no Scanning bottom-up and stopping at the first hit gives the same answer as the forge; top-down does not.
Three patterns match this path, and only file position decides between them — which is why the resolver reverses the rule list before searching rather than scoring specificity.

Validation

Validation has three separate claims to prove: buckets add up, a shared-component defect lands on the design system, and an unattributable finding fails the job. Run the resolver against the real report first and read the bucket table — if one team holds most of the findings, that is usually a marker problem rather than a quality problem.

node a11y/owners/resolve.mjs triage/findings.ndjson CODEOWNERS
# @acme/design-system    1904  triage/owners/_acme_design-system.ndjson
# @acme/storefront       1210  triage/owners/_acme_storefront.ndjson
# @acme/content-platform  802  triage/owners/_acme_content-platform.ndjson
# @acme/checkout          486  triage/owners/_acme_checkout.ndjson
# @acme/search            388  triage/owners/_acme_search.ndjson
#
# 22 findings could not be attributed:
#   region on /status (path: apps/platform/src/status)
#   ...
# Add a CODEOWNERS entry or a route-packages mapping. Not a ticket.
echo $?   # 1 — the routing job fails until those 22 have an owner

Then lock the three claims into assertions on a fixture. The fixture is a five-line NDJSON file plus a small CODEOWNERS, which keeps the test independent of the real report:

// a11y/owners/resolve.test.mjs — run with: node --test a11y/owners
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, execFile } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { promisify } from 'node:util';

const FIXTURES = 'a11y/owners/fixtures';
const run = (findings) =>
  execFileSync('node', [
    'a11y/owners/resolve.mjs', `${FIXTURES}/${findings}`, `${FIXTURES}/CODEOWNERS`,
  ]).toString();

test('a Button defect on a storefront page belongs to the design system', () => {
  run('resolvable.ndjson');
  const ds = readFileSync('triage/owners/_acme_design-system.ndjson', 'utf8')
    .trim().split('\n').map(JSON.parse);
  const btn = ds.find((f) => f.rule === 'button-name');
  assert.equal(btn.route, '/product/:id');          // seen on someone else's page
  assert.equal(btn.sourcePath, 'packages/design-system/src/Button.tsx');
  assert.equal(btn.via, 'build-marker');
});

test('a missing alt on a shared Image is attributed to the calling package', () => {
  run('resolvable.ndjson');
  const content = readFileSync('triage/owners/_acme_content-platform.ndjson', 'utf8')
    .trim().split('\n').map(JSON.parse);
  const img = content.find((f) => f.rule === 'image-alt');
  assert.equal(img.via, 'caller-override'); // component code is fine, copy is not
});

test('an unattributable finding fails the job instead of pooling', async () => {
  const err = await promisify(execFile)('node', [
    'a11y/owners/resolve.mjs', `${FIXTURES}/orphan.ndjson`, `${FIXTURES}/CODEOWNERS`,
  ]).catch((e) => e);
  assert.equal(err.code, 1);
  assert.match(err.stderr, /could not be attributed/);
  assert.match(err.stderr, /Not a ticket/);
});

The third test is the one that keeps the system honest, and it is the one teams delete first when the job goes red on a Friday. Resist that. Twenty-two unattributed findings represent about ten minutes of CODEOWNERS editing; an “unowned” label representing 22 findings today will represent 400 in a year, and nobody will ever read it. If the volume is genuinely blocking a rollout, allow a time-boxed exception list keyed by route, checked into the repository with an expiry date the job enforces — a visible, expiring exception behaves completely differently from an open-ended bucket.

Findings distributed into per-team buckets A single input of four thousand eight hundred and twelve findings fans out into five per-team files: design system with one thousand nine hundred and four, storefront with one thousand two hundred and ten, content platform with eight hundred and two, checkout with four hundred and eighty-six, and search with three hundred and eighty-eight. A sixth branch holds twenty-two unattributed findings and fails the job. One report in, six files out, five of them actionable findings.ndjson 4,812 rows @acme/design-system 1,904 @acme/storefront 1,210 @acme/content-platform 802 @acme/checkout 486 @acme/search 388 unattributed — exit 1 never written to a bucket file 22 40% of all findings live in shared components — one team, highest leverage.
The design-system bucket being the largest is the expected shape of a component-based codebase, and it is the strongest argument for staffing that team's accessibility work first.

Edge Cases and Conditional Guards

  • A finding from a production build with no marker. Scheduled scans occasionally run against a production URL — a smoke check, a synthetic monitor — and those reports carry no data-a11y-src at all. The route manifest catches page-level ownership, but every shared-component finding will be attributed to the page. Tag the report with the build type at scan time and refuse to feed production reports into the routing step; route them to the violation-output formatter for notification only.
  • A component rendered through a wrapper that owns the failing attribute. A Field component renders a <label> and passes htmlFor down; the marker on the <label> points at Field.tsx, but the broken value came from the caller. The CALLER_OWNED set handles the common content cases, and for the rest the resolution rule is behavioural: if the defect reproduces in the component’s own test fixture it belongs to the component team, and if it needs the caller’s props it belongs to the caller. Record each decision in the config so the argument happens once.
  • Multiple owners on one CODEOWNERS line. GitHub allows several teams per pattern, and taking all of them means two teams each believe the other is fixing it. Take the first owner as accountable and store the rest as reviewers, exactly as the review process treats them — and if a line’s first owner is a catch-all platform team for a directory that has a real owner, that is a CODEOWNERS bug worth fixing at the source rather than working around in the resolver.

Pipeline Impact

The routing step runs inside the scheduled triage job, between scoring and issue creation, and it is one of the two places in the whole programme that is allowed to fail the build. Backlog size never fails a job; unroutable findings do, because that is a configuration defect a human can clear in minutes and because the failure is the only thing that reliably gets CODEOWNERS extended. Keep the failure message actionable: rule, route, resolved path if any, and the single sentence naming which file to edit.

Downstream, the per-team NDJSON files are the unit of work distribution. The issue-filing step reads one file at a time so each team’s tickets arrive in one batch with one label, and per-team counts feed the weekly review’s ownership coverage metric — the share of findings with a named owner, which should reach 100% within a few weeks because it costs configuration rather than engineering. The buckets also make capacity visible: a team holding 1,904 findings and a team holding 388 need different plans, which is the input the quarterly targets in burning down an accessibility backlog across quarters are derived from.

One caution about ordering in the pipeline: route ownership before you rank, or rank before you route, but never both at once in parallel jobs writing the same file. The scored rows produced by the impact scoring function and the owner field added here have to end up on the same row, and the simplest way to guarantee that is a single sequential step list in one job with the intermediate files committed as artifacts.

Common Pitfalls

  • Evaluating CODEOWNERS top-down and taking the first match, which assigns every nested directory to whatever catch-all line appears first in the file.
  • Enabling the source-marker plugin in production builds, which inflates the payload with a data attribute on every element and publishes the repository’s directory layout.
  • Attributing shared-component findings by route, which files the same defect against six teams and gets it closed six times as “not ours”.
  • Creating an unowned bucket file “temporarily”, which converts a ten-minute configuration fix into a permanent list nobody is accountable for.
  • Taking every team on a multi-owner CODEOWNERS line, so two teams each assume the other owns the ticket and neither schedules it.
  • Letting a component’s own defects and its callers’ content mistakes land in the same bucket, which makes the design-system team’s queue look twice as large as their actual work.

FAQ

Why not use source maps instead of stamping a data attribute? Source maps resolve a bundled stack frame to a source location, which is the wrong question — a violation has no stack frame, only an element in a DOM that was assembled long after the code ran. Recovering a source path from an element via source maps means reconstructing which component instance rendered it, which needs framework devtools hooks and breaks on every framework upgrade. The data attribute records the answer at compile time, when it is known exactly and costs nothing to compute.

Does the extra attribute change what the scan reports? It changes two things, both manageable. Selector text in node.target may get longer, and any rule that asserts on the full attribute set of an element — a custom rule checking for unexpected data-* attributes, for instance — will see one more. Nothing in the built-in axe catalogue keys on data-* attributes, so the violation set is unaffected in practice, but the marker build must be the build that gets scanned; comparing counts between a marked preview build and an unmarked production build will produce small unexplained differences.

What owns a defect in a component that no team maintains? Nothing does, and the resolver is correct to fail. An unmaintained shared component with accessibility defects is an ownership decision that has been deferred, not a routing problem, and the failing job is the forcing function that gets it made. In practice the outcome is one of three: a team adopts it, it is moved into the design system, or it is deleted along with the pages that use it — and all three are better than a ticket that sits in a shared queue for a year.