Architecture
One hard line, and finding it is the entire design.
Why a formatter
Code formatters won. Not because they made code prettier, but because they made a class of argument extinct. Nobody debates brace style anymore because prettier --check turns the debate into a failing build. The discipline that made that possible is a single contract: a formatter never changes what the code means. Output is equivalent by construction. That is the only reason a human stopped reading the diff.
Accessibility has no such tool, and the absence is the excuse. “I’ll add a11y later” survives because a11y currently looks like judgment work: open a PR, argue about roles, eyeball a screen reader. Aria’s thesis is that a real, defensible slice of that work is mechanical, and the mechanical slice can be held to the formatter contract.
The catch is that accessibility breaks the formatter contract the instant you guess. Put role="button" on a div and you changed behavior. Author aria-label="Close" and you asserted a fact that might be a lie — and the spec is explicit that a wrong label is worse than none.
The central invariant
Aria classifies every accessibility fact by where its semantics came from, and every rule belongs to exactly one tier:
type SemanticSource =
| 'native' // implicit role/semantics of a real HTML element, per aria-query
| 'declared' // author-written explicit ARIA, or component semantics from config
| 'inferred'; // guessed by the engine from signals (onClick, class names, context)type Tier =
| 'format' // meaning-preserving. runs on save. gates CI.
| 'lint'; // inference. located errors + suggested fixes. never silently applied.A fix may run in the format tier only if its semantic basis is native or declared. Any fix whose basis is inferred is lint tier and is never auto-applied.
The formatter acts only on semantics it knows — real HTML, or declared via config. It never acts on semantics it guessed. A format-tier fix must additionally satisfy meaning-preservation: for every possible runtime, the computed accessibility tree after the fix is identical to before, or strictly more spec-conformant, with zero change to the conveyed name, role, or state. In practice that means format fixes are subtractive or normalizing — they delete ARIA that is redundant or forbidden, and they normalize syntax. They do not add assertions.
Enforced three times, not promised once
1 · in code
Rules never choose their fix kind. They declare a basis, and @aria/core’s policy derives the kind; assertGate throws on any inferred + auto-fix pairing. An inferred auto-fix is structurally impossible to emit, not merely discouraged.
2 · by the host
ESLint and oxlint already distinguish an auto-applied fix from a surfaced suggestion. The gate maps onto that model, so the host’s own machinery guarantees inferred fixes never land on save.
3 · by tests
A property suite asserts no inferred-basis diagnostic ever carries an applied fix, and an ESLint ↔ oxlint parity harness re-verifies every fixture on both hosts on every commit — a required CI check, currently zero drift.
Basis and tier can deliberately diverge
The gate says what may be auto-applied; it doesn’t force every provable fact to become an auto-fix. Several shipped rules detect a native fact yet stay lint-tier, for three distinct, documented reasons:
- Uncertain-if-broken — idref-resolves
- “Not found in this file” is a fact, but not conclusively a bug — an id can legitimately live in another file or be injected at runtime. Advisory, never CI-failing: a false positive on correct code is the one thing the format tier may never produce.
- Unfixable-by-machine — img-needs-alt, control-needs-name
- A nameless image or control is certainly broken, but the only repair is authoring content — a hard non-goal. Aria flags the gap and leaves the words to a human.
- Refuses-to-pick — aria-hidden-not-focusable
- A mechanical fix exists (tabindex="-1"), but which repair is right depends on intent the tool can’t see — applying the wrong one would make things strictly worse. The rule names the options and declines to choose.
The line moves: the config bridge
The boundary between guess and known isn’t fixed — config is the lever. Declare a component’s semantics and the engine stops guessing: its diagnostics move from inferred to declared basis. The declared role is descriptive — it lets the name checks understand the component. Role injection is a separate opt-in (injectRole), for a component that renders a non-semantic element and genuinely needs the role: that’s what graduates an inferred suggestion to a real declared-basis auto-fix. Proven by named end-to-end tests in the repo, not asserted.
Declared basis doesn’t force a fix, either — a declared image component missing its name prop is still report-only, because Aria still can’t author the name. Known semantics, honest limits.
// aria.config.ts
import { defineConfig } from '@aria/config';
export default defineConfig({
componentSemantics: {
// renders a native <button>: declared so the name checks understand it
IconButton: { role: 'button', requiresName: true, nameProp: 'aria-label' },
// renders a <div> that needs the role at runtime: opt into injection
MenuButton: { role: 'button', injectRole: true },
},
});The full spec — the working agreement, the gate, the implementation plan, and every documented judgment call — lives in the repo: CLAUDE.md and docs/rule-registry.md.