Skip to content

Add standard and best-practice options to axe (#14607)#14682

Draft
cwickham wants to merge 4 commits into
axe/vendor-axe-corefrom
axe/standard-option
Draft

Add standard and best-practice options to axe (#14607)#14682
cwickham wants to merge 4 commits into
axe/vendor-axe-corefrom
axe/standard-option

Conversation

@cwickham

@cwickham cwickham commented Jul 13, 2026

Copy link
Copy Markdown
Member

Closes #14607.

Stacked on #14677 (base: axe/vendor-axe-core); retargets to main automatically when that merges. Docs PR: quarto-dev/quarto-web#2105.

What

The axe: accessibility checker always ran axe-core's default rule set. This PR adds two options:

format:
  html:
    axe:
      output: document
      standard: wcag21aa      # scope checks to a WCAG conformance level
      best-practice: true     # also run axe's best-practice rules
  • standard — one of wcag2a, wcag2aa, wcag2aaa, wcag21a, wcag21aa, wcag21aaa, wcag22a, wcag22aa, wcag22aaa. Axe tags aren't cumulative (a rule's version tag marks where its criterion was introduced), so each level maps to its own tag plus those of the lower levels and earlier versions it builds on — the STANDARD_TAGS table, exported from axe-check.js for reuse by later tooling.
  • best-practice — with standard, true adds axe's best-practice rules to the scope (they're excluded by default once a standard is set). Without standard, false removes best-practice rules from the default scan. Unset preserves today's behavior in both cases.

No options set ⇒ behavior is unchanged.

Design notes

Behavior claims below were verified against the axe-core 4.10.3 source, not remembered docs.

What we send to axe. For standard: wcag2aa, axe-check.js calls axe.run with:

{
  runOnly: { type: "tag", values: ["wcag2a", "wcag2aa"] },
  rules: {
    "aria-roledescription": { enabled: false },
    "audio-caption": { enabled: false },
  },
  preload: { assets: ["cssom"], timeout: 50000 },
}

The one fact that drives the design: when runOnly selects by tag, axe ignores each rule's enabled: false default (lib/core/utils/rule-should-run.js). So what runs is: rules matching our tag list, minus rules we explicitly override. Axe's default-disabled rules fall into three groups:

Rule group Axe default Selected by our tag list? Our override Net effect
AAA rules, target-size (2.2 AA) enabled: false Yes, when the standard covers their level none — left selected Run when the standard covers them
Deprecated (aria-roledescription, audio-caption) enabled: false Yes (they still carry wcag2a) enabled: false Never run
Experimental enabled: false No — axe's default tagExclude filters the experimental tag none needed Never run

The first row is deliberate and user-visible: choosing a standard can report violations the default scan doesn't — all three AAA rules and target-size ship disabled and turn on when the chosen standard covers them. "Check me against this level" should mean full coverage of that level; without this, standard: wcag2aaa would add zero AAA checks.

Only the override column is ours. axeScopeOptions builds it: filter axe.getRules() for deprecated rules the tag list would match, set enabled: false for each — rule-level overrides beat tag selection, which is axe's documented "runOnly + rules" combination (not the undocumented values: {include, exclude} form). Rule IDs are never hardcoded; they come out of axe's catalog at scan time, so the list adapts if an axe upgrade deprecates different rules. The same mechanism handles best-practice: false without a standard, filtering on the best-practice tag instead.

STANDARD_TAGS lists some tags that match no rules today. The table encodes WCAG conformance logic — each level covers its own tag, the lower levels of its version, and the versions it builds on — rather than axe's current rule inventory (which has no rules yet for criteria introduced at 2.1 AAA, 2.2 A, or 2.2 AAA; true of both 4.10.3 and 4.12.1). Passing those tags is safe: axe's unknown-tag warning explicitly exempts wcag2x level tags (Audit.normalizeOptions). And it's future-proof: an axe upgrade that adds rules for those criteria scopes them in with no Quarto change.

Two small fixes ride along:

  • Object-form options now default to output: console. Previously an object without output hit reporters[undefined] and silently did nothing — axe: {standard: wcag21aa} would have been the common way to trip that.
  • preload moved from axe.run's context argument to its options argument, as sketched in the issue.

Commits

  1. Regenerate stale YAML intelligence artifacts — catch-up only, no schema change: docs(schema): note markdown support in website navigation text fields #14503 (4ed3ffb) updated definitions.yml descriptions but the committed editor artifacts only partially captured it, so any PR that reruns dev-call build-artifacts inherits the drift. Regenerated separately so the feature commit's artifact diff is only its own change.
  2. Featureaxe-check.js, schema, regenerated artifacts, changelog.
  3. Tests.

Tests

  • Unit (tests/unit/axe-standard-tags.test.ts): STANDARD_TAGS structural recurrence (each level covers its own tag, the lower levels of its version, and the previous version's same level) and axeScopeOptions across all option combinations, using hand-written rule catalogs so an axe-core upgrade that re-tags rules can't break them. 18/18 pass (incl. existing conformance tests).
  • Playwright: four fixtures share one body violating image-alt (A), color-contrast (AA), and heading-order (best practice), differing only in axe options — presence in one case proves absence in another is scoping, not a fixture that stopped violating. Full axe-accessibility.spec.ts passes: 90/90 across chromium/firefox/webkit (also verifies the axe.run restructuring didn't regress the default scan, and that the rule-less wcag22a tag is accepted warning-free in practice). A fourth case covers axe: {standard: wcag2a} with no output: it falls back to console reporting (the reporters[undefined] fix) and scoping applies through the console path.

AI-assisted (Claude Code), grounded in a local clone per CONTRIBUTING.md "Using AI tools to investigate"; axe-core behavior claims verified against the vendored 4.10.3/4.12.1 bundles and the v4.10.3 tag of dequelabs/axe-core.

@posit-snyk-bot

posit-snyk-bot commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

cwickham added 3 commits July 13, 2026 13:37
Commit 4ed3ffb (#14503) added 'Supports markdown formatting.' to
several website navigation descriptions in definitions.yml but the
regenerated editor artifacts only partially captured the change, so
the committed artifacts have been stale since. Any later schema change
that reruns dev-call build-artifacts picks the drift up; regenerate it
separately so the next commit's artifact diff is only its own change.
The axe accessibility checker always ran axe-core's default rule set.
A new `standard` option scopes checks to a WCAG conformance level by
mapping the level to axe-core tags (STANDARD_TAGS) and passing them to
axe.run's runOnly. Tags aren't cumulative in axe (a rule's version tag
marks where its criterion was introduced), so each level lists its own
tag plus the lower levels and earlier versions it builds on. The lists
follow WCAG conformance logic rather than the bundled axe-core's rule
inventory: tags with no rules yet (2.1 AAA, 2.2 A/AAA) are listed
anyway — axe's unknown-tag warning explicitly exempts wcag2x level
tags, and a future axe that adds such rules scopes them in with no
change here. A `best-practice` option controls whether axe's
best-practice rules run.

Because runOnly by tag matches on tags alone (ignoring axe's
enabled:false defaults), a standard reaches the default-disabled rules
for its level (AAA rules, target-size) but would also resurrect
deprecated rules tagged wcag2a; those are explicitly disabled via the
documented rules override.

Also defaults output to console for object-form options, so
`axe: {standard: wcag21aa}` doesn't silently fail on the reporter
lookup, and regenerates the schema editor artifacts.
Unit tests cover the STANDARD_TAGS mapping (structural recurrence:
each level covers its own tag, lower levels of its version, and the
previous version's same level) and axeScopeOptions with hand-written
rule catalogs (immune to axe-core re-tagging). Playwright fixtures
share one body violating image-alt (A), color-contrast (AA), and
heading-order (best practice), differing only in axe options, so
absence of a violation in one case is provably scoping.
@cwickham

Copy link
Copy Markdown
Member Author

Docs PR: quarto-dev/quarto-web#2105 (draft, targets prerelease; merge after this lands).

axe: {standard: wcag2a} with no output key previously hit
reporters[undefined] and silently did nothing; it now defaults to the
console reporter. The new fixture pins both the console fallback and
that scoping applies through the console path (image-alt reported,
color-contrast and heading-order absent).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants