Output schema

This is the exact shape of the object returned by runDomRulesInPage(...) / runa11yCoreInPage(...) (see INTEGRATION.md for which one to call). Every example on this page is real output from the current engine (schemaVersion: "1.0.0"), not hand-written. runa11yCoreAcrossFrames returns a different, recursive shape wrapping this one — see Cross-frame result below.

Top-level result

{
engine: {
tag: string,
version: string, // the @surea11y/core release that produced the result
schemaVersion: string,
locale: { requested: string, resolved: string, reason: string },
wcagVersion: "2.0" | "2.1" | "2.2",
wcagLevel?: "A" | "AA" | "AAA",
profile?: string, // "wcag20-a" … "wcag22-aaa" (wcag<version>-<level>), the deprecated "en301549-v4.1.1", "en301549-v3.2.1" and "section508", or a pack's own
profiles?: string[], // a scan of several profiles only: the profiles that ran
profilesNotRun?: { profile: string, reason: string }[], // a scan of several profiles only
mappings?: string[], // e.g. ["en301549"] or ["en301549:V3.2.1"]
profileExcludes?: { rules: string[], criteria: string[] },
law?: { id: string, name: string, jurisdiction: string, lang: string }, // experimental
optInRules?: string[],
outputDetail?: "findings",
packs?: string[], // name@version
environment: {
layout: boolean,
viewport?: { width: number, height: number }, // CSS px
devicePixelRatio?: number,
colorScheme?: "light" | "dark",
forcedColors?: "active" | "none",
forcedColorsCss?: true, // under forced colors, when the page's own CSS reacts to them
fonts?: "loaded" | "loading",
images?: "loaded" | "loading",
animationsSettled?: number,
contentVisibilitySkipped?: number, // elements whose content-visibility: auto content was skipped
internalsHook?: "early" | "late", // only where the page runs the internals hook
listenerHook?: "early" | "late", // only where the page runs the listener hook
liveRegionHook?: "early" | "late" // only where the page runs the live-region hook
}
},
url: string | null,
title: string | null,
timestamp: string | null,
perfStats: object | null,
contextSelector: string | string[] | null,
contextMatch: { elementCount: number, unmatchedSelectors: string[] } | null,
checksResults: CheckResult[],
rulesResults: CompositeResult[],
standards?: Array<{ key: string, standard: string, titleLang?: string, note?: string }>,
overriddenBuiltinIds: string[],
skippedCustomRules: Array<{ id: string | null, reason: string, excluded?: true }>,
skippedPacks?: Array<{ name: string | null, reason: string }>,
profiles?: { [profile: string]: { wcagVersion, wcagLevel?, standards, rulesResults } } // a scan of several profiles only
}
FieldMeaning
engine.tagThe engine's own identity tag, currently "a11ycore". Every rule (built-in or custom) carries it in meta.tags — rule ruleIds themselves are bare (no prefix).
engine.versionThe @surea11y/core release that produced the result, such as "1.11.1": what to quote in a bug report, and how to tell results from two releases apart. A result can change between releases without its shape changing, so this is not schemaVersion. The SARIF and EARL reporters use it as the tool version when you give none.
engine.schemaVersionThe result-schema version ("1.0.0"). Bump-worthy if this document's shape ever changes incompatibly — pin to it if you're parsing output programmatically. See API_STABILITY.md for the full stable/unstable field list and version-bump policy.
engine.localeWhich dictionary the run actually used. requested is your engineOptions.locale after trimming ("en" if you passed nothing or a non-string); resolved is the locale whose dictionary was used; reason explains the pairing. Because locale fallback is graceful and per-string, asking for a language the build does not carry produces English text rather than an error — this field is how you find that out without reading the strings. Reported once per result: a run uses one dictionary throughout.
engine.locale.reason"ok" — you got the dictionary you asked for, and it carries every key. "primary-subtag" — your code had a subtag with no dictionary of its own, so its base language was used: "de-DE" resolves to "de". "dictionary-not-loaded" — the project ships that language, but this build does not carry it and none was supplied (the standalone browser bundle, without its locale side file). "unknown-locale" — the project has no such translation at all. "partial-dictionary" — the dictionary was used but is missing keys English has, so those strings fell back to English. Treat the set as open; later releases can add to it.
engine.wcagVersionWhich version of WCAG this run checked against: your engineOptions.wcagVersion, or the wcagVersion of the pack profile the run applied, or what your version-origin tags implied, or the default "2.2". For SC 4.1.1 Parsing it says why a rule mapped only to it cannot fail: 2.2 removed it, and the 2023 errata to 2.0 and 2.1 count it as always satisfied (see checksResults[i].wcagVersionScope below). Reported once per result: a run has one target throughout.
engine.wcagLevelThe WCAG level the reported criteria were gated by, "A", "AA" or "AAA": engineOptions.wcagLevel when it was given and valid, or else the level the selection gives (runOnly.wcag.level, or the highest level its version/level tags name, a profile's tags included). WCAG rollups above it are not in rulesResults, though their rules ran and are in checksResults; a rollup the selection named by id is reported whatever engineOptions.wcagLevel says. Absent when the scan had no level, as a full scan or one that names rules only, which reports every level. The reporters gate the criteria they name by it. See Giving the level of a scan that names checks.
engine.profilePresent only when an engineOptions.profile selected this run's rules, and names the profile, lowercased (in a scan of several profiles, the first that ran). Absent when none was asked for, and also when one was asked for but did not apply (unknown name, or an include in runOnly or engineOptions chose the rules instead), so its presence is how you confirm a run really targeted that profile. See Conformance profiles.
engine.profilesA scan of several profiles only (engineOptions.profile as a list): the profiles that ran, in the order asked. engine.profile is then the first of them. See Several profiles.
engine.profilesNotRunA scan of several profiles only, and only when one of them didn't run: [{ profile, reason }], reason unknown-profile (no such profile), pack-skipped (its pack was skipped, see skippedPacks) or overridden-by-selection (an include in runOnly, or rule ids or tags in engineOptions, selects the rules instead, as it would for one profile; it does for every profile's scan). The set of reasons is open.
engine.profileExcludesPresent only when the applied profile leaves rules out: { rules, criteria }, the rules it names and the WCAG criteria it leaves out. The rules it names, the rules whose every criterion it leaves out, and those criteria's WCAG rollups did not run; a rule that also checks a criterion the profile keeps ran, and counts toward that criterion. This narrows the scan without changing what WCAG requires. No built-in profile leaves anything out.
engine.lawExperimental (core 1.16.0, until 1.18.0). Present only when the applied profile names a law, the one a pack's standard is assessed under (see Packs): { id, name, jurisdiction, lang }, lang the language name is in. Core states the law; it never decides whom the law applies to.
engine.optInRulesPresent only when engineOptions.optInRules ran at least one opt-in rule that the rest of the selection would not have run. Lists their tags. Its presence means the result includes rules for requirements beyond the targeted standard, so a failure may not be a WCAG failure. See Running every rule.
engine.mappingsPresent only when the run names a standard besides WCAG in meta.normativeMappings, through engineOptions.mappings or a standard's profile. Lists them canonically: "en301549" for every version, "en301549:V3.2.1" for one. Absent means every result names WCAG only. See Other standards.
engine.packsPresent only when the scan ran with engineOptions.packs: each pack that ran, as name@version, sorted by name. See Packs.
engine.outputDetailPresent only when engineOptions.output.detail is 'findings', as "findings": each pass or notApplicable check result that lists no occurrences is compact, { ruleId, outcome, type } and its margin or error when it has one, without the title, description and meta, which are the catalog's and the same on every page. fail and cantTell results, the rollups and engine are whole, so no verdict changes. The reporters (SARIF, JUnit, the HTML report, EARL, the baseline functions) read a compact result as the full one, filling in from core's catalog. A result that catalog couldn't fill in stays whole: every check result of a scan with packs, a custom rule's, and one whose title or description the scan's messages changed. An empty page's result goes from about 263 KB to 90 KB.
engine.environmentThe conditions the page was rendered under, read from the page when the scan starts, before any rule runs. Rules that measure the layout, such as text-spacing-content-loss, can give another outcome for the same markup at another viewport width or while web fonts are still loading, so this is what you need to reproduce a run. layout is whether the page had one: false in jsdom and other DOM emulators, and then the viewport and the other rendering fields are left out, since they describe nothing that was measured; only internalsHook, listenerHook and liveRegionHook can sit beside it, where the page runs them. In a browser, viewport is innerWidth/innerHeight in CSS pixels, devicePixelRatio the page's (browser zoom shows here too), colorScheme what prefers-color-scheme matches, forcedColors whether forced-colors: active matches ("active" under Windows high contrast or Chromium's emulated forced colors, "none" otherwise; the rules measure the page's own colors either way, see Forced colors), forcedColorsCss is true under forced colors when the page's own CSS reacts to them in a way the scan can't hold off (a forced-colors media query, which still matches, or a system color such as GrayText, which still takes the forced palette's), so the contrast results can differ from a scan with forced colors off, and fonts is "loading" while any of the page's font faces is still loading, "loaded" otherwise. A field the page can't report is left out. Reading these adds no nondeterminism: like the DOM, they are input. For results you can compare between runs, wait for document.fonts.ready before scanning. images is "loading" while any image is still loading, "loaded" otherwise: an image that arrives later can move what layout rules measured, so for comparable results scan after the page's load event. An image with loading="lazy" is left out, since it waits for the reader to scroll. animationsSettled is how many running animations and transitions the scan held still: for the scan, a finite one (a fade-in) is read at its end and one that repeats for ever (a marquee, a spinner) at its start, and each is put back exactly where it was afterwards, so a page scanned during a fade-in gets the same result as one scanned after it. The browser never paints the held state, since the scan is one synchronous task. contentVisibilitySkipped is how many elements with content-visibility: auto had their content skipped by the browser, as it does off screen: the scan lays that content out as it is once near the viewport, and lets the browser skip it again afterwards (see LAYOUT_MEASUREMENT.md on GitHub); absent when there were none. internalsHook is there only where the page runs the opt-in internals hook (internalsHookScript(), see INTEGRATION.md) in the scanned document, with or without a layout: "early" when it was installed before the page's scripts and is still the page's attachInternals, so the scan read every custom element's ElementInternals and an element with none is no question; "late" when it was installed after them, or the page replaced attachInternals after it, so only internals attached after it were read. Without a hook the field is absent and the result is as before. listenerHook is the same for the opt-in listener hook (listenerHookScript(), see INTEGRATION.md): "early" when it was installed before the page's scripts and is still the page's addEventListener, so the keyboard rules saw every listener the page added from script and a missing one proves there is none; "late" when it was installed after them, or the page replaced addEventListener after it. liveRegionHook is there only where the page runs the opt-in live-region hook (liveRegionHookScript(), see INTEGRATION.md): "early" when it was installed before the document finished loading, so it saw every change to the page's live regions since; "late" otherwise. Each frame in a cross-frame result carries its own.
urlThe pageUrl argument you passed in, or document.location.href if you passed null/omitted it (or passed something other than a string, which also logs a console.warn), or null if neither is available.
titledocument.title at scan time, or null.
timestampNot auto-generated. Only set if you pass engineOptions.timestamp as a non-empty string — the engine has no built-in clock (deterministic-by-design). If you want a scan timestamp in the result, supply it yourself.
perfStatsnull unless engineOptions.perfStats: true. Internal timing/counters — shape not covered by this document, treat as debug-only.
contextSelectorThe (trimmed) contextSelector argument you passed — a string, an array of strings (multi-region scanning, see Engine options), or null if none/empty.
contextMatchHow the contextSelector resolved, or null when none was given. elementCount is how many distinct elements it matched, the roots of the scan. unmatchedSelectors lists each selector, as you gave it, that matched no element; a comma-separated string is one selector here, as it is one querySelectorAll call. An elementCount of 0 means nothing was scanned: the page has no such scope, every entry in checksResults and rulesResults is notApplicable, and the result says nothing about the page. Report it as a scope that wasn't found, not as a clean scan. A selector the browser can't parse never reaches a result: the scan throws with code: 'INVALID_CONTEXT_SELECTOR' (see Engine options).
checksResultsOne entry per atomic rule that ran (every rule not filtered out by runOnly — see Engine options). Every loaded rule produces an entry, even ones that outcome notApplicable — this is not a "violations only" list.
rulesResultsOne entry per composite (rollup) rule that ran — see A composite result and Conformance. Normally one per WCAG Success Criterion; a run under the profile of a standard with rollups of its own also gets those (meta.standard set). It holds the rollups the selection selects, and the rollups of the criteria checked by a rule the selection named on its own (by id, say), which may be partly checked (see Which criteria a scan reports). Empty array when neither gives any.
standardsPresent only when the result names a standard besides WCAG in a meta.normativeMappings entry or a rollup's meta.standard: one { key, standard, titleLang?, note? } per registered standard it names, a pack's included, in registry order. key is the standard's key ("en301549"), standard the name its entries carry ("EN 301 549"), titleLang the language its own rollups' titles are written in when not the scan's, and note the text above its own rollups in the HTML report, in the scan's language. The reporters read it instead of the registry, so a stored result renders the same wherever it is rendered, whether or not that installation knows the standard. A standard that only a custom rule names is not listed.
skippedPacksPresent only when a pack in engineOptions.packs was not run: each { name, reason }, name being null when the pack had none. In a page (runa11yCoreInPage), name is the name@version given, and the reason says why the page didn't run it: no packScript call registered exactly the named set, or the script was prepared with another core version. Each is also logged with console.warn.
overriddenBuiltinIdsRule ids where an engineOptions.customRules entry shared its id with a built-in rule, so the custom implementation replaced the built-in one for this scan (see Engine options). Always an array; empty when no collision occurred. Also logged via console.warn at scan time, since a same-named custom rule is as likely to be an accidental collision as a deliberate override.
skippedCustomRulesThe engineOptions.customRules entries that were not run, each { id, reason } (id is null when the entry had none): no id, a runInPage that is not a function or can't be rebuilt from its source, a meta that fails validation, an id another custom rule already has, or a composite rule's id; or a valid rule the run's selection does not select (under a profile or a WCAG target, a rule tagged best-practice or with no WCAG tag), whose reason says how to select it; or a rule the selection's excludes leave out (excludeRuleIds, excludeTestIds, excludeTags, rules.exclude…), whose reason is excluded by the selection. and which also has excluded: true, so a tool can tell the caller's choice from a gap without reading the reason. Always an array; empty when every custom rule ran. Each is also logged with console.warn, except an excluded rule, which the caller asked to leave out. A rule listed here is in no result, so a scan that leaves one out can be told apart from one where it passed.

Several profiles

With engineOptions.profile a list of two or more (['wcag22-aa', 'wai-aria-1.2'], see Engine options), each profile judges the page as a scan of it alone would, and the result reads as a scan of one profile with the rules of all. A single name, or a list of one, gives exactly the result it always has, with none of the fields below. Core 1.15.0 and later.

  • checksResults: each rule once. verdicts is each profile's outcome for it ({ 'wcag22-aa': 'cantTell', 'wai-aria-1.2': 'fail' }), keyed by profile, for the profiles that ran it; outcome is the worst of them, fail, then cantTell, then pass, then notApplicable. A reporter that reads outcome counts the scan as that one profile. checkOutcome is the rule's own outcome, before any standard judges its findings by reason code: what its WCAG criteria read, since a standard's failure of a finding WCAG only asks about is no WCAG failure.
  • Each occurrence is listed once, with verdicts: what each profile makes of it. That is its tier, unless a standard's rollups judge the rule by its findings' reason codes: then it is what they make of it (a WCAG cantTell a standard is certain of is fail there), or notApplicable where the standard doesn't count it. A finding that fails one profile and is a question for another says both. occurrenceOutcome is the worst of them where it differs from the check's.
  • rulesResults: every profile's rollups, each once, with profiles, the profiles that give it. One that two profiles give with different outcomes (two WCAG versions, say) is the worst of them.
  • profiles: per profile that ran, { wcagVersion, wcagLevel, standards, rulesResults } (wcagLevel only for a profile that targets a WCAG level), rulesResults the outcome of each of its rollups by id: what a scan of that profile alone would have reported. A profile that names a law has law too, as engine.law (experimental).
  • severity: the highest any profile that fails or asks about the rule gives it, whatever order the profiles are listed in.
ReaderWhat it does with several profiles
SARIF, JUnitGate on outcome: any profile's failure fails the build. A profile asked for and not run is an error unless allowProfilesNotRun (SARIF, JUnit).
EARLAsserts each rule against its WCAG criteria by checkOutcome, so a standard's failure is never asserted as a WCAG one (EARL report).
The HTML reportNames every profile in its heading, and shows each profile's verdict on a finding's card (wai-aria-1.2: Fail) beside its WCAG criteria (HTML report).
BaselinesMatch findings by rule, reason code and markup, as for any result: a finding is one entry, whatever its verdicts (Baseline).
BindingsPass engineOptions through; with frames, each frame's result is a merged one. A profile not run is a profile-not-run scan gap.

Cross-frame result (runa11yCoreAcrossFrames)

runa11yCoreAcrossFrames (see INTEGRATION.md) returns a different, recursive shape instead of a plain top-level result:

{
topFrame: <the normal top-level result shape above>,
frames: Array<
| { url: string | null, selector: string | null, title: string | null,
topFrame: <top-level result>, frames: [...same shape, recursively] }
| { url: string | null, selector: string | null, title: string | null, error: string }
>
}
  • topFrame is exactly the top-level result shape, for the frame the function was called in.
  • frames has one entry per direct child <iframe>/<frame> in the scanned scope (open shadow roots included, and the scope itself when it is a frame), except a frame the page doesn't show and a frame excludeSelectors excludes, which are left out with their whole document. Each entry names its frame element: selector is a CSS selector for the <iframe>/<frame> in the parent's document, and title is the element's title attribute, or null, so frames loading the same document can be told apart. url is the document the frame shows, or, for a frame that hasn't navigated yet, its src. A reachable child (one that called a11yCoreEnableFrameResponder()) contributes its own complete { url, topFrame, frames } — including its own nested frames, recursively, since a further-nested grandchild is only reachable through its immediate parent. An unreachable child (the common case for most third-party embeds — no cooperating responder, or it timed out) contributes { url, error } instead, and does not abort the rest of the scan.
  • This is a tree, not a flat list — a deliberate difference from the @surea11y/playwright binding's .frames(true), which can flatten because Playwright's page.frames() already gives every frame regardless of nesting depth; a postMessage relay has no such global view, so nesting is expressed structurally instead.
  • The reporters take the tree as it is. renderHtmlReport, renderSarifReport, renderJunitReport, buildBaselineEntries, matchBaseline and renderEarlReport report every frame of it: each frame that answered with its own findings, located in its own document, and each frame that did not as not scanned, so a gap in coverage doesn't read as a pass. A frame is named by its path, the selectors of the frame elements leading to it from the page, outermost first (#checkout → iframe:nth-of-type(2)). They throw a TypeError for anything that is neither a scan result nor a cross-frame result (an array, null or undefined from a scan that broke), rather than render it as a scan that found nothing.
  • They take a binding's frame scan too. The browser bindings' .frames(true) returns { topFrame, frames } with each frame a plain result (or { url, error }) rather than a nested entry; the reporters, getMargins and flattenCrossFrameResult read each such frame as scanned, named by the path ['iframe'], since it carries no selector.
  • flattenCrossFrameResult(result), from @surea11y/core, lists the frames as the reporters read them: [{ frame: { path, title, url }, result }], or error in place of result for a frame that did not answer, the page first and each frame before the frames inside it. The page's path is []. A plain scan result is one entry.

A check result (checksResults[i])

{
ruleId: string,
outcome: "pass" | "fail" | "cantTell" | "notApplicable",
outcomeNormalized: "pass" | "fail" | "cantTell" | "inapplicable",
severity: "minor" | "moderate" | "serious" | "critical",
ruleSeverity?: "minor" | "moderate" | "serious" | "critical", // the rule's own, when a profile gave it another
verdicts?: { [profile: string]: string }, // a scan of several profiles only: each profile's outcome
checkOutcome?: string, // a scan of several profiles only: the rule's own outcome, which its WCAG criteria read
policyOutcome?: "pass" | "fail" | "cantTell" | "notApplicable", // what the rule found, when the policy made it cantTell
confidence: "high" | "medium" | "low",
type: "automatic" | "manual",
occurrences: Occurrence[],
title: string,
description: string,
i18n: { titleKey: string, descriptionKey: string, variantOfTitleKey?: string } | null,
meta: {
ruleId: string,
ruleInterfaceVersion: string,
ruleVersion: string,
normative: boolean,
atomic: boolean,
deprecated: boolean,
deprecation: object | null,
category: "perceivable" | "operable" | "understandable" | "robust" | null,
helpUrl: string, // where to read how to fix it; "" when there is none
tags: string[], // the rule's tags, its own and the engine's
normativeMappings: Array<{ standard: string, version: string, requirement: string, title: string,
conformanceLevel?: string, wcagSc?: string[], url?: string, understandingUrl?: string }>,
standard: string | null,
applicability: string,
expectation: string,
references: string[],
requirements: object | null,
mappings: object | null
},
engineOptions: object, // the resolved engineOptions this rule actually ran under
schemaVersion: string,
rollupIds: string[], // the rulesResults entries that group this rule in this run; [] if none
variantOf?: string, // a pack's variant only: the id of the rule it runs at other settings; i18n.variantOfTitleKey is that rule's titleKey
data?: object, // page-level data a rule reports whatever its outcome; see below
margin?: { // the closest a measurement came to its threshold while meeting it; see below
measure: string, // "contrast-ratio" | "overflow-px" | "target-size-px" | ... (open set)
unit: "px" | "ratio", // open set
limit: "min" | "max",
threshold: number,
value: number,
headroom: number, // >= 0
measuredCount: number,
selector?: string,
shadowHostSelectors?: string[], // as on an occurrence
structuralPath?: number[],
context?: object // rule-specific; not a stable contract
},
wcagVersionScope?: { // present only when the target WCAG version changed this outcome
target: "2.0" | "2.1" | "2.2",
removedSc: string[],
coercedFrom: "fail",
by?: "errata" // 2.0 or 2.1: their errata count the criterion as always satisfied
},
ancestorWalkLimit?: { // present only when a failed element is nested deeper than the engine walks up from it
limit: number, // 10000: the ancestors read, the element included
occurrences: number, // the fail-tier occurrences made cantTell-tier
coercedFrom?: "fail" // no fail-tier occurrence was left, so the outcome went from fail to cantTell
},
error?: string // present when the rule did not complete, or the engine noted a change — see below
}

Notes:

  • outcome vs outcomeNormalized: identical except notApplicable becomes "inapplicable" in outcomeNormalized. Both are provided so you can match either your own vocabulary or the engine's internal one.
  • Under the default a11y policy, type: "manual" rules report cantTell rather than fail. If a manual rule's own logic would have said fail, the engine coerces it to cantTell and appends an explanatory note to error — this is enforced centrally (policy.coerceManualFailToCantTell, on by default under the a11y policy contract; see POLICY.md), not something each rule has to remember. Under this policy, fail is reserved for deterministic, type: "automatic" findings only.
  • meta.normativeMappings is how a check result ties back to a WCAG Success Criterion. An empty list means the rule has no formal mapping in this result. That doesn't say whether the rule is automatic or manual: read type for that (aria-allowed-role, for one, is automatic and has no WCAG mapping). See Conformance for how these roll up. The list is not WCAG-only. When the scan asks for another standard (engineOptions.mappings, or a standard's profile), each WCAG criterion is followed by that standard's corresponding requirement, such as the EN 301 549 clause that restates it ({ standard: "EN 301 549", version: "V3.2.1" | "V4.1.1", requirement: "9.1.1.1", title, wcagSc: ["1.1.1"] }, one entry per version that includes the criterion); and a rule may also cite WCAG's Understanding documents (type: "Understanding"). Filter on standard (and on the absence of type) before reading requirement as a Success Criterion. A composite's WCAG entry is always first. See EN 301 549.
  • rollupIds lists the composites in rulesResults that group this rule in this run, those reported because the selection named the rule on its own included. An empty list means the rule's findings appear in no rollup, so a consumer that reads only rulesResults never sees them; heading-order, for instance, belongs to no WCAG rollup.
  • wcagVersionScope: only present when the run's target WCAG version turned this rule's fail into a cantTell — today that means a rule mapped to SC 4.1.1 Parsing (duplicate-id): under 2.2, which removed that criterion, and under 2.0 and 2.1, whose 2023 errata count it as always satisfied for HTML and XML (by: "errata"). A profile whose standard keeps 4.1.1's text (en301549-v3.2.1, or a pack's profile with keepsParsing) is not coerced. removedSc lists the criteria concerned, target is the run's version, and coercedFrom is the outcome the rule itself reported. The occurrences are the rule's own, unchanged — nothing was dropped, only the conformance verdict was. Absent on every other result, and never reported through error: nothing went wrong. See Filtering by WCAG version.
  • ancestorWalkLimit (core 1.16.0 and later): the engine reads every ancestor of an element, through shadow roots and slots, to tell whether something around it hides it, up to 10,000 of them: far deeper than any browser renders. A fail on an element nested deeper is not certain, since an element the engine didn't read may hide it, so its occurrence becomes cantTell-tier, keeping its reason code, with uncertainty.code: "not-computable" and steps of the engine's own that ask whether the element is exposed. The rule's verdict on every other element stands, so the outcome stays fail while one fail-tier occurrence is left, and becomes cantTell otherwise (coercedFrom: "fail"). Like wcagVersionScope, it is not reported through error: the rule completed.
  • data: present only on a rule that reports something about the whole page, whatever its outcome. No core rule does today. Like data.details on an occurrence, it is not a stable contract.
  • margin: present only on a rule that declares one in the catalog (getChecksCatalog()[i].margin, { measure, unit, limit, band? }), when at least one element met the rule's threshold and, if the declaration has a band, came within it. band.relative is a fraction of the threshold: contrast-minimum, contrast-enhanced and link-in-text-block, with 0.1, report a margin only for a ratio within 10% of the one it needed (4.5 to 4.95 against 4.5:1, 3 to 3.3 against 3:1). band.absolute is an amount in unit: target-size-minimum, with 6, reports a target only up to 30 px, and text-spacing-content-loss, with 8, a line only from 6 px inside its box's edge. Text at 21:1 or a 48 px button is not close to anything, and no margin says so. It names the element that came closest to the threshold while meeting it, so a pass at 4.52:1 against 4.5:1, or 0.4 px from cutting off text, can be told apart from a comfortable one. It sits beside the outcome and is not a finding: it never adds, removes or changes an occurrence, and baselines, SARIF, JUnit and EARL ignore it. It is reported on fail and cantTell results too when some elements passed. limit is min when the value must reach the threshold (contrast, size) and max when it must stay under it (overflow); headroom is how far inside, in unit, so value - threshold for min and threshold - value for max, never negative. threshold is the one that element was judged against, which can differ between elements (3:1 for large text). Pixels are given to one decimal: CSS pixels and thresholds such as half a 15px font are fractional, and a display rounds them for people. A ratio is not rounded, as WCAG does not round contrast. A tie goes to the element first in document order, so a page gives the same margin every time. measure and unit are open sets that may grow in a minor release; context is rule-specific and, like data.details, not a stable contract. getMargins(result) returns every margin in a result as [{ ruleId, ...margin }], sorted by ruleId; of a cross-frame result, every frame's, a child frame's with frame, the path to it.
  • error: present when the rule did not complete, or when the engine changed or ignored part of what the rule returned. A rule that threw (or returned something that is not a result) always surfaces as outcome: "cantTell" with occurrences: [] and error set to the exception message, so cantTell with no occurrences and an error is how to tell a rule that did not complete; the engine never lets one broken rule crash the whole scan. Otherwise error holds the engine's notes, joined by | after any error the rule returned itself: the manual-fail coercion above, an outcome or severity outside its set, a type that differs from its meta's, occurrences that are not objects (left out), a policyOutcome or ruleSeverity the rule returned (not taken: the engine sets both), and an uncertainty.code outside the closed set. Those results keep their occurrences: the rule completed. (Before 1.16.0, a fail that the 200-step ancestor walk stopped short of was noted here too; see ancestorWalkLimit above.)
  • policyOutcome: present when the policy's allowedOutcomes doesn't allow the outcome the rule found. The result is then cantTell, with the rule's own outcome here and no error, so the reporters don't read it as a rule that did not complete.
  • engineOptions on each result is the resolved options object (after locale/contrast defaults were applied), not literally what you passed in — useful for confirming what a given rule actually saw, especially the resolved locale and contrast.mode/contrast.rootCanvasFallback. contrast.rootCanvasFallback is white when the caller gave none, and contrast.rootCanvasFallbackSet says which: true for a color the caller gave, false for the default. It leaves out messages, the dictionaries the run translated with (a locale side file in the browser bundle, or your own strings): they are data, not a setting, and a copy on every result made a scan tens of megabytes. engine.locale says which dictionary was used. Three more options are echoed as the rules saw them, so the result stays plain data that JSON.stringify and structuredClone (postMessage) can carry: probes as the capped copy rules read, customRules as [{ id }], one entry per custom rule with an id, without its functions, and styleSheetTexts as [{ href }], one entry per sheet given, without its text, since a page's CSS repeated on every result multiplied it by the number of rules.

An occurrence (occurrences[i])

A fail always has at least one: a custom rule's fail that names no element gets one on the document element (selector: "html", reason code FAIL_WITHOUT_OCCURRENCE). Normally present only when outcome is fail or cantTell: a pass result has occurrences: [], since this engine does not enumerate the elements it passed, only the ones it flagged. The contrast rules are the exception: their pass carries one occurrence describing the scan (how much text was eligible and computable), with an empty selector, so a pass over no text can be told apart from a pass over a page of it.

A notApplicable from core names no occurrence. When the contrast rules are notApplicable because no text had a computable background, they say how much text they looked at in the result's own data.details (eligibleTextCount, computableTextCount), and contrast-computable reports each text it could not measure as cantTell: the difference between "checked, nothing to flag" and "could not check" is one this engine reports rather than hides. A pack's or custom rule that had nothing to judge may still attach a single occurrence saying why; such an occurrence describes the scan, not an element, so its selector is empty. Do not read occurrences.length as a violation count without checking outcome first.

{
selector: string,
html: string,
structuralPath: number[] | null,
shadowHostSelectors?: string[],
summary: string,
hint: string,
i18n: { summaryKey: string, hintKey: string, params: object } | null,
occurrenceOutcome?: "fail" | "cantTell", // present when the rule graded its findings into tiers
verdicts?: { [profile: string]: string }, // a scan of several profiles only
uncertainty?: { // present only on a cantTell-tier occurrence
code: "not-computable" | "runtime-dependent" | "spec-only"
| "equivalence-unknown" | "judgement-required" | "out-of-scope",
needed?: string, // what would settle the question
evidence?: object // what the rule did establish, rule-specific
},
lean?: { // present only on a cantTell-tier occurrence, and only with evidence
toward: "pass" | "fail",
why: string, // localized: what in the page points that way
signals: { signal: string, value: string | number | boolean | null }[],
caution?: string, // localized: what could make it wrong
measured?: { agreed: number, cases: number, corpus?: string, coreVersion?: string },
i18n?: { whyKey?: string, cautionKey?: string, params?: object }
},
howToCheck?: { // present only on a cantTell-tier occurrence, when the rule says
steps: string[], // localized: what a person does, in order
passWhen: string, // localized: what they see if it passes
failWhen: string // localized: what they see if it fails
},
data?: { // absent on manual-review's page-level finding
visibilityFilter?: { eligible?: boolean, targetSet: string, accEligible: boolean | null, reasons: string[] },
details?: object // rule-specific, non-normative — see below
}
}
FieldMeaning
selectorA best-effort CSS selector built to resolve back to the flagged element (see helpers.buildSelector in RULE_AUTHORING.md). An id, test id, name or aria-label is used as the anchor only when no other element in the document has it, hidden, excluded and out-of-scope elements included, since querySelector resolves it against the whole document; otherwise the selector is a path. Not guaranteed unique in every DOM shape: an element in a shadow root is named within its own tree (structuralPath places it), and a very deep element's path is shortened from the top. On 255 test and real pages, 15 of 39,857 selectors did not name exactly their element, all of those kinds. A value longer than 256 characters is no anchor either, so a selector stays short whatever the page puts in an attribute, and a control character in a value is escaped as CSS reads it (\a for a newline). The exception is a rule whose finding is an absent element: page-title-present reports a missing <title> with the selector of <head>, where it belongs (<html> without a <head>), and an html of <title>(missing)</title>, which is not on the page. That html is a constant, so the fingerprint it feeds stays stable, but do not treat it as real markup without checking the rule reported something that exists. For an element in a shadow tree, selector resolves inside its shadow root, and shadowHostSelectors says how to get there.
shadowHostSelectorsPresent only for an element inside a shadow tree: the selectors of the shadow hosts that lead to it, outermost first, each resolved in the tree that holds it, so document.querySelector(s[0]).shadowRoot.querySelector(s[1])…shadowRoot.querySelector(selector) finds the element. Two components with the same content give the same selector and differ here. Like selector, it is not part of a finding's identity. The HTML report, SARIF (logicalLocations), JUnit and baseline entries show the two as one location, the hosts and then the selector joined with >>> (#card >>> img).
htmlAn outer-HTML snippet of the flagged element, cut at 2,000 characters — useful to show the element, and for an element in a shadow tree read beside shadowHostSelectors. For <html>, <head> and <body>, which hold the whole page, it is the start tag alone (<html class="x">), so a page-level finding keeps its identity when other content changes.
structuralPathThe flagged element's sibling-index path from documentElement down to it (e.g. [1, 0, 2]) — [] if the element is documentElement, null if it couldn't be determined, which includes every element inside a shadow tree (use shadowHostSelectors and selector there). A more robust element-identity mechanism than selector alone: it survives DOM changes a selector string wouldn't (an id/class rename, for instance), at the cost of not being usable as an actual CSS selector.
summaryHuman-readable, already localized ("This button has no accessible name.").
hintHuman-readable remediation guidance, already localized.
i18nThe raw translation keys behind summary/hint, if you want to re-render them in a different locale yourself without re-running the scan. null if the occurrence didn't use key-based i18n.
data.visibilityFilterPresent on most occurrences: why the engine considered this element eligible (or not) under whichever eligibility model the rule used. eligible is that result (absent on the page-level findings of bypass-blocks-present, html-lang-attr-present and page-title-present); targetSet says which model produced it ('dom': raw DOM/CSS visibility — most rules; 'acc': accessibility-tree eligibility). accEligible mirrors eligible only when targetSet is 'acc', otherwise null. reasons is a list of machine-readable exclusion codes when eligible: false.
data.detailsRule-specific structured data (computed metrics, resolved references) — non-normative: useful for building richer UI or debugging, but never changes what outcome/severity mean. Shape varies per rule; treat as best-effort extra context, not a stable contract. The one exception is data.details.reasonCode, which is stable: it identifies which of a rule's findings this is, and together with ruleId and html forms the fingerprint baselines and SARIF are keyed on. A rule may gain a new reason code in a minor release, or split one by cause; see API_STABILITY.md. The size and ratio fields of a contrast failure (metrics.ratio, metrics.threshold, and typography's fontSizePx, fontSizePt, fontWeight, isBold and isLargeText) are stable too, so a report or pack can tell which kind of text failed, and so are element in list-children-valid findings, parentElement in listitem-parent-valid findings and role in aria-role-name-present findings, which say which kind of list or widget failed. Since core 1.16.0, so are the same typography fields on a contrast-computable finding (the text whose contrast could not be computed; isLargeText by core's definition, 24px, or 14pt and bold), linkKind in link-name-quality and identical-links-same-purpose findings (what the link is made of: text, image, composite or svg), and messageKind in live-region-announcements findings (the kind of message the region declares it carries, read from its role and live-region attributes, never from its text: status, alert, log, progress or unknown). linkKind and messageKind may gain values in a minor release. So is data.details.evidenceState (core 1.15.0 and later): on a finding that shows only in a state the rule put the page in and took it out of before the browser painted, it names that state (text-spacing, on the findings of text-spacing-content-loss about cut-off, overlapping or scroll-locked text), so a tool can put the page in it again with evidenceState() to picture the finding, as the bindings' .screenshots() do. Each rule's page lists the fields its findings carry, under “What a finding reports”.
occurrenceOutcomeWhich tier this occurrence belongs to, on a rule that graded its findings into a confident fail tier and a needs-review cantTell tier. A rule reporting one tier only omits it, in which case the result's own outcome is the occurrence's tier. occurrenceOutcome(check, occurrence), from @surea11y/core, gives the tier as every reporter reads it, either way; since core 1.16.0 it gives 'pass' for an occurrence of a passing check, where it gave null. The outcome always agrees with the tiers: any fail-tier occurrence makes it fail, and a fail whose occurrences are all cantTell-tier is cantTell, so SARIF, JUnit, EARL and baselines give one answer. The engine settles a custom or pack rule that returns them at odds; under the default policy, a manual rule is never fail. The reporters read a result stored before the engine did this by the same rule, under the policy its engineOptions record, and roll its WCAG rollups up again from what they read.
uncertaintyWhy this finding could not be decided — see Uncertainty codes below.
leanWhich way this undecided finding points, when the page gives evidence for one — see Leans below.
howToCheckHow a person answers this undecided finding — see How to check below.

Uncertainty codes

A cantTell says the engine did not decide. uncertainty says why, from a closed vocabulary, so a consumer can branch on the reason rather than parse a summary string. It is present only on a cantTell-tier occurrence: a fail-tier one would be claiming the rule both decided and did not, so the engine drops it. An uncertainty whose code is not one of the codes below is left out too, and the rule's error names the code.

codeMeaningTypical shape
not-computableThe evidence the rule needed could not be read in this environment.A cross-origin stylesheet, a background color that resolves to no value, an src that will not resolve.
runtime-dependentThe markup cannot settle it because script decides at runtime.An aria-controls naming an element the widget builds when it opens.
spec-onlyA real specification violation, but the exposed name, role and value survive it, so no Success Criterion is established as failed.An ARIA attribute whose absence the specification supplies a default for.
equivalence-unknownTwo things may or may not serve the same purpose, and neither the markup nor the content settles it.Two frames sharing an accessible name but embedding different resources.
judgement-requiredThe question is inherently a human call.Whether an undersized target is essential; every type: "manual" rule.
out-of-scopeThe finding is real but falls outside the standard this run targets.A rule mapped only to a criterion the target WCAG version removed.

needed states, in one sentence, what would settle the question — the thing a reviewer has to go and check. evidence carries what the rule did establish, so the reviewer starts from the engine's work rather than repeating it; its shape is rule-specific and, like data.details, not a stable contract. The code is: new codes may be added in a minor release, but an existing one does not change meaning, so branch on the codes you know and treat an unrecognised one as "needs review" rather than an error.

Every automatic rule that can report cantTell carries this, and a test holds that line so a new one cannot arrive without it. The out-of-scope code is attached by the engine rather than by a rule, on the same occurrences that produce a result-level wcagVersionScope. Manual rules do not carry it: judgement-required is what type: "manual" already means, so repeating it per occurrence would say nothing the result does not.

Leans

A cantTell can still point one way. lean says which, when the page gives evidence for it: toward is the answer that evidence makes likely, why says what the evidence is, and signals lists the facts the rule read, each a kebab-case name and a plain value, so a tool can show or weigh them. caution says what could make the lean wrong, when that isn't plain from uncertainty.needed. The outcome stays cantTell: a lean is advice, not a verdict, and nothing that reads outcomes or tiers changes because of it. LEANS.md is the guide for people who read reports and build tools: which findings lean and why, how a lean is measured, and how far to trust one.

lean is present only where the rule has evidence. Its absence is information too: only a person can judge this one. It is present only on a cantTell-tier occurrence, as uncertainty is; the engine drops one on a decided occurrence, and drops a malformed one (a toward other than pass or fail, or no why) without touching the finding. measured appears once a lean has been counted against at least five cases with known answers: how many times it was right (agreed) out of how many (cases), which corpus they came from (corpus, with the date it was read) and which core counted them (coreVersion). The counts are per rule, reason code and direction, from the W3C ACT Rules test cases, and the engine carries them, so a result from any entry point has them. A lean never counted, or counted on fewer cases, has none; there is no confidence number. None has yet: the first count, in jsdom, saw 9 leans, none on five cases.

why and caution are in the scan's locale; i18n holds their keys and parameters, as an occurrence's i18n does for summary and hint. The engine itself leans one finding: a rule mapped only to a criterion the target WCAG version removed, or that the 2023 errata count as satisfied, leans pass beside its out-of-scope uncertainty.

reviewGroupOf(check, occurrence), from @surea11y/core, says where an occurrence goes in a review: 'fail', 'leanFail', 'leanPass' or 'open' (only a person can judge), the groups of the HTML report. leaningFailures(result, { wcagOnly }) lists the findings that lean fail, those a gate with leanFailAs counts (JUnit, SARIF).

New leans arrive in minor releases, rule by rule, and a lean may be removed if measuring shows it is wrong too often: treat a lean as present-or-absent advice, never as part of a finding's identity. It does not enter the fingerprint.

A standard's mapping routes findings by their data.details (see Packs), and a lean sits outside them, so skip-link copies its lean's direction there as well (core 1.16.0, experimental until 1.18.0): data.details.lean is fail or pass, the same as lean.toward, and is absent when the finding has no lean.

How to check

A cantTell hands a question to a person. howToCheck tells them how to answer it: steps, a few actions in order, starting from the element the finding names (“Tab to the element.”, “Watch whether the focus indicator stays on it.”), then passWhen and failWhen, what they see if it passes and if it fails, naming the criterion it fails. Core has these for each reason code of every core rule that can report cantTell, kept with the procedures for assessing each criterion by hand (published as the review dictionaries, @surea11y/core/review/<locale>), and each cantTell-tier occurrence gets its code's entry, in the scan's locale. A variant's finding (variantOf) gets its base rule's where it has none of its own. A pack rule may write them too, in English, and its pack's dictionaries may translate them (see Packs); its findings get them when the packs are known, as runDomRulesInPage with engineOptions.packs and addHowToCheck(result, { packs }) know them. It is absent on a decided occurrence, on a custom rule's finding, on an override's (a custom or pack rule in overriddenBuiltinIds), and on a pack rule's that writes none.

The steps are added after the scan, in Node, not by the engine in the page: the engine is injected into every page and frame a scan opens, and has no use for a thousand sentences no rule reads. runDomRulesInPage adds them to its result. A result from runa11yCoreInPage run in a page (a binding's, an extension's) comes back without them; addHowToCheck(result) returns a copy with them, and howToCheckFor(ruleId, reasonCode, locale) gives one finding's. Both read core's review dictionaries from disk, so they need Node; a binding whose code runs in a browser (Cypress) calls applyHowToCheck(result, { en, fr }) from @surea11y/core/how-to-check with the dictionaries it imports from @surea11y/core/review/<locale>, and gets the same steps (BINDING_AUTHORS_GUIDE.md shows how a binding does it). The HTML report (renderHtmlReport) adds them in Node to a result that comes without them; in the browser (surea11y.report.js) it shows those the result carries. A tool that shows a result in another language than the scan's passes { locale } to addHowToCheck or applyHowToCheck (addHowToCheck(result, { locale: 'de' }), or a list of locales, most wanted first; renderHtmlReport takes none): the steps are given in it, those already there included, with English for any step the locale lacks.

The steps assume a browser, its developer tools (the accessibility tree), a keyboard and, for some, a screen reader. Unlike uncertainty.needed, which says in one sentence what would settle the question, they say how to settle it. Like a lean, they are guidance, not part of a finding's identity, and their wording may improve in any release.

Steps that name the case. Since core 1.15.0 a step, passWhen or failWhen may name what its finding found: {{name|text}} reads as the finding's value for name, from its i18n.params or its data.details, and as text where it has none. So a step “Open the popup, which aria-haspopup says is a {{expectedRole|menu}}” reads “…is a listbox” on a finding whose details give expectedRole: 'listbox', and “…is a menu” in howToCheckFor, which has no finding, and on a procedures page. A value is one line of at most 120 characters, and comes from the page: show it as text, as the HTML report does. howToCheckFor(ruleId, code, locale, { values }) fills them from the values given.

A standard's own words. A pack's standard may give its own steps for a core rule's code (standard.review): a finding that only the standard's profiles ask about reads them in place of core's, in English, so a WAI-ARIA scan's question names WAI-ARIA, not WCAG 4.1.2. Where a WCAG profile asks too, in a scan of several, the finding reads core's.

A composite result (rulesResults[i])

Composites roll multiple atomic rules up to one WCAG Success Criterion (e.g. wcag-1.1.1-non-text-content rolls up 21 atomic rules). Under the profile of a standard with rollups of its own, there are also those, with the standard's wording as title and its name as meta.standard. Shape is the same envelope as a check result, with composite-specific data.details:

{
ruleId: string, // e.g. "wcag-1.1.1-non-text-content"
outcome: "pass" | "fail" | "cantTell" | "notApplicable",
severity, confidence, type, title, description, meta, engineOptions, schemaVersion, // as on a check result, except that
// meta.normativeMappings names the criterion in a shorter form:
// { standard, requirement, level }
occurrences: [], // always empty — composites are rollups, not element-level findings
data: {
details: {
reasonCode: string, // e.g. "composite.rollup.fail.anyFail"
standard?: string, // a standard's own rollup only, with version and criterion
version?: string,
criterion?: string,
checksIds: string[], // every atomic ruleId it rolls up, custom rules mapped to its criteria included
customChecksIds?: string[], // the custom rules among them; absent when none
restatesWcag?: string[], // a standard's rollup that restates WCAG criteria (applicability: 'wcag'): those criteria
partial?: true, // a standard's requirement its rules don't check in full (partial: true)
contributors: Array<{ testId: string, outcome: string, severity?: string | null, custom?: true, notSelected?: true,
byReasonCode?: true, checkOutcome?: string, requirements?: string[] }>,
metrics: { failCount, cantTellCount, notApplicableCount, passCount, missingCount }
}
}
}

Rollup precedence (deterministic, in this order): any contributor fail → composite fail; else any cantTell (or a contributor rule that didn't run at all, missingCount > 0) → composite cantTell; else all contributors notApplicable → composite notApplicable, where the rules find every case the criterion applies to, and cantTell (partialCoverage) otherwise; else pass, where the rules check all the criterion asks, and cantTell (partialCheck) otherwise. No WCAG criterion is yet listed as checked in full, so no WCAG rollup passes today. See Conformance for what this means for an overall conformance claim.

A standard's own rollup can judge a rule by its findings rather than whole (see Packs): its contributor then says byReasonCode: true, outcome is the rule's outcome in this rollup (a fail whose findings all belong elsewhere is a pass here) and checkOutcome the rule's own. When the findings that count toward the rollup name which of its requirements they belong to, requirements lists them, and the rollup's meta.normativeMappings names those for that rule, not every requirement the rule checks.

A WCAG rollup's rules are its own list and every rule of engineOptions.customRules that ran and maps to one of its criteria (through meta.wcagSc or a WCAG entry in meta.normativeMappings), marked custom: true among the contributors and listed in customChecksIds, so a reader sees when a custom rule decided the outcome. A standard's own rollups keep their lists.

A partly checked criterion. A scan that names rules rather than criteria (includeRuleIds: ['link-name-present']) also gets the rollups of the criteria those rules check, when the selection doesn't select them itself (see Which criteria a scan reports). The precedence above decides them as any other, so a failing rule fails the criterion, and rules the scan didn't select keep it from passing. Each of those is a contributor with outcome: "missing" and notSelected: true, counted in missingCount as before. When they are what makes the rollup cantTell, its reasonCode is composite.rollup.cantTell.notSelectedChild, and the HTML report says “Partly checked: 1 of 2 checks was not selected for this scan”. composite.rollup.cantTell.missingChild keeps meaning a rule that didn't run for another reason, such as one the profile excludes (engine.profileExcludes), which is never marked notSelected.

reasonCodeOutcomeWhen
composite.rollup.fail.anyFailfailA contributor failed.
composite.rollup.cantTell.anyCantTellcantTellNone failed, and one is cantTell.
composite.rollup.cantTell.notSelectedChildcantTellNone failed or is cantTell, and some didn't run because the scan didn't select them: partly checked.
composite.rollup.cantTell.missingChildcantTellNone failed or is cantTell, and some didn't run for another reason.
composite.rollup.notApplicable.allInapplicablenotApplicableEvery contributor is notApplicable, and they find every case the criterion applies to.
composite.rollup.cantTell.partialCoveragecantTellEvery contributor is notApplicable, but the criterion (or a standard's requirement marked partial) can apply to content they don't look for, so the page was not shown to have none.
composite.rollup.cantTell.emptyCompositecantTellThe rollup has no rules.
composite.rollup.cantTell.partialCheckcantTellNone failed or is cantTell, and not all are notApplicable, but the rules check only part of what the criterion (or a standard's requirement marked partial) asks: a person checks the rest.
composite.rollup.pass.otherwisepassOtherwise.

Outcome values

OutcomeMeaningCan appear on type: "manual"?
failThe rule reports that the requirement it checks is not met. Read its mapping, confidence and occurrences to interpret the finding.No, under the default policy (coerced to cantTell)
passThe rule's applicable target(s) exist and none were flagged.Yes
cantTellRequires human judgment — either genuinely ambiguous, or a manual rule's advisory finding.Yes
notApplicableThe rule found no elements it applies to on this page/scope.Yes

In core's built-in rules, fail is intentionally the narrowest, highest-bar outcome: reserved for deterministic, normative violations; chasing rule coverage must never dilute this.

Severity and confidence values

  • severity: minor < moderate < serious < critical — the rule author's assessment of user impact, independent of confidence. A profile can give a rule another severity (a profile's severity in a standard or a pack's checklist): under it the result shows the profile's, keeps the rule's own as ruleSeverity (present only then), and the rollups take the profile's. In a scan of several profiles, it is the highest severity any profile that fails or asks about the rule gives it (the highest of all where none does), whatever order the profiles are listed in.
  • confidence: low < medium < high — how certain the engine is that a fail/cantTell verdict is correct. Both are informational metadata for prioritization; neither changes outcome's meaning.

A fail is not always confidence: "high", and that is not a contradiction. The outcome describes the decision procedure — it resolved the question without guessing — while confidence describes the model that decision was made against. A handful of automatic rules decide deterministically against something that is itself an approximation (the curated WAI-ARIA role tables, the native-role mappings, an accessibility tree inferred from static markup) and report medium: aria-required-children, aria-prohibited-children, aria-required-parent, aria-allowed-attr, form-control-programmatic-label-present, identical-iframes-same-purpose, svg-image-text-alternative-present, video-poster-text-alternative-present, target-size-minimum and text-spacing-content-loss. confidence is on every result, so a consumer that wants only the most certain failures can gate on it directly; policy.allowedConfidence will not do it for you, since a disallowed value is replaced with the rule's own defaultConfidence rather than changing the outcome (see POLICY.md).

Worked example

Scanning <img src="logo.png"> (no alt) and <button></button> (no accessible name), scoped to just those two rules via runOnly: { includeRuleIds: [...] } (see Engine options; since 1.10.0 a bare array of rule ids does the same):

const result = runDomRulesInPage(
'https://example.test/',
null,
{},
{ includeRuleIds: ['img-alt-present', 'button-name-present'] }
);
{
"engine": {
"tag": "a11ycore",
"version": "1.14.0",
"schemaVersion": "1.0.0",
"locale": { "requested": "en", "resolved": "en", "reason": "ok" },
"wcagVersion": "2.2",
"environment": { "layout": false }
},
"url": "https://example.test/",
"title": "Example",
"timestamp": null,
"perfStats": null,
"contextSelector": null,
"contextMatch": null,
"checksResults": [
{
"ruleId": "button-name-present",
"outcome": "fail",
"severity": "serious",
"confidence": "high",
"type": "automatic",
"occurrences": [
{
"selector": "html > body > button",
"html": "<button></button>",
"structuralPath": [1, 1],
"summary": "This button has no accessible name.",
"hint": "Give the button visible text, or name it with aria-label, so screen reader users know what it does.",
"data": {
"visibilityFilter": { "eligible": true, "reasons": [], "targetSet": "acc", "accEligible": true },
"details": { "reasonCode": "name_missing" }
}
}
]
},
{
"ruleId": "img-alt-present",
"outcome": "fail",
"severity": "serious",
"confidence": "high",
"type": "automatic",
"occurrences": [
{
"selector": "html > body > img",
"html": "<img src=\"logo.png\">",
"structuralPath": [1, 0],
"summary": "This <img> has no alt attribute.",
"hint": "Add an alt attribute that says what the image shows, or alt=\"\" if the image is purely decorative.",
"data": {
"details": { "reasonCode": "TEXT_ALTERNATIVE_MISSING" },
"visibilityFilter": { "eligible": true, "reasons": [], "targetSet": "acc", "accEligible": true }
}
}
]
}
],
"rulesResults": [
{
"ruleId": "wcag-1.1.1-non-text-content",
"outcome": "fail",
"severity": "serious",
"title": "Non-text content: text alternatives",
"occurrences": [],
"data": {
"details": {
"reasonCode": "composite.rollup.fail.anyFail",
"checksIds": ["area-alt-present", "img-alt-decorative", "img-alt-quality", "..."],
"contributors": [
{ "testId": "area-alt-present", "outcome": "missing", "notSelected": true },
"...",
{ "testId": "img-alt-present", "outcome": "fail", "severity": "serious" },
"..."
],
"metrics": { "failCount": 1, "cantTellCount": 0, "notApplicableCount": 0, "passCount": 0, "missingCount": 20 }
}
}
},
{
"ruleId": "wcag-4.1.2-name",
"outcome": "fail",
"severity": "serious",
"title": "Name, role, value: accessible name",
"occurrences": [],
"data": {
"details": {
"reasonCode": "composite.rollup.fail.anyFail",
"metrics": { "failCount": 1, "cantTellCount": 0, "notApplicableCount": 0, "passCount": 0, "missingCount": 23 }
}
}
}
],
"overriddenBuiltinIds": [],
"skippedCustomRules": []
}

(Trimmed for readability — the real result also includes title/description/i18n/meta/engineOptions/schemaVersion on every entry, per the full shape above, and every contributor of each rollup. rulesResults holds the criteria the two named rules check: 1.1.1 for img-alt-present and 4.1.2 for button-name-present, each fail because its rule failed. The other rules of those criteria weren't selected, so they are missing with notSelected: true; with no failing rule, the criteria would be cantTell (notSelectedChild), partly checked. Each check result's rollupIds names its rollup.)