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,
schemaVersion: string,
locale: { requested: string, resolved: string, reason: string },
wcagVersion: "2.0" | "2.1" | "2.2"
},
url: string | null,
title: string | null,
timestamp: string | null,
perfStats: object | null,
contextSelector: string | string[] | null,
checksResults: CheckResult[],
rulesResults: CompositeResult[],
overriddenBuiltinIds: string[]
}
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.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 was conformance-tested against: your engineOptions.wcagVersion, or what your version-origin tags implied, or the default "2.2". It affects one thing today — a rule mapped only to SC 4.1.1 Parsing cannot fail under a 2.2 target (see checksResults[i].wcagVersionScope below). Reported once per result: a run has one target throughout.
urlThe pageUrl argument you passed in, or document.location.href if you passed null/omitted it, 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.
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 (WCAG-SC rollup) rule that ran — see A composite result and Conformance. Empty array if no composite matched the current runOnly/tag filter.
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.

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, topFrame: <top-level result>, frames: [...same shape, recursively] }
| { url: 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. 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.

A check result (checksResults[i])

{
ruleId: string,
outcome: "pass" | "fail" | "cantTell" | "notApplicable",
outcomeNormalized: "pass" | "fail" | "cantTell" | "inapplicable",
severity: "minor" | "moderate" | "serious" | "critical",
confidence: "high" | "medium" | "low",
type: "automatic" | "manual",
occurrences: Occurrence[],
title: string,
description: string,
i18n: { titleKey: string, descriptionKey: string } | null,
meta: {
ruleId: string,
ruleInterfaceVersion: string,
ruleVersion: string,
normative: boolean,
atomic: boolean,
deprecated: boolean,
deprecation: object | null,
category: "perceivable" | "operable" | "understandable" | "robust" | null,
normativeMappings: Array<{ standard: string, version: string, requirement: string, title: string, conformanceLevel: 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,
wcagVersionScope?: { // present only when the target WCAG version changed this outcome
target: "2.0" | "2.1" | "2.2",
removedSc: string[],
coercedFrom: "fail"
},
error?: string // present only if the rule threw — 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.
  • type: "manual" rules can never report outcome: "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. fail is reserved for deterministic, type: "automatic" findings only.
  • meta.normativeMappings is how a check result ties back to a WCAG Success Criterion — [] for rules with no formal WCAG mapping (this engine calls them advisory type: "manual" rules). See Conformance for how these roll up.
  • 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 the default 2.2 target, since 2.2 removed that criterion. removedSc lists the criteria that stopped existing, target is the version that removed them, 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.
  • error: only present in three cases — the rule implementation threw an uncaught exception, the manual-fail coercion above fired, or every occurrence of a fail result had its ancestor walk hit an internal depth limit (so the engine couldn't confirm the flagged content is actually exposed) and the whole result was coerced to cantTell the same way. A thrown rule always surfaces as outcome: "cantTell" with occurrences: [] and error set to the exception message — the engine never lets one broken rule crash the whole scan.
  • 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.

An occurrence (occurrences[i])

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.

notApplicable is the one exception. A rule that had nothing to judge may attach a single occurrence saying why, and the contrast rules do exactly that when no text had a computable background — the difference between "checked, nothing to flag" and "could not check" is one this engine reports rather than hides. 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,
summary: string,
hint: string,
i18n: { summaryKey: string, hintKey: string, params: object } | null,
occurrenceOutcome?: "fail" | "cantTell", // present when the rule graded its findings into tiers
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
},
data: {
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). Not guaranteed unique in adversarial DOM shapes, but the engine actively verifies it resolves to the reported element before using it. The exception is a rule whose finding is an absent element: page-title-present reports head > title with an html of <title>(missing)</title>, neither of which is on the page. Both are constants, so the fingerprint they feed stays stable, but do not treat selector as resolvable or html as real markup without checking the rule reported something that exists.
htmlAn outer-HTML snippet of the flagged element — use this as your primary "which element" signal when includeShadowDom: true (selectors don't pierce shadow boundaries).
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. 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; 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. See API_STABILITY.md.
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.
uncertaintyWhy this finding could not be decided — see Uncertainty codes 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.

codeMeaningTypical shape
not-computableThe evidence the rule needed could not be read in this environment.A cross-origin stylesheet, a background colour 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.

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). 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, // same as a check result
occurrences: [], // always empty — composites are rollups, not element-level findings
data: {
details: {
reasonCode: string, // e.g. "composite.rollup.fail.anyFail"
checksIds: string[], // every atomic ruleId this composite rolls up
contributors: Array<{ testId: string, outcome: string, severity: string | null }>,
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; else pass. See Conformance for what this means for an overall conformance claim.

Outcome values

OutcomeMeaningCan appear on type: "manual"?
failDeterministic, normative violation — the decision procedure guesses at nothing.No (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

fail is intentionally the narrowest, highest-bar outcome in this engine: 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.
  • 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 and target-size-minimum. 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 — this is not a bare array):

const result = runDomRulesInPage(
'https://example.test/',
null,
{},
{ includeRuleIds: ['img-alt-present', 'button-name-present'] }
);
{
"engine": {
"tag": "a11ycore",
"schemaVersion": "1.0.0",
"locale": { "requested": "en", "resolved": "en", "reason": "ok" }
},
"url": "https://example.test/",
"title": "Example",
"timestamp": null,
"perfStats": null,
"contextSelector": 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": "Provide visible button text or a programmatic accessible-name mechanism (for example aria-label) so assistive technologies can identify the button.",
"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": "Missing alt attribute on <img>.",
"hint": "Add an alt attribute (use alt=\"\" only for decorative images)."
}
]
}
],
"rulesResults": [],
"overriddenBuiltinIds": []
}

(Trimmed for readability — the real result also includes title/description/i18n/meta/engineOptions/schemaVersion on every entry, per the full shape above. rulesResults is empty here because runOnly.includeRuleIds scoped the scan to two atomic rules and no composite's own ID was included.)