Engine options

Every runner (runDomRulesInPage, runa11yCoreInPage) takes the same four arguments: (pageUrl, contextSelector, engineOptions, runOnly). This page documents engineOptions and runOnly in full. To check a page against a WCAG version and level, start with runOnly.wcag.

Selecting which rules run

Name what you are testing against, and the engine picks the rules. With no selection, the default rules run: every rule except the opt-in rules of a standard or a pack, which run only once unlocked (see Opt-in rules).

To runPass
The default rules: every rule except opt-in ones not unlockednothing: runOnly null
A WCAG conformance target: the rules for the criteria of a version at a level and belowrunOnly: { wcag: { version: '2.2', level: 'AA' } }
The same, and the best-practice rules, which test no WCAG criterionrunOnly: { wcag: { version: '2.2', level: 'AA' }, bestPractices: true }
The best-practice rules onlyrunOnly: { bestPractices: true }
A WCAG version and level by nameengineOptions: { profile: 'wcag21-aa' }
A standard built on WCAG, from a pack (EN 301 549)engineOptions: { packs: [en301549], profile: 'en301549-v4.1.1' }
The same, and the best-practice rulesengineOptions: { packs: [en301549], profile: 'en301549-v4.1.1' }, runOnly: { bestPractices: true }
// WCAG 2.2 AA, with the best practices:
runDomRulesInPage(url, null, {}, {
wcag: { version: '2.2', level: 'AA' },
bestPractices: true
});

Every rule either tests WCAG criteria or is a best practice, so { wcag: { version: '2.2', level: 'AAA' }, bestPractices: true } runs every rule but those for 4.1.1 Parsing, which WCAG 2.2 removed. For anything finer, such as a single criterion, a list of rules, or an exclusion, see "Advanced selection: tags and rule ids"; those fields combine with the ones above. A scan that names rules reports the criteria they check too ("Which criteria a scan reports").

Selecting by WCAG target (runOnly.wcag)

runOnly.wcag names a WCAG version and conformance level, and selects the rules for that target's criteria:

// WCAG 2.2 Level A and AA:
runDomRulesInPage(url, null, {}, { wcag: { version: '2.2', level: 'AA' } });
  • version is '2.0', '2.1' or '2.2'; level is 'A', 'AA' or 'AAA' (in any case).
  • A rule is selected when one of the criteria it names is part of the target: in force in that version (introduced in it or before, and not removed in it or before), at the target's level or below, by the level the criterion has in that version. The levels come from WCAG_CRITERIA in @surea11y/core/wcag, which has a level per version.
  • So 4.1.1 Parsing, which 2.2 removed, is part of { version: '2.1' } targets and not of { version: '2.2' } ones: duplicate-id runs for the first and not for the second, where the version tags would run it.
  • A rule that names no criterion (a best-practice rule) is not part of any target. Add those with bestPractices: true (next section), or single rules with tags or includeRuleIds: beside wcag, all of them select rules as well, the union of the target's rules and what the others select. excludeTags, excludeRuleIds and excludeTestIds apply after, as always.
  • The target's version is the run's WCAG target (engine.wcagVersion), unless engineOptions.wcagVersion is set; its level decides which WCAG rollups are reported, as a level tag does, unless engineOptions.wcagLevel is set ("Giving the level of a scan that names checks").
  • A target includes rules, so it selects them instead of engineOptions.profile, as tags do. getProfileWcagTarget(profile) gives the target a profile comes to (see "Conformance profiles"), and getChecksForRunOnly(runOnly, engineOptions) lists the rules a runOnly with wcag runs, and getRulesForRunOnly(runOnly, engineOptions) the rollups it reports.
  • Anything else throws INVALID_RUN_ONLY, naming what is wrong: runOnly.wcag.version must be "2.0", "2.1" or "2.2", not "3.0".
// WCAG 2.1 AA, plus the best-practice rules, without one of them:
{ wcag: { version: '2.1', level: 'AA' }, tags: ['best-practice'], excludeRuleIds: ['region'] }

Adding the best practices (runOnly.bestPractices)

bestPractices: true selects the best-practice rules: the rules that name no WCAG criterion, such as region, landmark-one-main, heading-order, page-has-heading-one, skip-link, tabindex and image-redundant-alt. They are the rules tagged best-practice, so it is the same as adding that tag. A finding from one of them is good practice, not a WCAG failure: no WCAG rollup counts it. Most report cantTell; one whose finding is itself a barrier reports fail, as empty-heading does for a heading with no name.

  • Beside wcag, tags or rule ids, it adds those rules to what they select; excludes apply after, so excludeRuleIds: ['region'] still leaves region out.
  • On its own it selects the best-practice rules only.
  • false, or leaving it out, adds nothing. On its own, { bestPractices: false } is an empty selection, so the default rules run, as with {}.
  • Any other value throws INVALID_RUN_ONLY: runOnly.bestPractices must be true or false, not "yes".
  • Beside engineOptions.profile, it adds the best-practice rules to the profile's, and the profile still applies: its rules, its rollups, its mappings and engine.profile, as without it. { bestPractices: true } with profile: 'en301549-v4.1.1' runs that standard and the best practices. The best-practice rules count in none of the profile's rollups, as they test no WCAG criterion.
// WCAG 2.1 AA and the best practices, without one of them:
{ wcag: { version: '2.1', level: 'AA' }, bestPractices: true, excludeRuleIds: ['region'] }

Conformance profiles

engineOptions.profile names a conformance target instead of spelling out its tag set. Each WCAG version has a profile per level, named wcag<version>-<level>:

ProfileRuns the rules taggedWCAG targetgetProfileWcagTargetWhy
wcag20-awcag2a2.0{ version: '2.0', level: 'A' }WCAG 2.0 Level A
wcag20-aawcag2a, wcag2aa2.0{ version: '2.0', level: 'AA' }WCAG 2.0 Level A and AA
wcag20-aaawcag2a, wcag2aa, wcag2aaa2.0{ version: '2.0', level: 'AAA' }WCAG 2.0 Level A, AA and AAA
wcag21-awcag2a, wcag21a2.1{ version: '2.1', level: 'A' }WCAG 2.1 Level A
wcag21-aawcag2a, wcag2aa, wcag21a, wcag21aa2.1{ version: '2.1', level: 'AA' }WCAG 2.1 Level A and AA
wcag21-aaathe wcag21-aa tags and wcag2aaa, wcag21aaa2.1{ version: '2.1', level: 'AAA' }WCAG 2.1 Level A, AA and AAA
wcag22-awcag2a, wcag21a, wcag22a2.2{ version: '2.2', level: 'A' }WCAG 2.2 Level A
wcag22-aawcag2a, wcag2aa, wcag21a, wcag21aa, wcag22a, wcag22aa2.2{ version: '2.2', level: 'AA' }WCAG 2.2 Level A and AA
wcag22-aaathe wcag22-aa tags and wcag2aaa, wcag21aaa, wcag22aaa2.2{ version: '2.2', level: 'AAA' }WCAG 2.2 Level A, AA and AAA
en301549-v4.1.1same as wcag22-aa2.2{ version: '2.2', level: 'AA' }Deprecated since 1.13.0, removed in 2.0.0: use @surea11y/pack-en301549, or wcag22-aa for WCAG alone. EN 301 549 V4.1.1 chapter 9 restates WCAG 2.2 A and AA
en301549-v3.2.1same as wcag21-aa2.1{ version: '2.1', level: 'AA' }Deprecated since 1.13.0, removed in 2.0.0: use @surea11y/pack-en301549, or wcag21-aa for WCAG alone (which counts 4.1.1 Parsing as satisfied, as WCAG 2.1's errata do). EN 301 549 V3.2.1 chapter 9 restates WCAG 2.1 A and AA, including 4.1.1 Parsing
section508same as wcag20-aa2.0{ version: '2.0', level: 'AA' }Deprecated since 1.16.0, removed in 2.0.0: use wcag20-aa, which runs the same rules and gives the same results. A scan that names it says so once per process

getProfileWcagTarget(profile) gives the runOnly.wcag target a profile's tag set comes to, or null for a name that is no profile. Each WCAG level includes the ones below it, as WCAG's levels do: wcag22-aaa runs every rule of wcag22-aa and adds the AAA ones, so a scan names one level, not several. wcag22-a and wcag22-aaa are new in core 1.15.0, the WCAG 2.0 and 2.1 profiles in 1.16.0: each runs what runOnly: { wcag: { version, level } } runs, and unlike runOnly can be scanned beside a pack's profile. A profile still selects by its tags, as in the table: under wcag22-aa (and wcag22-a and wcag22-aaa), duplicate-id (4.1.1) runs and cannot fail, while { wcag: getProfileWcagTarget('wcag22-aa') } leaves it out.

runDomRulesInPage(url, null, { profile: 'wcag21-aa' }, null);
Deprecated

en301549-v4.1.1 and en301549-v3.2.1 move to EN 301 549's pack, @surea11y/pack-en301549, and leave core in 2.0.0. They work as before, with one console.warn per process. Given the pack (packs: [en301549]), a scan takes the same names from it and shows no notice; the pack's profiles report a result per clause in place of the WCAG rollups. section508 leaves core in 2.0.0 too, deprecated since 1.16.0: it was only WCAG 2.0 A and AA under another name, so use wcag20-aa, which runs the same rules. See how core deprecates a standard.

Core's own profiles are WCAG's: once the deprecated ones go in 2.0.0, every other standard's profile comes from a pack, whose standard or checklist brings its own profiles to this list.

Several profiles at once

profile also takes a list, with the packs that bring them in packs: one scan then judges the page by WCAG and by WAI-ARIA, by EN 301 549 and your own policy, or by two versions of one standard while you move from one to the other. Each profile judges the page as a scan of it alone would, and the result reads as one profile with the rules of all: a check's outcome is the worst its profiles give it, and each finding's verdicts say what each profile makes of it (Output schema). Core 1.15.0 and later.

const waiAria = require('@surea11y/pack-wai-aria');
 
const result = runDomRulesInPage(url, null, {
packs: [waiAria],
profile: ['wcag22-aa', 'wai-aria-1.2']
});
// <div role="group" aria-checked="true">: an attribute the role doesn't
// support, a question for WCAG 4.1.2 and a failure of WAI-ARIA.
result.checksResults.find((c) => c.ruleId === 'aria-allowed-attr').verdicts;
// { 'wcag22-aa': 'cantTell', 'wai-aria-1.2': 'fail' }

Each profile is scanned in turn, on the same page, so the scan takes about as long as one per profile. A profile that doesn't apply (no such profile, its pack skipped, or a runOnly include choosing the rules instead) is listed in engine.profilesNotRun with the reason, and the others still run; SARIF, JUnit, the CLI and the test matchers treat such a scan as an error, since it judged less than it was asked to. A list of one is its one name, and scans exactly as before. The WAI-ARIA pack is the case it was built for.

The WCAG target follows from the tags the same way it does for a hand-written set (see "Filtering by WCAG version" below), so en301549-v3.2.1 targets WCAG 2.1 and en301549-v4.1.1 WCAG 2.2. A duplicate id can still fail under en301549-v3.2.1 because its profile keeps 4.1.1's text (keepsParsing, see below); under en301549-v4.1.1 it cannot. A pack's profile can name its version instead, with wcagVersion (Packs): a profile that runs only the rules its standard maps has no WCAG tags to imply one. Names are matched case-insensitively. A run that used a profile reports it back as engine.profile.

Precedence: anything that includes rules selects them instead of the profile — a runOnly with wcag, tags, includeRuleIds or includeTestIds, or an include in engineOptions.rules/.tags/.tests. Excludes still apply on top of the profile, whether they come from runOnly (excludeTags, excludeRuleIds, excludeTestIds, so a binding's disableTags() narrows the profile rather than replacing it) or, when there is no runOnly filter, from engineOptions (tags.exclude, rules.exclude, tests.exclude), and an explicit engineOptions.wcagVersion still wins over the version the profile implies or names. A profile that does not take effect — an unknown name, or one overridden as above — is not an error: the run proceeds as if none was given, logs a console.warn saying why, and carries no engine.profile. runOnly.bestPractices: true is not an include of that kind: it adds the best-practice rules to the profile's, and the profile applies ("Adding the best practices").

A standard's profile may also leave rules out, when its standard waives a WCAG criterion or replaces a WCAG check with its own: the rules it names, the rules whose every criterion it waives, and the waived criteria's WCAG rollups do not run, and the rule catalog leaves them out too. A rule that also checks a criterion the profile keeps still runs (link-name-present, which checks 2.4.4 and 4.1.2, under a profile waiving 2.4.4), and engine.profileExcludes names what was left out. No built-in profile excludes anything today.

A profile only chooses which rules run. It says nothing about whether passing them meets the standard it is named after: most Success Criteria need human judgement no automated rule covers (see what this engine cannot tell you).

An EN 301 549 profile also switches on the EN 301 549 clauses of the version it targets, as if mappings: ['en301549:V4.1.1'] (or V3.2.1) had been passed; see "Other standards" below. Any standard's profile switches on its own version the same way. A profile with mappedRules also runs, whatever their tags, the rules its standard is mapped to: the ones with no WCAG mapping, such as heading-order and skip-link, have no WCAG tag to be selected by. A standard with requirements of its own, or an organisation's checklist, comes to the engine as a pack, bringing its own profile names, rules and rollups. The engine ships no such standard itself.

Opt-in rules

Some rules check a standard's own requirements, ones WCAG does not make: a national standard may require a doctype, or forbid presentational attributes such as bgcolor. A fail from such a rule is a failure of that standard, not of WCAG, so these rules are off by default. Each carries its standard's rule tag and runs only when the selection asks for it:

  • through the standard's profile, which lists the tag,
  • by that tag (tags: { include: '<tag>' }, alone or with others),
  • by its id (rules: { include: '<rule id>' }, or runOnly.includeRuleIds),
  • by the id of one of its standard's own rollups that groups it (rules: { include: '<rollup id>' } runs that rollup's rules, opt-in or not), or
  • by unlocking it with engineOptions.optInRules, below.

Nothing else selects one: not a default run, not a WCAG tag set, not a WCAG or EN 301 549 profile, not a WCAG rollup id. Excludes apply to them as to any rule. Selecting the tag alone runs that standard's own rules, not the WCAG rules it is also mapped to, so it answers "what does the standard require beyond WCAG" and is not an audit against it. For an audit, use the standard's profile: under a tag alone, its own rollups report cantTell for any requirement whose rules did not run. A rule added through customRules that carries the tag follows the same gate. None of the engine's built-in standards has opt-in rules today; a pack's namespace tag works this way.

Running every rule (optInRules)

To run every rule the engine has, whatever standard it belongs to, leave out the profile and unlock the opt-in rules:

runDomRulesInPage(url, null, { optInRules: 'all' }, null);

'all' unlocks every standard's opt-in rules; a list of tags unlocks only those. It lifts the gate and nothing else: the rest of the selection still decides which rules run. So with no filter and no profile, as above, the run covers every rule and every standard's rollups; under profile: 'wcag22-aa' it still runs the WCAG rules only, since opt-in rules carry no WCAG tag. Excludes apply as usual. A tag that is not an opt-in tag is ignored with a console.warn. Only this engine option unlocks: runOnly has no optInTags key, so one given there is an unknown key, which throws INVALID_RUN_ONLY on its own and is ignored with a warning beside keys runOnly reads, the message pointing to engineOptions.optInRules.

This is for seeing everything the engine can report, during development or when exploring a site. It is not a conformance target: such a run fails a page for things WCAG allows and only another standard forbids. The result says so: engine.optInRules lists the tags whose rules ran, and the HTML report, SARIF and JUnit show it next to the profile. It adds no other standard's numbers to results; pass mappings for that, for example { optInRules: 'all', mappings: ['<key>'] }.

Other standards (mappings)

Every result's meta.normativeMappings names the WCAG Success Criteria it tests. engineOptions.mappings adds the requirement of another standard that corresponds to each one. By default it adds none: a clause of a standard you do not audit against is noise in every SARIF tag, JUnit property and report.

ValueAdds
'en301549'The EN 301 549 chapter 9 clause for each criterion, in every version that has it (V3.2.1 and V4.1.1)
'en301549:V3.2.1', 'en301549:V4.1.1'The same, for that version only
runDomRulesInPage(url, null, { mappings: ['en301549:V3.2.1'] }, null);
Deprecated

The en301549 mappings move to EN 301 549's pack, @surea11y/pack-en301549, and leave core in 2.0.0. They work as before, with one console.warn per process. Given the pack, mappings: ['en301549'] takes the same name and gives every result the same entries, with no notice.

It takes an array or a comma-separated string, names and versions matched case-insensitively. Under profile: 'en301549-v4.1.1' the V4.1.1 clauses are there without asking, and mappings can add more. A name or version the engine has no table for is ignored with a console.warn. A run that carries any other standard reports which in engine.mappings, canonically spelled (["en301549:V3.2.1"], or ["en301549"] for every version).

It changes only what a result names, never which rules run or their outcomes. The rule catalog follows the same option: getChecksCatalog(engineOptions), getCheckDefById(ruleId, engineOptions), getChecksForRunOnly(runOnly, engineOptions), getRulesCatalog(engineOptions), getRulesForRunOnly(runOnly, engineOptions) and getCompositeRuleById(id, engineOptions) name the standards a scan with those options would, a profile included when it would apply, so a catalog entry and a result always agree. With no options they name WCAG only. A rule added through customRules keeps exactly the mappings it declares. What the clauses are and how the two versions differ is on WCAG mapping.

Advanced selection: tags and rule ids

The fields below select by the tags rules carry and by rule ids. Reach for them for what a WCAG target and bestPractices don't say: one criterion (tags: ['wcag1412']), a list of rules, an exclusion, or a tag set of your own. They combine with wcag and bestPractices: what they include is added to what those select, and what they exclude is taken away after.

There are two independent ways to give them — the 4th argument (runOnly), or engineOptions.rules/.tags/.tests/.includeMode. If runOnly contains any filter, it wins outright; otherwise the engine falls back to engineOptions. Don't mix them expecting both to apply — pick one. engineOptions.rules, .tags and .tests take { include, exclude }; a bare list or string is the include list.

Via runOnly (4th argument)

runDomRulesInPage(url, null, {}, {
includeRuleIds: ['img-alt-present', 'button-name-present'],
excludeRuleIds: ['region'],
tags: ['wcag412'],
excludeTags: ['best-practice'],
includeMode: 'and' // 'and' (default) | 'or' — see below
});

A bare array or string works as shorthand: runOnly: ['img-alt-present', 'button-name-present'] runs those rules (a composite id brings in its rules), and runOnly: ['wcag2a', 'wcag2aa'] is the same as { tags: ['wcag2a', 'wcag2aa'] }. An array can't mix rule ids and tags, and every value has to name a rule (built-in, composite or one of engineOptions.customRules) or a tag: anything else throws, so a typo can't quietly run every rule or none. Use the object form to combine ids and tags or to exclude. Before 1.10.0 a bare array was ignored and every rule ran.

FieldTypeMeaning
wcag{ version, level }A WCAG conformance target: the rules for the criteria of that version at that level and below. See Selecting by WCAG target, above.
bestPracticesbooleantrue adds the best-practice rules. See Adding the best practices, above.
includeRuleIdsstring[]Only run these rule IDs (plus, for a composite ID, its child atomic rules).
excludeRuleIdsstring[]Never run these, applied after include.
includeTestIds / excludeTestIdsstring[]Same matching as above — kept as a separate field because rules are internally called "tests" (the atomic executable unit); functionally identical to includeRuleIds/excludeRuleIds today.
tagsstring[]Only run rules carrying at least one of these tags (e.g. wcag412, wcag2aa, best-practice).
excludeTagsstring[]Never run rules carrying any of these tags, applied after include.
includeMode'and' | 'or'When both an ID include and a tag include are given: 'and' (default) requires a rule to satisfy both; 'or' runs a rule if it satisfies either. Irrelevant if you only use one dimension.

Each of these accepts either an array or a comma-separated string, matching the engineOptions form below — includeRuleIds: 'img-alt-present, button-name-present' and includeRuleIds: ['img-alt-present', 'button-name-present'] are equivalent.

Names are checked, here and in engineOptions.rules/.tags below. An include list (includeRuleIds, includeTestIds, tags, the legacy values, rules.include, tags.include) in which no value names a rule or a tag (one a rule or a rollup carries, so a standard's tag is known though only its rollups carry it) throws, with code: 'INVALID_RUN_ONLY' and a message naming the field and the values, since it would select nothing and a run of no rules reads as a clean pass. A selection whose names all exist but whose parts cancel each other (a rule both included and excluded, rules and tags with no rule in common under includeMode: 'and') also runs no rule: the scan warns, naming the parts, and throws INVALID_RUN_ONLY under strictOptions. includeMode is 'and' or 'or'; another value is read as 'and', with a warning, and throws under strictOptions; in a browser binding, .includeMode('or') sets it. A value that names nothing beside ones that do, or in an exclude list, is ignored with a console.warn. Before 1.10.0 every such value was ignored silently.

The nine WCAG version/level tags, wcag2a to wcag22aaa, are always known, whether or not a rule carries one. Some carry none today (wcag21aaa, wcag22a, wcag22aaa): their criteria have no automated rule, which is not a typo. In an include list beside other values, such a tag is noted with a console.info, not warned about:

[surea11y] runOnly.tags: no rules for WCAG 2.2 Level A ("wcag22a"); its criteria need manual review.

engineOptions.logUntestedWcag: false leaves that line out. When every value of an include list is such a tag, nothing would run, so it still throws INVALID_RUN_ONLY, saying why. Beside a runOnly.wcag target or bestPractices: true, which select rules of their own, it is only noted. In an exclude list it is left alone.

Rule IDs are bare (no engine prefix), e.g. 'img-alt-present'. For backward compatibility, matching also accepts a legacy a11ycore--prefixed form of the same id ('a11ycore-img-alt-present'). Ids are matched in any case, as tags are: 'IMG-ALT-PRESENT' selects img-alt-present, in every list that names rules (runOnly in each form, engineOptions.rules and engineOptions.tests, include and exclude). No two rules have ids that differ only in case, so this can't select the wrong one.

The { type, values } shape is also accepted as the whole runOnly value: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } is { tags: ['wcag2a', 'wcag2aa'] }, and { type: 'rule', values: ['img-alt-present'] } is { includeRuleIds: ['img-alt-present'] } ('tags' and 'rules' work too). values may be a comma-separated string, its names are checked like any rule ids or tags, and the other keys beside it (excludeTags, excludeRuleIds...) still apply. A Set of names is read as the list it holds. A key the object form doesn't read, beside ones it does, is ignored with a warning. Any other type throws INVALID_RUN_ONLY, and so does a runOnly that is a number or a boolean, or an object none of whose keys the engine reads ({ includeRuleId: [...] }): each used to run every rule. An empty array, string or object still means the default selection.

Which criteria a scan reports

A scan reports a criterion's rollup (rulesResults) when the selection selects it: a WCAG target or a version/level tag, a profile, the rollup's own id, or no selection at all. It also reports the rollup of each criterion that a rule the selection named on its own checks, when that rule ran:

  • by its id (includeRuleIds, includeTestIds, rules.include, or a profile's own rule list),
  • by a tag that is not a WCAG tag (a custom rule's, a pack's or a standard's; WCAG's tags are the version/level tags, the criterion tags such as wcag244, and wcag22-removed, so a custom tag such as wcagteam is not one),
  • by optInRules, or
  • as a best-practice rule (bestPractices).

A rule a standard's profile runs only because its standard maps it (mappedRules) is named for that standard, not on its own: it adds the standard's requirements and none of its WCAG criteria. So a profile that runs only what its standard asks for reports that standard alone (Packs); a rule its rules list names adds its WCAG criteria too. Before 1.12.0, a scan that named rules rather than criteria had an empty rulesResults.

// The unnamed link fails link-name-present, and with it 2.4.4 and 4.1.2:
runDomRulesInPage(url, null, {}, { includeRuleIds: ['link-name-present'] });
// rulesResults: wcag-2.4.4-link-purpose-in-context: fail, wcag-4.1.2-name: fail
  • The verdict is the rollup's own. A failing rule fails the criterion. A rule that passes doesn't make it pass when the scan left out the criterion's other rules: it is cantTell, with reasonCode composite.rollup.cantTell.notSelectedChild, and each rule the scan didn't select is listed among the contributors as missing with notSelected: true. With all of them selected and passing, a WCAG criterion is still cantTell (composite.rollup.cantTell.partialCheck), since no criterion's rules yet check all it asks (see WCAG conformance mapping). The HTML report shows "Partly checked: 1 of 2 checks was not selected for this scan" under it. See the output schema.
  • A criterion tag names its own criterion. tags: ['wcag244'] reports 2.4.4, and not the other criteria its rules check (4.1.2 for link-name-present). A rule brought in by a rollup's id, a WCAG target, a version or level tag, or a WCAG profile adds no criterion: the selection names the criteria there. So a scan that selects its criteria reports what it reported before.
  • The run's WCAG version and level apply. A criterion is added only if it is in force in engine.wcagVersion (duplicate-id alone adds no 4.1.1 under 2.2, and does under wcagVersion: '2.1'), and at or below the run's level, as a target would: engineOptions.wcagLevel when given (next section), else the level the selection gives (runOnly.wcag.level, or the version/level tags beside the rule ids). With neither, every level is reported.
  • Excludes apply. A rollup the selection excludes (excludeRuleIds: ['wcag-4.1.2-name'], or a tag) is not added. A rule the caller excluded counts as not selected. A rule a profile excludes narrows the criterion: it is missing without notSelected, and a criterion the profile waives is never added.
  • A standard's own rollups (one per requirement) are added the same way, but only while the standard is in play: the applied profile targets it, or the selection names its tag or optInRules unlocks it. A WCAG scan never shows the requirements of a standard nobody asked for. A profile that didn't apply, and mappings alone, don't put a standard in play. Under its profile, only the profile's version is added; unlocked without a profile, every version is, as a scan selected by its tag reports them. This holds for a standard or checklist from a pack as for one built in: a checklist profile that names its rules (tags: [], rules: [...]) gets their WCAG criteria and its own items.
  • The list before the scan. getRulesForRunOnly(runOnly, engineOptions) lists the rollups a scan with that selection and those options reports, the added ones included, as getRulesCatalog entries. It works them out with the scan's own rollup step, so the two agree; getChecksForRunOnly lists the rules. getRulesCatalog(engineOptions) lists every rollup the options make available, whatever the selection. A tool can record what a scan checks before running it; the browser bindings' builders give the same lists as plannedChecks() and plannedCriteria().

Giving the level of a scan that names checks (wcagLevel)

A scan meant as WCAG 2.2 AA that runs only some checks names them, and gives the version in engineOptions.wcagVersion. That says no level, so the criteria of those checks are reported at every level: contrast-computable checks 1.4.3 (AA) and 1.4.6 (AAA), and both are reported. engineOptions.wcagLevel gives the level:

// Only the contrast checks of a WCAG 2.2 AA scan: 1.4.3 is reported, 1.4.6 is not.
runDomRulesInPage(url, null,
{ wcagVersion: '2.2', wcagLevel: 'AA' },
{ includeRuleIds: ['contrast-computable'] });
  • Values. 'A', 'AA' or 'AAA', in any case. Another value is ignored with a console.warn (engineOptions.wcagLevel: ignoring "AA+"; use "A", "AA" or "AAA". ...), and throws INVALID_ENGINE_OPTIONS under strictOptions. The run reports a valid level as engine.wcagLevel, written 'A', 'AA' or 'AAA'; without the option, engine.wcagLevel is the level the selection implies, and absent when it implies none.
  • It decides what is reported, never what runs. The selection decides what runs, as without it. A check above the level that the selection names still runs and reports its own result; only its criteria above the level are left out of rulesResults.
  • It applies to every WCAG rollup, not only the criteria added for named checks. wcagLevel: 'AA' on a full scan, a profile or a tag selection leaves the AAA criteria out of rulesResults, while their checks still run and report in checksResults. This differs from wcagVersion, which never removes a rollup the selection selects: a 2.2 scan still reports 4.1.1, with its rules coerced to cantTell. A scan without the option reports what it reported before.
  • It wins over the level the selection implies (runOnly.wcag.level, or the highest level its version/level tags name), as an explicit wcagVersion wins over the version they imply. A conflict between the two is not warned about, as one between wcagVersion and a target's version isn't; engine.wcagLevel shows the level the criteria were gated by.
  • A rollup named by id is reported as named. includeRuleIds: ['wcag-1.4.6-contrast-enhanced'] with wcagLevel: 'AA' reports 1.4.6: the option says which criteria to report, not to drop one the caller asked for. A level the selection's own tags imply still gates a named rollup, as it does without the option.
  • A standard's own rollups have no WCAG level and are not gated by it. A pack's profile can't give a level (its tags imply one); its caller can pass wcagLevel.
  • getRulesForRunOnly(runOnly, engineOptions) honors it, so it lists what the scan reports, and warns about an invalid one as the scan does, or throws under strictOptions.

Without the option, version/level tags beside the rule ids give a level too, since the default includeMode: 'and' keeps the named rules that carry one of them: { includeRuleIds: ['contrast-computable'], tags: wcagTags('2.2') } runs contrast-computable and reports 1.4.3 alone. But the tags also decide what runs: a named rule with no WCAG tag (a best-practice rule such as region, a custom rule without WCAG tags), or one that checks only AAA criteria, is left out of the run, and a version/level tag no rule carries (wcag22a) is noted with a console.info. wcagLevel gives the level without changing what runs. In a browser binding, the builder's .wcagLevel('AA') sets it.

Filtering by WCAG version (2.0, 2.1, 2.2)

Every rule and composite carries exactly one WCAG-version-origin level tag: wcag2a/wcag2aa/wcag2aaa for a Success Criterion that's WCAG 2.0 baseline, wcag21a/wcag21aa/wcag21aaa for one newly introduced in WCAG 2.1 (e.g. 1.3.5 Identify Input Purpose), wcag22a/wcag22aa/wcag22aaa for one newly introduced in WCAG 2.2 (e.g. 2.5.8 Target Size Minimum). A rule gets only the tag for its SC's actual origin version — a 2.1-introduced SC is never also tagged wcag2aa, since it doesn't exist under a WCAG 2.0 conformance target.

runOnly.wcag selects a conformance target in one field (above), and leaves out 4.1.1 under 2.2. With tags, since versions are cumulative (2.1 = 2.0 + new; 2.2 = 2.0 + 2.1 + new), select a WCAG-version conformance target by combining tag sets — the engine's OR-matching on tags (any one match includes the rule) does the rest:

// WCAG 2.0 AA only (excludes every 2.1/2.2-introduced SC, even at level AA):
{ tags: ['wcag2a', 'wcag2aa'] }
 
// WCAG 2.1 AA conformance (2.0 baseline + everything 2.1 added, both at A and AA):
{ tags: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] }
 
// WCAG 2.2 AA conformance (2.0 baseline + 2.1 additions + 2.2 additions):
{ tags: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'] }
 
// Just the SCs 2.2 introduced, nothing else:
{ tags: ['wcag22a', 'wcag22aa', 'wcag22aaa'] }

One SC goes the other way. WCAG 2.2 removed SC 4.1.1 Parsing — the only criterion ever dropped rather than added. A rule mapped to it carries its 2.0-origin tag (wcag2a) like any other baseline rule, plus wcag22-removed, and the version tag sets above therefore include it under a 2.2 target, where it does not belong.

You do not have to do anything about that. The engine resolves a target WCAG version for every run and, when that target is 2.2, a wcag22-removed rule cannot report fail: it still runs, still reports every occurrence it found, but its outcome is coerced to cantTell and the result carries a wcagVersionScope field saying why (see the output schema). Nothing is silently dropped, and a 2.2 run is not gated by a criterion 2.2 does not contain.

Under a 2.0 or 2.1 target it is the same: W3C's 2023 errata to WCAG 2.0 and 2.1 count 4.1.1 as always satisfied for content in HTML or XML, so the rule asks rather than fails, and wcagVersionScope.by is "errata". The one exception is a profile whose standard keeps 4.1.1's text, such as en301549-v3.2.1 (keepsParsing in the standard's profile, see Packs): a duplicate id can still fail there. Core 1.14.0 and later; before, a 2.0 or 2.1 target let it fail.

The target version is resolved in this order:

  1. engineOptions.wcagVersion — '2.0', '2.1' or '2.2', if you set it.
  2. The wcagVersion of the pack profile the run applies, if it names one: a standard built on WCAG 2.1 whose profile runs only the rules it maps selects no WCAG tag, so it says the version itself. It wins over the version the profile's own tags imply. A profile that doesn't take effect (overridden by a selection of your own) brings no version.
  3. The version of a runOnly.wcag target.
  4. The version-origin tags in your own filter: a set topping out at wcag21a/wcag21aa reads as a 2.1 target, one containing any wcag22* tag as 2.2, one with only wcag2* tags as 2.0. Only those nine tags count — an SC tag (wcag411) or best-practice says nothing about a version.
  5. Otherwise '2.2', this engine's default target.
// Nothing to declare: a plain run already targets 2.2, so a duplicate id
// comes back cantTell rather than fail.
runDomRulesInPage(url, null, {}, null);
 
// Conformance-testing against 2.1, where SC 4.1.1 still exists:
runDomRulesInPage(url, null, { wcagVersion: '2.1' }, null);
 
// Same thing, implied by the tag set — no extra option needed:
runDomRulesInPage(url, null, {}, { tags: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] });

The resolved target is reported back on every result as engine.wcagVersion, so you can confirm which one a run actually used.

If you would rather not see the rule at all under 2.2, exclude it outright — the tag is still there for exactly that:

// WCAG 2.2 AA conformance, with the removed criterion left out entirely:
{
tags: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'],
excludeTags: ['wcag22-removed']
}

duplicate-id is the only rule carrying that tag today. Left in, it still reports something real — a duplicate id breaks <label for>, fragment links and getElementById whatever the standard says — it just is not a 2.2 conformance failure.

Via engineOptions (no runOnly)

Same filtering, expressed as comma-separated strings (or arrays) nested in engineOptions:

runDomRulesInPage(url, null, {
rules: { include: 'img-alt-present, button-name-present', exclude: 'region' },
tags: { include: 'wcag412', exclude: 'best-practice' },
includeMode: 'and'
}, null);

rules.include/.exclude, tags.include/.exclude, tests.include/.exclude (alias of rules), and top-level includeMode mirror the runOnly fields above exactly. Comma-separated strings are trimmed, de-duplicated, and empty tokens dropped automatically. A bare list or string, such as rules: ['img-alt-present'], is read as the include list.

engineOptions — the rest

const engineOptions = {
strictOptions: false, // default false — true throws on an unknown option or a value of the wrong type
locale: 'en', // default 'en'; de-DE falls back to de, then to en per string
wcagVersion: '2.2', // default '2.2' — the conformance target
wcagLevel: 'AA', // optional — the level of the criteria reported
profile: 'wcag22-aa', // optional — a named conformance target, or a list of them
mappings: ['en301549'], // optional — standards besides WCAG to name on each result
optInRules: 'all', // optional — also run rules other standards add beyond WCAG
messages: { de: { /* key: text */ } }, // optional caller-supplied dictionaries; win over built-in ones
includeHiddenElements: false, // default false — set true to evaluate hidden/collapsed subtrees too
includeShadowDom: true, // default true — opt OUT with false to skip open shadow roots
fragment: false, // default false — set true when the scan target isn't a real page
embeddedAriaHidden: false, // default false — a frame's document shown under aria-hidden="true"
embeddedInert: false, // default false — a frame's document shown under [inert]
topLevelDocument: true, // optional — unset, a document shown in a frame is no page of its own
assumeModal: false, // default false — under jsdom, take an open <dialog aria-modal="true"> as modal
excludeSelectors: ['#cookie-banner', '.third-party-widget'], // array or comma-separated string
styleSheetTexts: [{ href: 'https://cdn.example/site.css', text: '/* its CSS */' }], // sheets the page can't read
timestamp: '2026-07-20T12:00:00Z', // optional — engine has no built-in clock
perfStats: false, // default false — internal timing counters, debug-only shape
profileRules: false, // default false — per-rule timings; needs perfStats
 
contrast: {
mode: 'strictConformance', // 'strictConformance' (default) | 'auditorAssist'
rootCanvasFallback: '#ffffff', // a canvas to measure on in place of the page's own; unset, the page's color scheme gives it
rootCanvasFallbackSet: true // echoed on each result: whether rootCanvasFallback was given; read back, false means the default
},
visibilityMode: undefined, // 'styleOnly' | 'styleAndGeometry'; unset: geometry with a layout, else styles
 
policyContract: 'a11y', // 'a11y' (default) | 'generic' | inline contract object
policy: { // optional overrides on top of policyContract
coerceManualFailToCantTell: true
},
 
output: {
includeSelector: true, // set false to suppress auto-filled selectors
includeHtml: true,
detail: 'full' // 'findings': pass/notApplicable results keep only rule, outcome, type
},
 
rules: {
'some-rule-id': {
excludeSelectors: ['.some-noisy-widget'] // narrows candidates for THIS rule only
}
},
 
probes: { /* optional host-supplied evidence */ },
 
customRules: [ /* runtime-registered rules, see below */ ],
packs: [ /* rules and standards from outside core, see Packs */ ],
 
// Only read by runa11yCoreAcrossFrames. Ignored by runDomRulesInPage/runa11yCoreInPage.
pingWaitTime: 500, // ms to wait for a child frame to answer a ping
frameWaitTime: 60000 // ms to wait for a child frame's full scan result
};
OptionMeaning
strictOptionsDefault false. true checks every option before the scan, and throws INVALID_ENGINE_OPTIONS on a key the engine doesn't read or a value of the wrong type or outside its set; see "Catching mistakes in the options" below.
localeAny string. A code with a subtag falls back to its base language first, so de-DE uses de; failing that, English. Individual strings then fall back the same way (chosen locale → en → the rule's literal English text), so a partly-translated locale never produces missing text. All of that is silent in the strings themselves, so the result reports what actually happened in engine.locale — check it if you need to know whether you got the language you asked for. See Locales.
wcagVersion'2.0', '2.1' or '2.2' — which version of WCAG the run is conformance-testing against. Defaults to the version the run's pack profile names (its wcagVersion), else to whatever your version-origin tags imply, and to '2.2' when they imply nothing. What it changes is which criteria a scan reports and, for SC 4.1.1 Parsing, why a rule tagged wcag22-removed cannot fail: removed in 2.2, and always satisfied in 2.0 and 2.1 by their errata, except under a profile whose standard keeps it, such as en301549-v3.2.1 — see "Filtering by WCAG version" above. Any other value, of any type (the number 2.1, '3.0'), is ignored with a console.warn naming the version the run targets instead.
wcagLevelOptional: 'A', 'AA' or 'AAA', in any case — the WCAG level of the criteria a run reports. WCAG rollups above it are left out of rulesResults while their rules still run; it never changes what runs. It wins over the level the selection implies; a rollup named by id is reported as named. Reported back as engine.wcagLevel, which without the option holds the level the selection implies, if any. Any other value is ignored with a console.warn, and throws under strictOptions. See "Giving the level of a scan that names checks" above.
profileOptional named conformance target: 'wcag20-a', 'wcag20-aa', 'wcag20-aaa', 'wcag21-a', 'wcag21-aa', 'wcag21-aaa', 'wcag22-a', 'wcag22-aa', 'wcag22-aaa', the deprecated 'en301549-v4.1.1', 'en301549-v3.2.1' and 'section508', or a pack's own; or a list of them, for a scan of several profiles at once. Selects rules by the matching tag set when nothing else includes any, and the WCAG target follows from those tags. Reported back as engine.profile when it took effect (for a list, the first that ran, with every one in engine.profiles); otherwise ignored with a console warning. A name no profile has throws INVALID_ENGINE_OPTIONS under strictOptions, in a list too, with a message naming the profiles there are (also in the error's knownProfiles): without a profile, the scan would run every rule. See "Conformance profiles" above.
mappingsOptional standards besides WCAG whose requirements meta.normativeMappings names: 'en301549', 'en301549:V3.2.1' or 'en301549:V4.1.1', or a pack's standard's key, as an array or comma-separated string. Default none, so results name WCAG only; an EN 301 549 profile adds its own version. Reported back as engine.mappings when any applies. See "Other standards" above.
logUntestedWcagDefault true. false leaves out the console.info note for a WCAG version/level tag no rule carries (wcag22a), in an include list beside others; see "Via runOnly" above.
optInRulesOptional: 'all', or a list of opt-in rule tags, as an array or comma-separated string. Unlocks those rules outside their standard's profile; the rest of the selection still decides what runs. Reported back as engine.optInRules when it ran a rule the selection would not have run otherwise; unknown tags are ignored with a console warning. See "Running every rule" above.
messagesOptional { [locale]: { key: text } }. Checked before the engine's own tables, so it can override individual strings or supply a language the build does not carry. Keys you omit fall back normally, so a partial override is fine. This is how the standalone browser bundle receives a locale side file, and it is the only way to get a dictionary into a page context, since the in-page runner is serialized and cannot read files. It is not echoed back on each result's engineOptions, unlike the other options; engine.locale reports the dictionary used. See Locales.
includeHiddenElementsDefault false: helper queries exclude elements hidden by structural/CSS mechanisms such as display:none, [hidden], closed <details> (everything but its first <summary>, at any depth), content-visibility:hidden and hidden="until-found" content, content in an [inert] subtree (which rules about how content looks, such as contrast and text spacing, still judge, since it is drawn), and hidden rendering-only host elements (with descendants excluded too). runa11yCoreAcrossFrames does not scan a frame that is hidden this way. Set true to include those hidden/collapsed subtrees in evaluation (legacy/static-markup behavior). It widens what the shared queries return; most rules then judge only what is in the accessibility tree, since a hidden element has no name or role to check, so for them it changes nothing. It matters for rules about markup itself, such as a broken ID reference.
includeShadowDomDefault true: rules using helpers.queryAllSmart traverse into open shadow roots. Set false to scan only the light DOM. Closed shadow roots are never reachable either way — no DOM API exposes them: an element whose name could come from a custom element with no content the engine can read (a button, link, menu item, option, tab, tree item, summary or tooltip) is asked about (cantTell, name_closedContent), not failed.
fragmentDefault false. A handful of rules check for the presence of a property that exists once per real page — page-title-present, html-lang-attr-present, html-xml-lang-mismatch, aria-hidden-body, css-orientation-lock, meta-refresh-no-exceptions, meta-refresh-timing-absent, meta-viewport-zoom-enabled, meta-viewport-large, page-title-patterns, region, bypass-blocks-present, landmark-one-main, page-has-heading-one — and correctly report notApplicable for these once contextSelector has scoped a run narrower than the whole document (document.documentElement no longer among the resolved roots), since a scoped subtree was never expected to carry its own <title>/<html lang>/etc. Set fragment: true for the case that scoping alone can't detect: a scan target that's the whole given document but was never meant to represent a real page at all (e.g. a raw component snippet parsed on its own) — this forces the same notApplicable gating even when unscoped. A document shown in a frame is no page of its own either: page-title-present, page-title-patterns, html-lang-attr-present, meta-viewport-zoom-enabled, meta-viewport-large, region, bypass-blocks-present, landmark-one-main and page-has-heading-one are notApplicable in it whatever this option says, unless topLevelDocument: true (below) says it is the page. Core 1.15.0 and later.
embeddedAriaHiddenDefault false. true says the document is shown in a frame under aria-hidden="true" (on the <iframe> or an element around it): every element is read as aria-hidden content is, out of the accessibility tree but for what overrides that. Rules about names and roles (img-alt-present, button-name-present, …) then don't apply, and those about what is seen and operated (contrast, focus, keyboard) still do. runa11yCoreAcrossFrames sets it for such a frame, and a binding that scans frames itself sets it in that frame's engineOptions. The focus such a frame takes is the top page's aria-hidden-focus finding on the <iframe>. Core 1.15.0 and later.
embeddedInertDefault false. true says the document is shown in a frame under [inert]: every element is read as inert content is, out of the accessibility tree and taking no focus, so only the rules about how content looks (contrast, text spacing) judge it. runa11yCoreAcrossFrames sets it for such a frame, and leaves out a frame behind an open modal dialog, as it leaves out a hidden one; a binding that scans frames itself does the same. Core 1.15.0 and later.
topLevelDocumentUnset by default: whether the document is a page of its own. Unset, it is when it is the top window's: a document shown in a frame is no page of its own, and the nine rules about the page the fragment row names are notApplicable in it. A test runner that shows the page under test in a frame of its own, as Cypress does, sets it to true (@surea11y/cypress does it for you); false reads a top-level document as a frame's. runa11yCoreAcrossFrames doesn't pass it to the frames it scans. Core 1.15.0 and later.
assumeModalDefault false. Only a <dialog> opened with showModal() (one that matches :modal) is modal: the browser makes the rest of the page inert, and the engine leaves that page out of every rule and counts nothing in it as focusable. A dialog opened with the open attribute or show() is not modal, even with aria-modal="true", so the same markup gets the same verdicts in jsdom and in a browser. jsdom can't open a modal dialog (it has no showModal()), so a test there that stands in for showModal() by setting open and aria-modal="true" on the dialog sets assumeModal: true: such a dialog is then taken as modal, as releases before 1.16.0 took it under jsdom. A DOM that can open a modal dialog (any browser) ignores it. See Known limitations.
excludeSelectorsElements matching any of these selectors (and their descendants, including what is in their shadow roots, and the documents of frames among them) are skipped entirely, for every rule — useful for cookie banners, third-party embeds, or known-noisy widgets you don't control. To exclude something from just one specific rule instead, use rules[ruleId].excludeSelectors below. An array of selectors, or a string holding a selector list: '#cookie-banner, img:not(.a, .b)' is the same as ['#cookie-banner', 'img:not(.a, .b)'], since the string is cut only at the commas between selectors. Each selector applies on its own: one that isn't valid CSS excludes nothing, with a console.warn, and the others still apply.
styleSheetTextsThe text of style sheets the page can't read, [{ href, text }]: a sheet from another origin, by its URL, and its CSS. The rules that read the CSS (css-orientation-lock, css-hidden-focus, css-focus-indicator-suppressed) read it in place of the sheet. Each result's engineOptions echoes it as the sheets' hrefs, [{ href }], without their text. preloadStyleSheets() fetches the ones a server shares from inside the page, and runa11yCoreInPageAsync fetches them before scanning; a tool driving the browser can fetch the others from outside it. See "Style sheets from another origin" in core's integration guide on GitHub. Core 1.14.0 and later.
timestampPassed straight through to the result's top-level timestamp field; the engine does not generate one itself (deterministic-by-design). A value that isn't a string (a Date, a number) is ignored with a console.warn; pass new Date().toISOString().
contrast.modestrictConformance (default): contrast rules stay silent (notApplicable/skip) whenever the true rendered background isn't confidently computable, to protect against false fails. auditorAssist: trades some of that safety margin for more findings, intended for a human auditor who will double-check flagged cases, not for unattended CI gating. A page that paints no opaque background is not such a case: the canvas behind it follows from the page's own color scheme, so its text is measured in either mode without a guess. Where the page's canvas can't be known (in a frame, under forced colors), auditorAssist measures text on contrast.rootCanvasFallback, recorded as ROOT_CANVAS_FALLBACK.
contrast.rootCanvasFallbackA canvas color for a page that paints no opaque background. Unset, the contrast rules measure such a page's text on the canvas the browser paints behind it, in either mode: the canvas of the color scheme the page is drawn in, which its own color-scheme (on the root, or the <meta> form) decides. A page that declares none is drawn in light, on white, whatever the user prefers; light dark is drawn on white for a user in light and on the dark canvas for one in dark, and its text is measured in both schemes, failing where it fails in either (data.details.colorScheme names the one). Under jsdom, which resolves no system color, the engine uses Chromium's colors, and text whose colors it can't work out there is asked about (COLOR_SCHEME_NOT_RENDERED). Set, white included, the color stands for the page's canvas in every scheme: the text is measured on it once, recorded as an assumption (ROOT_CANVAS_FALLBACK). The page's canvas can't be known in a frame, whose canvas can let the parent page show through, or under forced colors: there contrast-computable asks and the ratio rules leave the text out, unless auditorAssist measures it on this color. Each result's engineOptions.contrast echoes the color used, white when none was given.
contrast.rootCanvasFallbackSetEchoed on each result: true when contrast.rootCanvasFallback is the caller's, false for the default white (also when the given value wasn't a usable color). It isn't needed as an input: options passed back from a result's echo are read as they were, so false keeps the echoed white the default. A value that isn't true or false is warned about and read as not given. Core 1.14.0 and later.
visibilityModeControls how strict the three contrast rules (contrast-minimum, contrast-enhanced, contrast-computable) are about deciding a text node is actually eligible to check. Not read by any other rule. Unset (the default), it is 'styleAndGeometry' where the page has a layout, which is any browser scan, and 'styleOnly' where it has none (jsdom): a reader only sees text that is drawn somewhere visible, which only the layout can tell. 'styleOnly': eligibility is CSS-only — display, visibility, opacity, ancestor-hiding, etc. 'styleAndGeometry': adds real layout checks (getClientRects()/getBoundingClientRect()) on top of that — text with no client rects, or zero width/height, is excluded too, and so is text entirely above or left of the page, where no scrolling reaches (the left: -9999px technique), and text an ancestor with overflow: hidden or clip cuts off completely (height: 0; overflow: hidden, a carousel slide out of view). A box that scrolls (auto, scroll) does not exclude its text, since a reader can reach it. The options of a <select> are judged by their <select>'s place on the page. In both modes, text with a font size of 0 or a fully transparent color is excluded, since nothing is drawn; transparent text that lets a background show through its glyphs (background-clip: text, on it or an ancestor) is not: it is measured in that background's color when it is solid, and asked about when it is a gradient or an image. Set 'styleOnly' in a browser to check text from computed styles alone, as earlier releases did; under plain jsdom (runDomRulesInPage) there's no real layout engine, so 'styleAndGeometry' mostly just adds getBoundingClientRect() zero-size checks, not true clipping/overflow detection — see known limitations.
policyContractSee POLICY.md — controls which outcomes/confidence values are allowed and whether manual rules' would-be fails get coerced to cantTell.
output.includeSelector / .includeHtmlSuppresses the engine's automatic selector/html fill-in. Since every rule was migrated to report its element rather than build occurrences by hand (1.5.0), that fill-in is the path almost all rules take: setting includeSelector: false / includeHtml: false strips selectors and HTML snippets from nearly every rule. A handful still assemble those fields themselves inside runInPage and are unaffected — among them contrast-minimum/contrast-enhanced (whose findings are text runs, not elements), page-title-present and identical-links-same-purpose. So this narrows output substantially but is still not a guarantee of no selectors or HTML anywhere in the result.
output.detail'full' (default) or 'findings': a smaller result, keeping whole only the check results that report something (fail, cantTell, and any listing occurrences); every other pass and notApplicable keeps its ruleId, outcome and type, and its margin or error if it has one. Rollups and verdicts are unchanged, and the reporters read it as a full result. A rule core's catalog couldn't fill back in stays whole: every rule of a scan with packs, a custom rule, and one the scan's messages reword. See engine.outputDetail; in TypeScript, such a result is a CompactScanResult.
rules[ruleId]Passed through to that rule as ctx.config, and — for excludeSelectors specifically — read by the engine itself before the rule ever runs. See "Rule-scoped excludeSelectors" below. Any other key is passthrough only: no shipped rule takes anything else from it. A rule's declared settings, such as contrast-minimum's thresholds, are its standard's, so a caller's value for one is dropped: a result naming WCAG 1.4.3 is always decided at 4.5:1. A standard with other thresholds has its own rule, a variant, which a pack can bring. A key is matched as a rule id is, in any case or with the legacy a11ycore- prefix; one naming no rule of the scan is warned about with the closest id.
packsOptional packs from @surea11y/core/pack: rules, variants of core's rules, a standard or a checklist and messages from outside core, which join the scan. A list: the pack objects for runDomRulesInPage and the catalog functions in Node, and for runa11yCoreInPage the name@version of packs a packScript registered in the page. Anything else runs no pack, with a warning, and throws under strictOptions, with code: 'INVALID_ENGINE_OPTIONS'. Reported back as engine.packs. See Packs.
probesAn optional, JSON-safe evidence object your host application supplies, for what a scan of one page cannot see, such as the site's other pages. The engine caps it before rules read it (ctx.inputs.probes): six levels deep, 200 items per array, 50 keys per object, 2,000 characters per string. crawl.pageTitles ({ pages: [{ url, title }] }) is read by page-title-patterns, to look for generic and templated titles across a site. A pack's rules may read probes of their own, which the pack documents.
perfStats / profileRulesDebug-only. perfStats: true returns internal counters on the result's perfStats field; profileRules: true additionally adds a per-rule timing breakdown there. profileRules on its own does nothing — perfStats is what creates the object the breakdown lives in. Shape is not part of the stable output contract — don't build on it. Note also that profileRules is the one option that makes output non-deterministic: counters are stable across identical runs, wall-clock timings are not. Leave it off if you diff results between runs. With profileRules, the shared caches (computed styles and accessibility-tree eligibility) are filled before the first rule and timed apart as perfStats.warmUpMs, so their cost isn't charged to whichever rule walks the page first.
pingWaitTime / frameWaitTimeOnly read by runa11yCoreAcrossFrames (see INTEGRATION.md) — how long to wait for a child frame to answer a ping (default 500ms) and a full run request (default 60000ms) before treating it as unreachable. Ignored by runDomRulesInPage/runa11yCoreInPage.

Catching mistakes in the options (strictOptions)

An option the engine doesn't read is passed through: a custom rule can read settings of its own from ctx.engineOptions, and an option removed in a later release doesn't break a scan. So by default a misspelt option is ignored, and the scan runs as if it weren't there. Two things say so:

  • Always, a key one or two letters from a known one (in any case) is warned about, nested keys included: lcoale gets [surea11y] engineOptions: unknown option "lcoale" (did you mean "locale"?); ignored.
  • Always, a value of the wrong type is warned about and read as not given, as is an engineOptions that isn't an object. On/off options (includeShadowDom, includeHiddenElements, fragment, perfStats, profileRules, output.includeHtml, output.includeSelector) take true or false, or 'true', 'false', '1', '0', 1 and 0 as a command line spells them: perfStats: 'false' is off.
  • With strictOptions: true (or 'true', '1' or 1), every option is checked before the scan, and any mistake throws an error with code: 'INVALID_ENGINE_OPTIONS', naming each one, with problems listing them as { path, kind, message, suggestion }: an unknown key (kind: 'unknown'), in engineOptions or in one of its objects, and a value of the wrong type or outside its set (kind: 'invalid'), such as includeHiddenElements: 'yes' or contrast.mode: 'strict'.
runDomRulesInPage(url, null, { strictOptions: true, lcoale: 'de' }, null);
// Error: engineOptions: unknown option "lcoale" (did you mean "locale"?). (strictOptions)

The catalog functions that take engineOptions (getChecksCatalog, getRulesCatalog, getCheckDefById, getCompositeRuleById, getChecksForRunOnly, getRulesForRunOnly, getTestsForRunOnly) check them the same way, in the same words: getRulesForRunOnly(runOnly, { wcagLevel: 'AA+' }) warns, reads the level as not given, and lists what a scan with those options reports; with strictOptions: true it throws INVALID_ENGINE_OPTIONS, as the scan does. A selection that cancels itself (a rule both included and excluded, parts with no rule in common) is said so as the scan says it: a warning, and INVALID_RUN_ONLY under strictOptions.

Not checked: a rule's own settings (rules[ruleId]), the keys of messages, and probes, which the engine passes on unread; and the custom rules, which have checks of their own (skippedCustomRules). In TypeScript, StrictEngineOptions is the same set of options without the index signature that lets any key compile, so a misspelt option is an error there too.

Rule-scoped excludeSelectors

The top-level excludeSelectors applies to every rule — there's no way to exclude an element from just one rule while still running every other rule against it. rules[ruleId].excludeSelectors fills that gap: it narrows candidates for that one rule only, on top of (never instead of) the global list.

const engineOptions = {
excludeSelectors: ['#cookie-banner'], // applies to every rule, as always
rules: {
'aria-required-children': {
excludeSelectors: ['mat-select', 'mat-stepper', 'mat-horizontal-stepper', 'mat-vertical-stepper']
},
'aria-allowed-attr': {
excludeSelectors: ['mat-progress-spinner']
}
}
};

Why you'd want this: Angular Material's <mat-select> builds its internal ARIA structure in a way that trips a false positive on aria-required-children specifically, even though the component is otherwise fine. With only the global excludeSelectors, the only way to silence that false positive is excludeSelectors: ['mat-select'] — which also hides mat-select from every other rule, including contrast-minimum and aria-allowed-attr, silently dropping real coverage those checks never had a problem with. The example above keeps mat-select fully visible to every rule except the one that misfires on it.

Effective exclusions for a given rule are the union of the global list and that rule's own list — an element matching either is dropped from that rule's candidates. A rule whose only would-be-failing elements are all excluded this way reports outcome: 'pass' or 'notApplicable' (matching that rule's own no-candidates convention), with occurrences: [] — never outcome: 'fail' with an empty occurrences array, since a built-in rule's fail always names what failed. (A rule that throws is a different case: it reports cantTell with error set, see the output schema.)

Accepts the same forms as the global option: an array (['mat-select', 'mat-stepper']) or a comma-separated string ('mat-select, mat-stepper').

If you're using a binding package (@surea11y/binding-base and its Playwright/Puppeteer wrappers), check that binding's own README for whether its .exclude() builder method has a rule-scoped form yet — this is an engineOptions shape documented here at the engine level; not every binding has picked it up.

Recipes — composing options for real scenarios

CI gate: WCAG 2.2 AA only, ignore a third-party widget you don't control

runDomRulesInPage(url, null, {
excludeSelectors: ['#cookie-banner', '.intercom-launcher']
}, { wcag: { version: '2.2', level: 'AA' } });

Add bestPractices: true beside wcag to also run the best-practice rules, which a WCAG gate usually reports without failing on. Some of them can fail (empty-heading), so a gate that fails on any fail fails on those too.

Human auditor doing a deep contrast pass in a real browser — trade some false-positive protection for more findings. In a real browser the contrast rules already check the layout, not just computed styles, so only the contrast mode needs setting. Shown with Puppeteer's page.evaluate (accepts multiple args); if you're on Playwright, wrap the four positional args into a single object first — see INTEGRATION.md:

const result = await page.evaluate(runa11yCoreInPage, url, null, {
contrast: { mode: 'auditorAssist' }
}, null);

Scoped re-scan of one region after a UI change, skipping shadow DOM — useful in a component-level test where you only care about the widget you just changed:

runDomRulesInPage(url, '#checkout-form', {
includeShadowDom: false
}, { includeRuleIds: ['form-control-programmatic-label-present', 'button-name-present'] });

Output for snapshot testing — pin a timestamp so the result's own time stamp doesn't change between runs, and leave perfStats and profileRules off: their timings vary, and their shape is not part of the stable output. A snapshot also needs the same DOM, the same options, and the same rendering conditions (browser, viewport, fonts) on every run; the timestamp alone doesn't make two results identical.

runDomRulesInPage(url, null, {
timestamp: '2026-01-01T00:00:00Z'
}, null);

A custom, org-specific rule alongside the built-ins, only for this one call:

runDomRulesInPage(url, null, {
customRules: [{
id: 'org-no-inline-onclick',
meta: { title: 'No inline onclick handlers', defaultSeverity: 'moderate' },
runInPage(ctx) {
// queryAllSmart applies excludeSelectors, the scope's shadow roots and
// the hidden-content filter; queryAll is the plain light-DOM query.
// ctx.helpers.dom reads the DOM so the page's markup can't redirect it.
const dom = ctx.helpers.dom;
const els = ctx.helpers.queryAllSmart('[onclick]');
const occurrences = els.map((el) => ({
selector: ctx.helpers.buildSelector(el),
html: dom.outerHTML(el),
summary: 'Inline onclick handler found.',
hint: 'Move event handling into an external script.'
}));
 
return { ruleId: ctx.rule.ruleId, outcome: occurrences.length ? 'fail' : 'pass', severity: 'moderate', occurrences };
}
}]
}, null);

customRules — runtime-registered rules

Every shipped rule is baked into src/core.js at build time. engineOptions.customRules is the runtime escape hatch: an array of rule descriptors registered for that one call only — nothing is added to the static catalog, and nothing persists between calls. The catalog functions take the same engineOptions and list what a scan with them has: getChecksCatalog(engineOptions) lists the valid custom rules, with each override in place of its built-in, getCheckDefById(id, engineOptions) finds them, getChecksForRunOnly(runOnly, engineOptions) selects them as the scan does, and getRulesForRunOnly(runOnly, engineOptions) lists the rollups they add. This is deliberate, not a limitation to work around: surea11y already takes fresh engineOptions per call with no mutable global config, and custom rules follow that same per-call model.

Calling the library directly is one way in; the CLI also exposes this via --custom-rules <path> (a local file, loaded once per scan) — see the CLI docs.

A custom rule, like a pack's, runs after the built-in rules, and the ctx.helpers, ctx.engineOptions and ctx.inputs.probes it is given are read-only: setting or deleting anything on them throws (the rule is reported as cantTell with that error), so it can't change what other rules see. It must not change the page: one that does is named in a console.warn. Results keep the catalog's order. Whatever it throws or returns, the scan returns a result, and the rule's result is made plain data, so the scan's result can be written as JSON and cloned.

A descriptor has the same shape as an internal rule module's own export — if you already know how to write a rule file for this engine, you already know this API:

{
id: 'my-org-custom-rule', // required
meta: { title, description, tags, defaultSeverity, defaultConfidence, /* same fields as a rule module's meta */ },
runInPage(ctx) { /* same ctx shape and same return contract as any built-in rule */ },
applicability(ctx) { return true; }, // optional, same contract as a built-in rule's applicability
data: { /* optional, JSON-serializable */ }
}
  • runInPage/applicability may be a real function or a function-source string (i.e. fn.toString()). Pass a real function when engineOptions never leaves the current JS realm (plain Node/jsdom use). Pass a string when it does — e.g. a Playwright page.evaluate(runa11yCoreInPage, { engineOptions }) call, where engineOptions crosses a JSON/structured-clone boundary that cannot carry a live Function reference but can carry a string. The engine reconstructs a string via new Function, the same mechanism the build uses to embed every built-in rule's source into the in-page runner. Any function's source works: an arrow function, a function declaration, or a method written in shorthand as in the example above (runInPage(ctx) {…}, async too), whose toString() is not an expression on its own. Reconstruction needs eval to be allowed, so a page whose Content Security Policy forbids 'unsafe-eval' skips a rule given as a string, and its skippedCustomRules reason says the policy is why; pass a real function there.
  • meta gets identical defaulting/validation to a build-time rule — omit anything you don't need; defaultSeverity defaults to moderate, defaultConfidence to medium, type to automatic, etc. A value outside its set (minor/moderate/serious/critical; high/medium/low; automatic/manual, any case) is a meta that fails validation, and the rule is skipped. A severity a rule returns outside the set is replaced by its default, with a note in error.
  • A custom rule whose id collides with a built-in one overrides it for that scan, rather than running both. Since a same-named custom rule is just as likely to be an accidental collision as a deliberate override, every collision is surfaced two ways: a console.warn naming the id(s), and a top-level overriddenBuiltinIds array on the result (empty when there's no collision) — see the output schema. Ids are matched in any case: a custom rule IMG-ALT-PRESENT overrides img-alt-present and takes its id, and a second warning names both spellings.
  • A custom rule that ran counts toward the WCAG rollup (rulesResults) of each criterion it maps to, through meta.wcagSc or a WCAG entry in meta.normativeMappings, as a built-in rule does: a failing one fails the rollup. The rollup marks it custom: true among its contributors and lists it in data.details.customChecksIds. A custom rule the selection names on its own (by its id or one of its tags) that ran reports the rollups of its criteria even when the selection names none of them ("Which criteria a scan reports"). An override counts where its own mapping says: in its built-in's place when it maps to that criterion, and in no rollup when it maps to none, with a warning naming the rollups it left.
  • A custom rule is selected like a built-in one, by its id and its meta.tags. A runOnly that names a custom rule the scan skipped throws INVALID_RUN_ONLY, with the reason it was skipped. customRules is a list: a single rule given on its own is not run, and is listed in skippedCustomRules. Under a profile or a WCAG target, a rule tagged best-practice, or with no WCAG tag, is not part of the run: it is listed in skippedCustomRules, with a console.warn, and runs when you give it the tags of the criteria it checks ('wcag111') or add runOnly.bestPractices: true. A custom rule the selection excludes (excludeRuleIds: ['org-b'], rules.exclude...) is listed there too, with the reason excluded by the selection. and excluded: true, and not warned about. An override runs wherever its built-in would have.
  • A custom rule can report a margin as a built-in rule does: declare meta.margin ({ measure, unit, limit }) and return marginCandidates (see RULE_AUTHORING.md, meta.margin, the rule-authoring guide on GitHub). A malformed meta.margin is ignored, so that rule reports no margin; the scan carries on.
  • An invalid descriptor (missing/non-string id, a runInPage that isn't a function and isn't a reconstructable source string, an applicability that can't be turned into a function, a meta that fails validation, such as deprecated: true without deprecation, an id another custom rule in the list already has, or the id of a composite rule) is skipped with a console.warn naming the rule and the reason, and listed in the result's skippedCustomRules — the rest of the scan, including every built-in rule, still runs normally. This isn't a validation gap to fix: a custom rule is arbitrary caller-supplied code, so "fail this one entry closed, don't abort the scan" is the safer default, mirroring how a built-in rule that throws is contained to a cantTell for that rule rather than crashing the run.
  • Rules run synchronously. A runInPage or applicability that returns a Promise (an async function) is reported as cantTell with an error saying so, as is a runInPage that returns anything but a result object. An outcome outside pass, fail, cantTell and notApplicable (such as 'failed') becomes cantTell, and error names the value returned. A rule's type comes from its meta: a type in its return is ignored, with a note in error.
  • Results appear in checksResults exactly like any other rule's, including automatic selector/html/structuralPath fill-in for fail/cantTell occurrences that only attach { __node } (see the output schema).
  • A fail names what failed. A custom rule's fail with no occurrences (a failure of the whole page) is reported with one occurrence on the document element (selector: 'html', reason code FAIL_WITHOUT_OCCURRENCE), so JUnit, SARIF, baselines and the HTML report show it as the failure it is.

Packs — rules and standards from outside core

A pack brings rules, variants of core's rules, a standard or a checklist, and the messages they use, from a package of its own, without a build of core. Pass packs to a scan in engineOptions.packs; the scan runs on an engine prepared with them, by the steps the build uses for core's own rules, so a pack's standard, profiles, rollups and opt-in rules behave as a built-in one's would. The engine is kept for the next scan with the same pack objects, so a crawl that passes the same packs to every page prepares them once.

const { runDomRulesInPage, getChecksCatalog } = require('@surea11y/core');
const acmePolicy = require('./acme-policy-pack');
 
const result = runDomRulesInPage(url, null, { packs: [acmePolicy], profile: 'acme-web-2026' });
result.engine.packs; // ['@acme/a11y-policy@1.0.0']
getChecksCatalog({ packs: [acmePolicy] }); // core's rules and the pack's

A pack that does not hold together with core (its core range excludes this version, a rule id without the namespace, a dictionary key core has) is skipped with a console.warn, listed in the result's skippedPacks as { name, reason }, and the scan runs without it; under strictOptions it throws, with code: 'INVALID_ENGINE_OPTIONS'. Packs that clash with each other throw. In a page, a pack is registered first with packScript(packs) and named in engineOptions.packs as name@version. What a pack holds, how to use one from each binding, the test matchers and the CLI, and how to write one, are on Packs.

contextSelector (2nd runner argument, not an engineOptions field)

A CSS selector (or array of selectors) scoping the scan to one or more subtrees, resolved via document.querySelectorAll (all matches, not just the first). Pass null to scan the whole document.

  • A selector that matches nothing scans nothing. Every rule reports notApplicable, the page-level rules included, and the result's contextMatch says so: { elementCount: 0, unmatchedSelectors: ['#mian'] } (see the output schema). The run also logs a console.warn. A scope the page doesn't have is not widened to the whole page: a result for content you never asked about, a whole-page pass among it, would read as a result for the part you did. In an array, a selector that matches nothing is listed in unmatchedSelectors while the others are scanned as usual, so content that is only sometimes on the page, such as a cookie banner, can sit in the list. Before 1.10.0 a scope that matched nothing fell back to the whole document, without a word in the result.
  • A selector the browser can't parse throws, alone or in an array, before any rule runs: contextSelector: "#main[" is not a valid CSS selector. The error carries code: 'INVALID_CONTEXT_SELECTOR' and selector, the selector that failed. Unlike a selector that matches nothing, it is always a mistake in the call. runa11yCoreAcrossFrames rejects with the same error. An error crossing page.evaluate (Playwright, Puppeteer) keeps only its message, which is why the message names the option and the selector. Before 1.10.0 an invalid selector was skipped, so on its own it scanned the whole document.
  • Anything but a selector, an array of selectors or null throws the same error, naming what was passed: an element, a NodeList, an { include, exclude } object, a number, or an array item that isn't a string (contextSelector[1] must be a CSS selector, not the number 5.). Pass a selector that matches the element instead, and put what an exclude would leave out in engineOptions.excludeSelectors.
  • An empty selector or list scans the whole page, and says so. '', ' ', [] and a list of blank strings name no part of the page, so the scan covers all of it, as with null, and logs a console.warn. Under strictOptions it throws INVALID_CONTEXT_SELECTOR instead, since a list built empty by mistake may have been meant as "nothing to scan". Pass null for the whole page. The result's contextMatch is null, as for no selector. Core 1.16.0 and later.
  • A single string may itself be a comma-separated selector list (ordinary CSS union semantics) — '#a, #b' scans both #a and #b.
  • An array of strings scans the union of every selector's matches — ['#a', '.card'] behaves the same as '#a, .card'; the array form exists for callers building the list programmatically.
  • Shadow DOM. With includeShadowDom (the default), a scope includes the shadow roots inside it, and a scope that is itself a shadow host includes its own shadow root. The selector is matched against the document, so it can't select an element inside a shadow root; scope to its host instead.
  • Overlapping/nested regions are deduped automatically — an element reachable from more than one matched root is only ever reported once, not once per region.
  • What a scoped result means. Most rules judge each element on its own, and judge the elements in scope exactly as in a whole-page scan. Two kinds of rule depend on the page around them. Whole-page rules (page-title-present, landmark-one-main and the rest listed under fragment above) report notApplicable. Rules that compare elements across the page (landmark-unique, landmark-no-duplicate-banner, -contentinfo and -main, landmark-banner-is-top-level, -complementary-, -contentinfo- and -main-is-top-level, accesskeys, heading-order, identical-links-same-purpose, identical-iframes-same-purpose) still report what they find inside the scope, since two landmarks there sharing a name share it on any page, but report notApplicable instead of pass: the scan saw only part of the page, so it cannot say the page is fine. duplicate-id and duplicate-id-aria compare each element in scope with the whole document, since its duplicate can sit outside the scope, and report an element in scope whose id appears anywhere else. Their pass means no element in the scope shares its id with another element on the page; a duplicate pair entirely outside the scope is not reported, as with every rule, so it does not say the whole page is free of duplicates. target-size-minimum judges the targets in scope against neighbours anywhere in the document, since the spacing exception depends on what the page puts next to a target. text-spacing-content-loss judges the text in scope, and checks whether it comes to overlap text anywhere on the page.