Output schema reference
August 29, 2026 · View on GitHub
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
- Cross-frame result (
runa11yCoreAcrossFrames) - A check result (
checksResults[i]) - An occurrence (
occurrences[i]) - A composite result (
rulesResults[i]) - Outcome values
- Severity and confidence values
- Worked example
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[]
}
| Field | Meaning |
|---|---|
engine.tag | The 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.schemaVersion | The 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.locale | Which 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.wcagVersion | Which 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. |
url | The pageUrl argument you passed in, or document.location.href if you passed null/omitted it, or null if neither is available. |
title | document.title at scan time, or null. |
timestamp | Not 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. |
perfStats | null unless engineOptions.perfStats: true. Internal timing/counters — shape not covered by this document, treat as debug-only. |
contextSelector | The (trimmed) contextSelector argument you passed — a string, an array of strings (multi-region scanning, see ENGINE_OPTIONS.md), or null if none/empty. |
checksResults | One entry per atomic rule that ran (every rule not filtered out by runOnly — see ENGINE_OPTIONS.md). Every loaded rule produces an entry, even ones that outcome notApplicable — this is not a "violations only" list. |
rulesResults | One entry per composite (WCAG-SC rollup) rule that ran — see Composite result and WCAG_CONFORMANCE.md. Empty array if no composite matched the current runOnly/tag filter. |
overriddenBuiltinIds | Rule 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.md). 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 }
>
}
topFrameis exactly the top-level result shape, for the frame the function was called in.frameshas one entry per direct child<iframe>/<frame>in the scanned scope. A reachable child (one that calleda11yCoreEnableFrameResponder()) contributes its own complete{ url, topFrame, frames }— including its own nestedframes, 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/playwrightbinding's.frames(true), which can flatten because Playwright'spage.frames()already gives every frame regardless of nesting depth; apostMessagerelay 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,
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:
outcomevsoutcomeNormalized: identical exceptnotApplicablebecomes"inapplicable"inoutcomeNormalized. Both are provided so you can match either your own vocabulary or the engine's internal one.type: "manual"rules can never reportoutcome: "fail". If a manual rule's own logic would have saidfail, the engine coerces it tocantTelland appends an explanatory note toerror— this is enforced centrally (policy.coerceManualFailToCantTell, on by default under thea11ypolicy contract; seePOLICY.md), not something each rule has to remember.failis reserved for deterministic,type: "automatic"findings only.meta.normativeMappingsis how a check result ties back to a WCAG Success Criterion —[]for rules with no formal WCAG mapping (this engine calls them advisorytype: "manual"rules). SeeWCAG_CONFORMANCE.mdfor how these roll up.wcagVersionScope: only present when the run's target WCAG version turned this rule'sfailinto acantTell— 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.removedSclists the criteria that stopped existing,targetis the version that removed them, andcoercedFromis 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 througherror: nothing went wrong. SeeENGINE_OPTIONS.md.error: only present if the rule implementation threw an uncaught exception, or if the manual-fail coercion above fired. A thrown rule always surfaces asoutcome: "cantTell"withoccurrences: []anderrorset to the exception message — the engine never lets one broken rule crash the whole scan.engineOptionson 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 resolvedlocaleandcontrast.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
}
}
| Field | Meaning |
|---|---|
selector | A 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. |
html | An outer-HTML snippet of the flagged element — use this as your primary "which element" signal when includeShadowDom: true (selectors don't pierce shadow boundaries). |
structuralPath | The 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. Computed from the element reference when the rule kept one, otherwise by re-resolving selector against the document (same caveat as selector itself: a non-unique selector could resolve to a different element than intended). |
summary | Human-readable, already localized ("This button has no accessible name."). |
hint | Human-readable remediation guidance, already localized. |
i18n | The 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.visibilityFilter | Present 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 — don't read it as a second, independent signal. reasons is a list of machine-readable exclusion codes when eligible: false. |
data.details | Rule-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; a shipped one does not change. See API_STABILITY.md. |
occurrenceOutcome | Which 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. This is why a fail result can carry cantTell-tier occurrences: the aggregate outcome stays singular so CI can still gate on it, without discarding the findings that only warranted review. |
uncertainty | Why 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.
code | Meaning | Typical shape |
|---|---|---|
not-computable | The 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-dependent | The markup cannot settle it because script decides at runtime. | An aria-controls naming an element the widget builds when it opens. |
spec-only | A 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-unknown | Two 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-required | The question is inherently a human call. | Whether an undersized target is essential; every type: "manual" rule. |
out-of-scope | The 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 22 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 WCAG_CONFORMANCE.md for what this means for an overall conformance claim.
Outcome values
| Outcome | Meaning | Can appear on type: "manual"? |
|---|---|---|
fail | Deterministic, normative violation — the decision procedure guesses at nothing. | No (coerced to cantTell) |
pass | The rule's applicable target(s) exist and none were flagged. | Yes |
cantTell | Requires human judgment — either genuinely ambiguous, or a manual rule's advisory finding. | Yes |
notApplicable | The 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 ofconfidence.confidence:low<medium<high— how certain the engine is that afail/cantTellverdict is correct. Both are informational metadata for prioritization; neither changesoutcome'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, 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.md — 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.)