WCAG conformance mapping
How individual rule results relate to a WCAG Success Criterion (SC), and what surea11y can and cannot tell you about overall conformance.
The three layers
- Atomic rules (
checksResults[]) — one normative decision each, e.g. "does this<img>have analtattribute." See the rule catalog for every one. - Facets — a WCAG SC is usually bigger than any one rule can decide deterministically. Internally, each SC is broken into named "facets" (e.g. 1.1.1 Non-text Content has facets like
img-alt-attr-present,text-alternative-quality,decorative-null) tracked insrc/coverage/wcag-facets.js, each markedfull(a rule decides it with high confidence),partial(a rule decides part of it — see each rule's own scope notes), ormanual(no safe automated heuristic exists at all). Runnpm run coverageto regeneratecoverage/coverage-report.md, the per-SC facet breakdown. Since core 1.15.0 each facet of a criterion a rule evaluates also has a procedure, how a person assesses it by hand (see Test procedures). - Composite (WCAG-SC rollup) rules (
rulesResults[]) — a generated aggregate of every atomic rule mapped to one SC, giving you one pass/fail/cantTell/notApplicable verdict per SC instead of having to roll up dozens of atomic results yourself. See the full list (e.g.wcag-1.1.1-non-text-contentrolls up 21 atomic rules).
How a composite's outcome is computed
Deterministic precedence, evaluated over that composite's atomic contributors:
| Condition | Composite outcome |
|---|---|
Any contributor fail | fail |
No fail, but any contributor cantTell or a listed contributor didn't run at all | cantTell |
Every contributor notApplicable, for a criterion whose rules find every case it applies to | notApplicable |
Every contributor notApplicable, for any other criterion | cantTell (composite.rollup.cantTell.partialCoverage) |
| Otherwise (all ran, none failed/cantTell, not all N/A), for a criterion whose rules check all it asks | pass |
| Otherwise, for any other criterion | cantTell (composite.rollup.cantTell.partialCheck) |
When a criterion passes, and when it is not applicable
Since 1.14, a criterion passes only when its rules check all it asks, as WCAG has it satisfied only when all of it is. Rules that all passed say only that the parts they check are met: html-lang-attr-present finds that <html lang="fr"> names a language, not that the page is in French (3.1.1), and page-title-present that the page has a title, not that it describes the page (2.4.2). Such a criterion is cantTell, with the reason code composite.rollup.cantTell.partialCheck, and the HTML report says it was partly checked: a person checks the rest. The criteria whose rules check all of them are listed in src/coverage/complete-applicability.js (COMPLETE_CHECK), each with the reason, and none is yet: each asks something the rules can't see, whether a text alternative, a label, a heading or a link's text says the right thing, or how content a script draws or changes meets it. So no WCAG composite passes in 1.14.
Likewise, a criterion is notApplicable only when its rules find every case it applies to: links for 2.4.4 and 2.4.9, focusable elements for 2.4.3 and 2.4.7, labelled components for 2.5.3, markup for 4.1.1. Form fields (1.3.5, 3.3.2) and pointer targets (2.5.8) are not on that list: a field made from an ARIA role, a field with no autocomplete, or an element that is a target only through a script can be on a page where those rules found nothing. For any other criterion, rules that all found nothing only say the cases they look for are absent: a script that redirects after a delay is a time limit (2.2.1) with no <meta http-equiv="refresh">, and a passage in another language (3.1.2) need not carry a lang. In WCAG-EM's terms, "not present" needs every place the content could be to have been looked at, so such a criterion is cantTell, with the reason code composite.rollup.cantTell.partialCoverage.
This means: a composite fail is a real, deterministic violation, and a composite cantTell whose contributors all passed (partialCheck) says every automated check for that criterion came back clean, and what is left is a person's to check. It is not a conformance claim on its own. The facet table (coverage-report.md) shows which parts of each criterion have automated coverage, and which a person checks. Show these as "checks passed, needs review" and "nothing found to check", never as passed.
A composite's data.details.contributors array (see the output schema) lists every atomic rule and its individual outcome — use this to see exactly which facet(s) drove a fail/cantTell, rather than treating the composite as a black box.
A custom rule (engineOptions.customRules) or a pack's rule that ran counts toward the composite of each criterion it maps to, through meta.wcagSc or a WCAG entry in meta.normativeMappings, as a built-in rule does: a failing one fails the composite. The composite marks a custom rule custom: true among its contributors and lists it in data.details.customChecksIds, since custom rules are not checked against the specs as the built-in ones are. An override of a built-in counts where its own mapping says: in its built-in's place when it maps to that criterion, nowhere when it maps to none (with a warning).
A scan that names rules rather than criteria (by id, such as includeRuleIds: ['link-name-present'], by a tag that isn't a WCAG tag, by optInRules, or as the best practices) gets the composites of the criteria those rules check too; a criterion tag such as wcag244 reports that criterion alone (see Engine options). Before 1.12 such a scan reported none. The table above decides them: a failing rule fails the criterion, and the rules the scan didn't select are contributors that didn't run, so the criterion is at best cantTell, partly checked (reasonCode composite.rollup.cantTell.notSelectedChild, each such contributor marked notSelected: true; "Partly checked" in the HTML report).
Targeting a conformance level (A / AA / AAA)
Name the WCAG version and level in runOnly.wcag:
// WCAG 2.2 Level A and AA:runDomRulesInPage(url, null, {}, { wcag: { version: '2.2', level: 'AA' } });// The same, and the best-practice rules, which test no WCAG criterion:runDomRulesInPage(url, null, {}, { wcag: { version: '2.2', level: 'AA' }, bestPractices: true });
A target runs the rules for every criterion in force in that version at that level and below, by the level each criterion has in that version (WCAG_CRITERIA in @surea11y/core/wcag), so 4.1.1 Parsing is part of 2.0 and 2.1 targets and not of 2.2 ones. It also sets the run's WCAG version and the level of the rollups reported. See Selecting by WCAG target.
Each entry of WCAG_CRITERIA also has the id W3C gives the criterion in WCAG 2.2 (non-text-content). Every WCAG mapping a rule carries links its criterion's section in that version's Recommendation (url) and its Understanding document (understandingUrl), for 2.1 and 2.2: the reporters use the Understanding document as a rule's help link when the rule has none of its own.
With tags
The same targets can be spelled with level tags, in runOnly.tags (or engineOptions.tags.include):
// WCAG 2.0 A and AA. Both tags are required: they are not cumulative.runDomRulesInPage(url, null, {}, { tags: ['wcag2a', 'wcag2aa'] });
Level tags do not nest, and this is the easiest thing to get wrong here. A rule carries one level tag per Success Criterion it maps to, and nothing more: a rule mapped only to an AA criterion is tagged wcag2aa and not wcag2a. Asking for { tags: ['wcag2aa'] } on its own therefore runs the 11 rules mapped to a 2.0 AA criterion, not the 105 that make up a 2.0 A + AA target. List every level you mean, or use runOnly.wcag, which does not have the problem. engineOptions.profile (wcag20-aa, wcag21-aa, wcag22-aa and the other WCAG versions and levels) does it for you too; see Conformance profiles.
The same applies across WCAG versions — a criterion introduced in 2.1 or 2.2 carries only its own origin tag — so a full conformance target is a union of tag sets. See the engine options guide for the ready-made sets per version, including the one criterion WCAG 2.2 removed rather than added.
The removed criterion is handled for you. Every run resolves a target WCAG version (engineOptions.wcagVersion, else the wcagVersion of the pack profile the run applies, else the version of runOnly.wcag, else whatever your version tags imply, else 2.2) and reports it back as engine.wcagVersion. Under a 2.2 target, a rule mapped only to SC 4.1.1 Parsing cannot report fail — it runs, reports its occurrences, and comes back cantTell with a wcagVersionScope field explaining the coercion. Since 1.14, under a 2.0 or 2.1 target it does the same, with wcagVersionScope.by: "errata": W3C's 2023 errata to those versions count 4.1.1 as always satisfied for content in HTML or XML. So no WCAG scan gates on 4.1.1; only a profile whose standard keeps its text, one that says keepsParsing: true as en301549-v3.2.1 does, still fails it.
Composites, unlike atomic rules, are filtered cumulatively. The runner reads engineOptions.wcagLevel, else the level of runOnly.wcag, or the highest level named in tags, and drops every composite above it, so requesting ['wcag2a', 'wcag2aa'] returns no rulesResults entry for an AAA-only SC. wcagLevel ('A', 'AA' or 'AAA') is the one to set on a scan that names checks rather than a target: every selected rule still runs, and only the criteria it reports are kept to that level, so a scan of contrast-computable at 'AA' reports 1.4.3 and not the AAA 1.4.6. The run records it as engine.wcagLevel. That is targetWcagLevelOf/isAllowedByTargetLevel in src/core/dom-runner.js if you need the exact precedence.
Omit tags entirely (the default) and every rule at every level runs, with no composite suppression.
EN 301 549
EN 301 549 moves to its own pack, @surea11y/pack-en301549, and leaves core in 2.0.0, with its profiles, its mappings key and @surea11y/core/en301549. All of it works as before, with one console.warn per process. Pass the pack in engineOptions.packs to switch: the scan takes the same names from the pack, and @surea11y/pack-en301549/public has the table's exports. See how core deprecates a standard.
Chapter 9 of EN 301 549 restates the WCAG Level A and AA Success Criteria as clauses numbered 9. plus the criterion's own number: WCAG 1.4.3 is clause 9.1.4.3. When a scan asks for them, every atomic and composite result carries, after its WCAG entries in meta.normativeMappings, the clause for each of its criteria, once per version of the standard that includes that criterion. A scan asks with engineOptions.mappings: ['en301549'] (both versions) or ['en301549:V3.2.1'] (one), or by targeting an EN 301 549 profile, which adds the version it names; by default results name WCAG only (see Other standards).
| Version | Built on | Differs from the other in |
|---|---|---|
| V3.2.1 (2021-03) | WCAG 2.1 A and AA, 50 criteria | Includes 9.4.1.1 Parsing |
| V4.1.1 (2026-09) | WCAG 2.2 A and AA, 55 criteria | Adds 2.4.11, 2.5.7, 2.5.8, 3.2.6, 3.3.7, 3.3.8; 9.4.1.1 is void |
Each clause entry also names the criterion it restates, as wcagSc: ["1.4.3"], so a view grouped by criterion (JUnit's suites, the HTML report's rollup) puts it under the right one even on a rule mapped to several. AAA criteria have no clause in either version. The catalogs name the clauses under the same options: getChecksCatalog({ mappings: ['en301549'] }) in each entry's normativeMappings, and composite entries returned by getRulesCatalog({ mappings: ['en301549'] }) as meta.standardMappings, so the clauses a criterion maps to can be read without running a scan.
The table is public as @surea11y/core/en301549, for tools that need the reverse view, such as which criteria a version requires that a scan did not cover:
const { EN301549_CLAUSES, en301549ClausesForSc } = require('@surea11y/core/en301549');Object.keys(EN301549_CLAUSES['V3.2.1']); // the 50 WCAG 2.1 A and AA criteria V3.2.1 restatesen301549ClausesForSc('2.5.8'); // [{ version: 'V4.1.1', clause: '9.2.5.8', title: 'Target size (minimum)' }]
This is a correspondence between two published documents, not a conformance claim: a clause on a result says which EN 301 549 requirement that WCAG criterion is, nothing more. Which version a given law requires is outside the engine.
EN 301 549 is one entry in a registry of standards that the runner, the rule catalog and the reporters all read. A standard with verdicts of its own (requirements its own rules check, a national standard such as RGAA, say), or an organisation's checklist, comes as a pack: a package of its own outside core, which a scan takes in engineOptions.packs and whose entry joins the registry for that scan, with its own profiles, rules and rollups. A custom rule can name any standard in its own normativeMappings, but only registered standards (core's and the scan's packs') get a profile, a mappings switch, opt-in rules, rollups, or a place in SARIF, JUnit and the HTML report. How a standard is built into core itself is in WCAG_CONFORMANCE.md, the contributor guide on GitHub.
What this engine cannot tell you
No automated tool — this one included — can certify full WCAG conformance. That's not a limitation specific to surea11y; it's inherent to WCAG itself; a meaningful fraction of Success Criteria require human judgment (is this alt text accurate, not just present; is this error message understandable) or dynamic testing this engine's static-DOM-scan architecture cannot do at all (keyboard-trap detection, real layout/reflow at zoom). See the known limitations for the full, explicit list of what's out of scope and why.
What surea11y can give you, honestly:
- Core's built-in rules are written to
failonly on a deterministic, normative violation of the standard and version you targeted, and to reportcantTellwhen they can't be sure. That is a design goal, not a guarantee: the known limitations list edge cases where a rule can still be wrong, each finding's occurrences show what it was decided on, and the rule catalog gives each rule's confidence. How accurate a custom rule, a pack rule or an override is depends on how it was written. By default that standard is WCAG. A rule for a requirement only another standard makes is opt-in: by default it runs only when you target that standard (see Opt-in rules).engineOptions.optInRulesunlocks opt-in rules without removing the scan's profile or filters. WithoptInRules: 'all'and no profile or filter, the run covers every rule and targets no single standard; a list of tags unlocks only those opt-in groups.engine.optInRuleslists the tags whose rules ran. Under a WCAG profile, an unlocked rule runs only when its tags match that profile, and the run still targets WCAG. So among core's rules, a scan with a WCAG target runs only those for the target's criteria, plus the best-practice rules if you add them. A best-practice rule names no criterion, and can still reportfailwhere what it finds is itself a barrier, such as an empty heading (empty-heading); no WCAG composite counts it. A custom rule, an override or a pack rule without an opt-in tag runs when the selection includes it, and can then report afailfor a check of its own that WCAG doesn't require. - Since core 1.15.0, a rule fails a WCAG criterion only where WCAG fails. Where only WAI-ARIA or the HTML content model is broken and the page's name, role and value survive it, the ARIA and HTML structure rules ask instead (
cantTell, uncertaintyspec-only), so a WCAG failure never stands for a specification violation that WCAG doesn't count. To judge those findings by WAI-ARIA itself, run the WAI-ARIA pack's profile beside WCAG's. - Every
cantTellis an explicit flag for human review, not a swallowed uncertainty. - A composite never passes a criterion its rules check only in part: where they all pass, it is
cantTell(composite.rollup.cantTell.partialCheck), so apassis never silent about a part no rule checks. - The facet coverage table tells you exactly which parts of which SCs have zero automated coverage, and so what is left to a person, and core's test procedures say how a person checks each of them, for every criterion a rule evaluates.
Composites with no fail across every SC at your target level mean: every automatable check for that level came back clean. It is the automatable subset of conformance, stated precisely — not a substitute for the manual review WCAG itself requires.