///|
/// A result produced by `audit_html`.
///
/// `FindingsWithParseErrors` preserves recoverable parser diagnostics while
/// still auditing the parser's recovered DOM. `ParseErrors` means the parser
/// could not return a DOM for auditing.
pub(all) enum AuditResult {
  Findings(Array[Finding])
  FindingsWithParseErrors(Array[Finding], Array[ParseDiagnostic])
  ParseErrors(Array[ParseDiagnostic])
}

///|
/// A single accessibility finding.
///
/// `element_path` is a deterministic CSS-style path based on element names and
/// `:nth-of-type()` positions. `line` and `column` are the parser's optional,
/// 1-based source location for the element start tag.
pub(all) struct Finding {
  rule_id : String
  message : String
  suggestion : String
  element_path : String
  line : Int?
  column : Int?
}

///|
/// A parse diagnostic reported before audit rules are evaluated.
pub(all) struct ParseDiagnostic {
  code : String
  message : String
  line : Int?
  column : Int?
}

///|
/// The parser and finding status of a detailed audit.
pub(all) enum DetailedAuditStatus {
  Findings
  FindingsWithParseErrors
  ParseErrors
}

///|
/// A static check that requires browser, CSS, or runtime information before it
/// can be treated as a confirmed issue.
pub(all) struct ReviewItem {
  rule_id : String
  reason : String
  element_path : String
  line : Int?
  column : Int?
}

///|
/// A detailed audit result that keeps definite findings, parser diagnostics,
/// and manual-review items separate.
pub(all) struct DetailedAuditResult {
  status : DetailedAuditStatus
  findings : Array[Finding]
  parse_diagnostics : Array[ParseDiagnostic]
  review_items : Array[ReviewItem]
}

///|
/// A result produced by `audit_html_with_rules`.
pub(all) enum ConfiguredAuditResult {
  Audited(AuditResult)
  ConfigurationError(RuleConfigurationError)
}

///|
/// A result produced by a detailed rule-selected audit.
pub(all) enum DetailedConfiguredAuditResult {
  Audited(DetailedAuditResult)
  ConfigurationError(RuleConfigurationError)
}

///|
/// An invalid rule-selection configuration.
pub(all) enum RuleConfigurationError {
  UnknownRuleId(String)
}

///|
/// A broad grouping used by the stable rule directory.
pub(all) enum RuleCategory {
  ContentName
  DocumentStructure
  Interaction
  Relationships
  Aria
  Timing
}

///|
/// The input scope in which a rule is evaluated.
pub(all) enum RuleScope {
  Document
  Fragment
  DocumentOrFragment
}

///|
/// The confidence represented by a rule's output.
pub(all) enum RuleResultKind {
  ConfirmedFinding
  StaticHint
  ManualReview
}

///|
/// Stable, read-only metadata describing one built-in rule.
pub(all) struct RuleMetadata {
  id : String
  summary : String
  category : RuleCategory
  scope : RuleScope
  result_kind : RuleResultKind
  reference_url : String
  boundary : String
}

///|
/// The WCAG conformance level for an A/AA success criterion.
pub(all) enum WcagLevel {
  A
  AA
}

///|
/// How far A11yTrace can assess one WCAG criterion from static HTML.
/// This describes coverage, never a page conformance outcome.
pub(all) enum WcagCoverageStatus {
  PartialAutomatic
  ManualReview
  NotAssessed
  RemovedInWcag22
}

///|
/// A version-aware A/AA WCAG success-criterion coverage record.
pub(all) struct WcagCriterion {
  id : String
  title : String
  level : WcagLevel
  in_wcag21 : Bool
  in_wcag22 : Bool
  coverage_status : WcagCoverageStatus
  rule_ids : Array[String]
  review_steps : String
  reference_url : String
}

///|
/// A detailed audit alongside the complete static-coverage directory.
/// Empty findings do not mean the criteria have passed.
pub(all) struct WcagAuditResult {
  audit : DetailedAuditResult
  criteria : Array[WcagCriterion]
}

///|
// A finite base-ten number retained as coefficient and decimal scale. Keeping
// this representation exact avoids a floating-point rounding decision at a
// slider or scrollbar boundary.
priv struct ExactDecimal {
  negative : Bool
  digits : Array[Char]
  scale : Int
}

///|
priv enum RangeDecimalParse {
  Valid(ExactDecimal)
  Invalid
  TooLargeExponent
}

///|
priv enum RangeValueCheck {
  Valid
  Finding(String)
  Review(String)
}

///|
/// Audit HTML with the rules currently implemented by A11yTrace.
///
/// This is a pure operation: it performs no file I/O and can be used by a
/// build tool, test suite, or another MoonBit package. Input with parser
/// diagnostics returns `FindingsWithParseErrors`, so ordinary HTML5 recovery
/// does not hide rule findings. A parser failure returns `ParseErrors`.
///
/// Inputs with an HTML document marker (` AuditResult {
  detailed_to_audit_result(
    audit_html_detailed_with_enabled_rules(
      html,
      all_rule_ids(),
      looks_like_html_document(html),
    ),
  )
}

///|
/// Audit HTML and keep definite findings separate from items needing browser
/// or CSS review. Unlike `audit_html`, this result exposes `review_items`.
pub fn audit_html_detailed(html : String) -> DetailedAuditResult {
  audit_html_detailed_with_enabled_rules(
    html,
    all_rule_ids(),
    looks_like_html_document(html),
  )
}

///|
/// Audit HTML with selected rules while retaining manual-review items.
/// Unknown IDs are returned before parsing, as with `audit_html_with_rules`.
pub fn audit_html_detailed_with_rules(
  html : String,
  enabled_rule_ids : Array[String],
) -> DetailedConfiguredAuditResult {
  audit_html_detailed_with_selected_rules(
    html,
    enabled_rule_ids,
    looks_like_html_document(html),
  )
}

///|
/// Return the stable IDs accepted by `audit_html_with_rules`.
pub fn available_rule_ids() -> Array[String] {
  all_rule_ids()
}

///|
/// Return copied metadata for every built-in rule in stable ID order.
pub fn available_rules() -> Array[RuleMetadata] {
  rule_catalog()
}

///|
/// Look up copied metadata for one built-in rule ID.
pub fn rule_metadata(rule_id : String) -> RuleMetadata? {
  for rule in rule_catalog() {
    if rule.id == rule_id {
      return Some(rule)
    }
  }
  None
}

///|
/// Return the content-name rule IDs as a convenient selection.
pub fn content_rule_ids() -> Array[String] {
  rule_ids_for_category(ContentName)
}

///|
/// Return the complete-document structural rule IDs as a convenient selection.
pub fn document_rule_ids() -> Array[String] {
  rule_catalog()
  .filter(fn(rule) { rule.scope is Document })
  .map(fn(rule) { rule.id })
}

///|
/// Return the rule IDs that can produce manual-review items.
pub fn review_rule_ids() -> Array[String] {
  rule_catalog()
  .filter(fn(rule) { rule.result_kind is ManualReview })
  .map(fn(rule) { rule.id })
}

///|
/// Return copied A/AA WCAG 2.1 and 2.2 coverage records, including the
/// historical 4.1.1 entry removed from WCAG 2.2.
pub fn wcag_aa_criteria() -> Array[WcagCriterion] {
  wcag_criteria_catalog()
}

///|
/// Look up a copied WCAG A/AA coverage record by success-criterion ID.
pub fn wcag_criterion(id : String) -> WcagCriterion? {
  for criterion in wcag_criteria_catalog() {
    if criterion.id == id {
      return Some(criterion)
    }
  }
  None
}

///|
/// Audit a complete-document-or-fragment input and return its detailed result
/// with coverage records. Coverage status is not a pass/fail determination.
pub fn audit_html_with_wcag_coverage(html : String) -> WcagAuditResult {
  { audit: audit_html_detailed(html), criteria: wcag_criteria_catalog(), }
}

///|
/// Render a detailed audit and its WCAG coverage directory as JSON. The
/// `criteria` records describe assessment capability, never passed criteria.
pub fn render_wcag_audit_json(result : WcagAuditResult) -> String {
  @json.to_json({
    "audit": detailed_audit_result_json(result.audit),
    "criteria": @json.to_json(result.criteria.map(wcag_criterion_json)),
  }).stringify()
}

///|
/// Audit HTML with only the selected rule IDs.
///
/// An empty array runs no rules but still returns parser diagnostics. Repeated
/// IDs are treated as one selection. An unknown ID returns
/// `ConfigurationError` before parsing.
pub fn audit_html_with_rules(
  html : String,
  enabled_rule_ids : Array[String],
) -> ConfiguredAuditResult {
  audit_html_with_selected_rules(
    html,
    enabled_rule_ids,
    looks_like_html_document(html),
  )
}

///|
/// Audit an HTML fragment with every current A11yTrace rule.
///
/// This is useful for a component or generated template that is not wrapped
/// in a complete document. Labels and ARIA references are resolved only
/// within the supplied fragment.
pub fn audit_html_fragment(fragment : String) -> AuditResult {
  detailed_to_audit_result(
    audit_html_detailed_with_enabled_rules(fragment, all_rule_ids(), false),
  )
}

///|
/// Audit an HTML fragment and keep manual-review items separate from definite
/// findings and parser diagnostics.
pub fn audit_html_fragment_detailed(fragment : String) -> DetailedAuditResult {
  audit_html_detailed_with_enabled_rules(fragment, all_rule_ids(), false)
}

///|
/// Audit a fragment with selected rules while retaining manual-review items.
pub fn audit_html_fragment_detailed_with_rules(
  fragment : String,
  enabled_rule_ids : Array[String],
) -> DetailedConfiguredAuditResult {
  audit_html_detailed_with_selected_rules(fragment, enabled_rule_ids, false)
}

///|
/// Audit an HTML fragment with only the selected rule IDs.
///
/// Its rule-selection and parser-diagnostic behavior matches
/// `audit_html_with_rules`. Unknown IDs return `ConfigurationError` before
/// parsing the fragment.
pub fn audit_html_fragment_with_rules(
  fragment : String,
  enabled_rule_ids : Array[String],
) -> ConfiguredAuditResult {
  audit_html_with_selected_rules(fragment, enabled_rule_ids, false)
}

///|
fn audit_html_with_selected_rules(
  source : String,
  enabled_rule_ids : Array[String],
  is_document : Bool,
) -> ConfiguredAuditResult {
  match unknown_rule_id(enabled_rule_ids) {
    Some(rule_id) => ConfigurationError(UnknownRuleId(rule_id))
    None =>
      Audited(
        detailed_to_audit_result(
          audit_html_detailed_with_enabled_rules(
            source, enabled_rule_ids, is_document,
          ),
        ),
      )
  }
}

///|
fn audit_html_detailed_with_selected_rules(
  source : String,
  enabled_rule_ids : Array[String],
  is_document : Bool,
) -> DetailedConfiguredAuditResult {
  match unknown_rule_id(enabled_rule_ids) {
    Some(rule_id) => ConfigurationError(UnknownRuleId(rule_id))
    None =>
      Audited(
        audit_html_detailed_with_enabled_rules(
          source, enabled_rule_ids, is_document,
        ),
      )
  }
}

///|
/// Render an audit result as a compact JSON document for programmatic use.
///
/// The JSON object always contains `status`, `findings`, and
/// `parse_diagnostics`. Optional source positions are rendered as JSON `null`
/// when the parser did not provide them.
pub fn render_audit_json(result : AuditResult) -> String {
  @json.to_json(audit_result_json(result)).stringify()
}

///|
/// Render a detailed audit result as compact JSON. It has the stable fields
/// `status`, `findings`, `parse_diagnostics`, and `review_items`.
pub fn render_detailed_audit_json(result : DetailedAuditResult) -> String {
  @json.to_json(detailed_audit_result_json(result)).stringify()
}

///|
fn audit_result_json(result : AuditResult) -> Json {
  match result {
    Findings(findings) =>
      {
        "status": "findings",
        "findings": findings.map(finding_json),
        "parse_diagnostics": [],
      }
    FindingsWithParseErrors(findings, diagnostics) =>
      {
        "status": "findings_with_parse_errors",
        "findings": findings.map(finding_json),
        "parse_diagnostics": diagnostics.map(parse_diagnostic_json),
      }
    ParseErrors(diagnostics) =>
      {
        "status": "parse_errors",
        "findings": [],
        "parse_diagnostics": diagnostics.map(parse_diagnostic_json),
      }
  }
}

///|
fn detailed_audit_result_json(result : DetailedAuditResult) -> Json {
  {
    "status": detailed_status_string(result.status),
    "assessment": detailed_assessment_string(result),
    "findings": result.findings.map(finding_json),
    "parse_diagnostics": result.parse_diagnostics.map(parse_diagnostic_json),
    "review_items": result.review_items.map(review_item_json),
  }
}

///|
fn detailed_assessment_string(result : DetailedAuditResult) -> String {
  if result.status is DetailedAuditStatus::ParseErrors {
    "parse_errors"
  } else if !result.findings.is_empty() {
    "findings"
  } else if !result.review_items.is_empty() {
    "needs_review"
  } else {
    "no_static_findings"
  }
}

///|
fn detailed_status_string(status : DetailedAuditStatus) -> String {
  match status {
    DetailedAuditStatus::Findings => "findings"
    DetailedAuditStatus::FindingsWithParseErrors => "findings_with_parse_errors"
    DetailedAuditStatus::ParseErrors => "parse_errors"
  }
}

///|
fn finding_json(finding : Finding) -> Json {
  {
    "rule_id": finding.rule_id,
    "message": finding.message,
    "suggestion": finding.suggestion,
    "element_path": finding.element_path,
    "line": optional_int_json(finding.line),
    "column": optional_int_json(finding.column),
  }
}

///|
fn parse_diagnostic_json(diagnostic : ParseDiagnostic) -> Json {
  {
    "code": diagnostic.code,
    "message": diagnostic.message,
    "line": optional_int_json(diagnostic.line),
    "column": optional_int_json(diagnostic.column),
  }
}

///|
fn review_item_json(item : ReviewItem) -> Json {
  {
    "rule_id": item.rule_id,
    "reason": item.reason,
    "element_path": item.element_path,
    "line": optional_int_json(item.line),
    "column": optional_int_json(item.column),
  }
}

///|
fn wcag_criterion_json(criterion : WcagCriterion) -> Json {
  {
    "id": criterion.id,
    "title": criterion.title,
    "level": wcag_level_string(criterion.level),
    "in_wcag21": criterion.in_wcag21,
    "in_wcag22": criterion.in_wcag22,
    "coverage_status": wcag_coverage_status_string(criterion.coverage_status),
    "rule_ids": criterion.rule_ids,
    "review_steps": criterion.review_steps,
    "reference_url": criterion.reference_url,
  }
}

///|
fn wcag_level_string(level : WcagLevel) -> String {
  match level {
    WcagLevel::A => "A"
    WcagLevel::AA => "AA"
  }
}

///|
fn wcag_coverage_status_string(status : WcagCoverageStatus) -> String {
  match status {
    WcagCoverageStatus::PartialAutomatic => "partial_automatic"
    WcagCoverageStatus::ManualReview => "manual_review"
    WcagCoverageStatus::NotAssessed => "not_assessed"
    WcagCoverageStatus::RemovedInWcag22 => "removed_in_wcag22"
  }
}

///|
fn optional_int_json(value : Int?) -> Json {
  match value {
    Some(value) => @json.to_json(value)
    None => null
  }
}

///|
fn audit_html_detailed_with_enabled_rules(
  html : String,
  enabled_rule_ids : Array[String],
  is_document : Bool,
) -> DetailedAuditResult {
  let parsed = try {
    if is_document {
      @html_parser.parse(html, collect_errors=true, track_node_locations=true)
    } else {
      @html_parser.parse_fragment(
        html,
        collect_errors=true,
        track_node_locations=true,
      )
    }
  } catch {
    error =>
      return {
        status: DetailedAuditStatus::ParseErrors,
        findings: [],
        parse_diagnostics: [parse_failure(error)],
        review_items: [],
      }
  }
  let context = build_audit_context(parsed.root, is_document)
  let findings = []
  let review_items = []
  let seen_navigation_names : Array[String] = []
  ignore(
    collect_findings(
      parsed.root,
      "",
      None,
      findings,
      review_items,
      enabled_rule_ids,
      None,
      context,
      false,
      seen_navigation_names,
    ),
  )
  let diagnostics = parsed.errors.map(parse_diagnostic)
  let status = if diagnostics.is_empty() {
    DetailedAuditStatus::Findings
  } else {
    DetailedAuditStatus::FindingsWithParseErrors
  }
  { status, findings, parse_diagnostics: diagnostics, review_items, }
}

///|
fn detailed_to_audit_result(result : DetailedAuditResult) -> AuditResult {
  match result.status {
    DetailedAuditStatus::Findings => AuditResult::Findings(result.findings)
    DetailedAuditStatus::FindingsWithParseErrors =>
      AuditResult::FindingsWithParseErrors(
        result.findings,
        result.parse_diagnostics,
      )
    DetailedAuditStatus::ParseErrors =>
      AuditResult::ParseErrors(result.parse_diagnostics)
  }
}

///|
fn looks_like_html_document(source : String) -> Bool {
  let normalized = source.to_lower()
  normalized.contains("") ||
  normalized.contains(" Array[String] {
  rule_catalog().map(fn(rule) { rule.id })
}

///|
fn rule_ids_for_category(category : RuleCategory) -> Array[String] {
  rule_catalog()
  .filter(fn(rule) { rule_category_matches(rule.category, category) })
  .map(fn(rule) { rule.id })
}

///|
fn rule_category_matches(left : RuleCategory, right : RuleCategory) -> Bool {
  match (left, right) {
    (ContentName, ContentName) => true
    (DocumentStructure, DocumentStructure) => true
    (Interaction, Interaction) => true
    (Relationships, Relationships) => true
    (Aria, Aria) => true
    (Timing, Timing) => true
    _ => false
  }
}

///|
fn rule_catalog() -> Array[RuleMetadata] {
  let rules = [
    {
      id: "img-alt-missing",
      summary: "Image omits alt.",
      category: ContentName,
      scope: DocumentOrFragment,
      result_kind: ConfirmedFinding,
      reference_url: "https://www.w3.org/WAI/tutorials/images/",
      boundary: "Checks alt presence, not image purpose or alternative-text quality.",
    },
    {
      id: "form-control-name-missing",
      summary: "Input control lacks a supported static name.",
      category: ContentName,
      scope: DocumentOrFragment,
      result_kind: ConfirmedFinding,
      reference_url: "https://www.w3.org/WAI/tutorials/forms/labels/",
      boundary: "Selected labels, ARIA, and title sources only; not full accessible-name computation.",
    },
    {
      id: "link-name-missing",
      summary: "Link with href lacks a supported static name.",
      category: ContentName,
      scope: DocumentOrFragment,
      result_kind: ConfirmedFinding,
      reference_url: "https://www.w3.org/WAI/standards-guidelines/act/rules/c487ae/",
      boundary: "Does not judge destination clarity and conservatively skips unresolved SVG naming.",
    },
    {
      id: "button-name-missing",
      summary: "Native button lacks a supported static name.",
      category: ContentName,
      scope: DocumentOrFragment,
      result_kind: ConfirmedFinding,
      reference_url: "https://www.w3.org/WAI/standards-guidelines/act/rules/97a4e1/",
      boundary: "Native buttons only; SVG-only naming and custom roles are outside scope.",
    },
    {
      id: "heading-level-skipped",
      summary: "Native heading skips downward levels.",
      category: DocumentStructure,
      scope: DocumentOrFragment,
      result_kind: StaticHint,
      reference_url: "https://www.w3.org/WAI/tutorials/page-structure/headings/",
      boundary: "A structure prompt only; first heading can be any level.",
    },
    {
      id: "heading-name-missing",
      summary: "Native heading lacks a supported static name.",
      category: ContentName,
      scope: DocumentOrFragment,
      result_kind: ConfirmedFinding,
      reference_url: "https://www.w3.org/WAI/tutorials/page-structure/headings/",
      boundary: "Does not infer visual headings or calculate all browser name sources.",
    },
    {
      id: "document-title-missing",
      summary: "Complete document has no non-empty title.",
      category: DocumentStructure,
      scope: Document,
      result_kind: ConfirmedFinding,
      reference_url: "https://www.w3.org/WAI/standards-guidelines/act/rules/2779a5/",
      boundary: "Checks static head title presence, not descriptive quality or uniqueness.",
    },
    {
      id: "html-lang-missing",
      summary: "Complete document html element lacks lang.",
      category: DocumentStructure,
      scope: Document,
      result_kind: ConfirmedFinding,
      reference_url: "https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html",
      boundary: "Does not validate language tags or infer text language.",
    },
    {
      id: "iframe-name-missing",
      summary: "Iframe lacks a supported static name.",
      category: ContentName,
      scope: DocumentOrFragment,
      result_kind: ConfirmedFinding,
      reference_url: "https://www.w3.org/WAI/standards-guidelines/act/rules/cae760/proposed/",
      boundary: "Checks title and selected ARIA sources only.",
    },
    {
      id: "duplicate-id",
      summary: "Later non-empty ID duplicates an earlier ID.",
      category: Relationships,
      scope: DocumentOrFragment,
      result_kind: ConfirmedFinding,
      reference_url: "https://html-validate.org/rules/no-dup-id.html",
      boundary: "Compares the recovered input scope; template descendants are excluded.",
    },
    {
      id: "reference-target-invalid",
      summary: "Selected ID reference has no unique local target.",
      category: Relationships,
      scope: DocumentOrFragment,
      result_kind: ConfirmedFinding,
      reference_url: "https://html-validate.org/rules/no-missing-references.html",
      boundary: "Covers label for, aria-labelledby, and aria-describedby only.",
    },
    {
      id: "label-for-target-not-labelable",
      summary: "Label for attribute targets a built-in element that is not labelable.",
      category: Relationships,
      scope: DocumentOrFragment,
      result_kind: ConfirmedFinding,
      reference_url: "https://html.spec.whatwg.org/multipage/forms.html#the-label-element",
      boundary: "Only reports a unique target that is a known non-labelable built-in HTML element; custom elements are skipped.",
    },
    {
      id: "label-multiple-labelable-descendants",
      summary: "Label contains more than one known labelable built-in descendant.",
      category: Relationships,
      scope: DocumentOrFragment,
      result_kind: ConfirmedFinding,
      reference_url: "https://html.spec.whatwg.org/multipage/forms.html#the-label-element",
      boundary: "Counts known built-in labelable descendants except hidden inputs; custom elements and template contents are skipped.",
    },
    {
      id: "table-headers-invalid",
      summary: "Table headers reference is not a unique same-table cell.",
      category: Relationships,
      scope: DocumentOrFragment,
      result_kind: ConfirmedFinding,
      reference_url: "https://www.w3.org/WAI/standards-guidelines/act/rules/a25f45/",
      boundary: "Does not determine semantic header quality or browser fallback association.",
    },
    {
      id: "area-alt-missing",
      summary: "Clickable image-map area lacks non-empty alt.",
      category: ContentName,
      scope: DocumentOrFragment,
      result_kind: ConfirmedFinding,
      reference_url: "https://www.w3.org/WAI/standards-guidelines/act/rules/c487ae/",
      boundary: "Only area elements with href are checked.",
    },
    {
      id: "aria-hidden-focus-review",
      summary: "Potential focus in an aria-hidden subtree needs browser review.",
      category: Aria,
      scope: DocumentOrFragment,
      result_kind: ManualReview,
      reference_url: "https://www.w3.org/WAI/standards-guidelines/act/rules/6cfa84/",
      boundary: "CSS, scripts, and sequential focus state are not statically known.",
    },
    {
      id: "body-aria-hidden",
      summary: "Document body is hidden from the accessibility tree.",
      category: Aria,
      scope: Document,
      result_kind: ConfirmedFinding,
      reference_url: "https://www.w3.org/WAI/standards-guidelines/act/rules/6cfa84/",
      boundary: "Checks only static body aria-hidden=true.",
    },
    {
      id: "multiple-main",
      summary: "Complete document has multiple native main landmarks.",
      category: DocumentStructure,
      scope: Document,
      result_kind: StaticHint,
      reference_url: "https://html-validate.org/rules/",
      boundary: "Native main only; it is a structure prompt, not a conformance verdict.",
    },
    {
      id: "navigation-landmark-name-missing",
      summary: "Repeated navigation landmark lacks a static distinguishing name.",
      category: DocumentStructure,
      scope: Document,
      result_kind: StaticHint,
      reference_url: "https://html-validate.org/rules/unique-landmark.html",
      boundary: "Native nav and selected static names only; SVG/runtime names become review items.",
    },
    {
      id: "navigation-landmark-name-duplicate",
      summary: "Repeated navigation landmark reuses a static name.",
      category: DocumentStructure,
      scope: Document,
      result_kind: StaticHint,
      reference_url: "https://html-validate.org/rules/unique-landmark.html",
      boundary: "Native nav and selected static names only.",
    },
    {
      id: "aria-abstract-role",
      summary: "Role attribute contains an ARIA 1.2 abstract role.",
      category: Aria,
      scope: DocumentOrFragment,
      result_kind: ConfirmedFinding,
      reference_url: "https://www.w3.org/TR/wai-aria-1.2/#role_definitions",
      boundary: "Does not validate all roles, permissions, or fallback processing.",
    },
    {
      id: "meta-refresh-delay",
      summary: "Meta refresh uses a supported non-zero delay.",
      category: Timing,
      scope: Document,
      result_kind: ConfirmedFinding,
      reference_url: "https://html-validate.org/rules/meta-refresh.html",
      boundary: "Recognizes only numeric content delays; refresh-loop and long-delay policy are outside scope.",
    },
    {
      id: "aria-state-value-invalid",
      summary: "Selected ARIA state has an invalid static token.",
      category: Aria,
      scope: DocumentOrFragment,
      result_kind: ConfirmedFinding,
      reference_url: "https://www.w3.org/WAI/standards-guidelines/act/rules/6a7281/",
      boundary: "Only documented boolean, tristate, and aria-current tokens are checked.",
    },
    {
      id: "button-implicit-submit",
      summary: "Form button omits an explicit type.",
      category: Interaction,
      scope: DocumentOrFragment,
      result_kind: StaticHint,
      reference_url: "https://html-validate.org/rules/no-implicit-button-type.html",
      boundary: "Only native buttons with missing or empty type inside a form are prompted.",
    },
  ]
  for rule in extended_rule_catalog() {
    rules.push(rule)
  }
  rules
}

///|
fn extended_rule_catalog() -> Array[RuleMetadata] {
  let aria = "https://www.w3.org/TR/wai-aria-1.2/"
  let wcag = "https://www.w3.org/TR/WCAG22/"
  [
    rule(
      "aria-attribute-undefined",
      "Element uses an aria-* attribute not defined by WAI-ARIA 1.2.",
      Aria,
      DocumentOrFragment,
      ConfirmedFinding,
      aria,
      "Checks attribute-name membership only; it does not validate role permission or attribute values.",
    ),
    rule(
      "element-lang-invalid",
      "Content element lang lacks a known primary language subtag.",
      DocumentStructure,
      DocumentOrFragment,
      ConfirmedFinding,
      "https://www.w3.org/WAI/standards-guidelines/act/rules/de46e4/",
      "Checks only a non-empty explicit lang value in supplied fragment markup or the recovered body subtree of a complete document; it cannot establish inherited text, flat-tree scope, visibility, or the actual human language.",
    ),
    rule(
      "role-value-invalid",
      "Role token list has no concrete WAI-ARIA 1.2 fallback role.",
      Aria,
      DocumentOrFragment,
      ConfirmedFinding,
      aria,
      "Uses ordered WAI-ARIA 1.2 concrete-role fallback only; host-language extensions are outside scope.",
    ),
    rule(
      "table-scope-invalid",
      "Table header has an explicit invalid scope keyword.",
      Relationships,
      DocumentOrFragment,
      ConfirmedFinding,
      "https://html.spec.whatwg.org/multipage/tables.html#attr-th-scope",
      "Checks explicit scope on th only; absent scope and header association inference are outside scope.",
    ),
    rule(
      "role-button-name-missing",
      "Custom button role lacks a supported static name.",
      ContentName,
      DocumentOrFragment,
      ConfirmedFinding,
      aria,
      "Checks effective role=button only when no native button, link, or form-control name rule already applies; SVG-only cases become review items.",
    ),
    rule(
      "role-link-name-missing",
      "Custom link role lacks a supported static name.",
      ContentName,
      DocumentOrFragment,
      ConfirmedFinding,
      aria,
      "Checks effective role=link only when no native button, link, or form-control name rule already applies; SVG-only cases become review items.",
    ),
    rule(
      "role-radio-name-missing",
      "Custom radio role lacks a supported static name.",
      ContentName,
      DocumentOrFragment,
      ConfirmedFinding,
      aria,
      "Checks effective role=radio only when no native form-control name rule already applies; SVG-only cases become review items.",
    ),
    rule(
      "role-textbox-name-missing",
      "Custom textbox role lacks a supported author-provided static name.",
      ContentName,
      DocumentOrFragment,
      ConfirmedFinding,
      "https://www.w3.org/TR/wai-aria-1.2/#textbox",
      "Checks effective role=textbox only when no native form-control name rule already applies; text content, contenteditable content, and placeholders are not name sources.",
    ),
    rule(
      "role-searchbox-name-missing",
      "Custom searchbox role lacks a supported author-provided static name.",
      ContentName,
      DocumentOrFragment,
      ConfirmedFinding,
      "https://www.w3.org/TR/wai-aria-1.2/#searchbox",
      "Checks effective role=searchbox only when no native form-control name rule already applies; text content, contenteditable content, and placeholders are not name sources.",
    ),
    rule(
      "aria-required-property-missing",
      "Selected ARIA role omits a required state or property.",
      Aria,
      DocumentOrFragment,
      ConfirmedFinding,
      aria,
      "Checks only selected roles: checkbox/switch/radio checked, combobox expanded, slider valuenow, and scrollbar controls plus valuenow.",
    ),
    rule(
      "aria-range-value-invalid",
      "Custom slider or scrollbar has invalid or inconsistent numeric range values.",
      Aria,
      DocumentOrFragment,
      ConfirmedFinding,
      "https://www.w3.org/TR/wai-aria-1.2/#aria-valuenow",
      "Checks explicit finite decimal min/max/now values on effective custom slider and scrollbar roles, using 0 and 100 only when min/max are absent; missing valuenow remains the required-property rule.",
    ),
    rule(
      "aria-idref-invalid",
      "Selected ARIA ID reference has no unique local target.",
      Relationships,
      DocumentOrFragment,
      ConfirmedFinding,
      aria,
      "Covers aria-controls, aria-owns, aria-activedescendant, and aria-details; empty values are ignored.",
    ),
    rule(
      "role-checkbox-name-missing",
      "Checkbox role lacks a supported static name.",
      ContentName,
      DocumentOrFragment,
      ConfirmedFinding,
      aria,
      "Checks role=checkbox only; SVG-only and runtime names become review items.",
    ),
    rule(
      "role-combobox-name-missing",
      "Combobox role lacks a supported static name.",
      ContentName,
      DocumentOrFragment,
      ConfirmedFinding,
      aria,
      "Checks role=combobox only; no browser accessible-name computation.",
    ),
    rule(
      "role-slider-name-missing",
      "Slider role lacks a supported static name.",
      ContentName,
      DocumentOrFragment,
      ConfirmedFinding,
      aria,
      "Checks role=slider only; SVG-only and runtime names become review items.",
    ),
    rule(
      "role-progressbar-name-missing",
      "Progressbar role lacks a supported static name.",
      ContentName,
      DocumentOrFragment,
      ConfirmedFinding,
      aria,
      "Checks role=progressbar only; value semantics are outside this rule.",
    ),
    rule(
      "role-meter-name-missing",
      "Meter role lacks a supported static name.",
      ContentName,
      DocumentOrFragment,
      ConfirmedFinding,
      aria,
      "Checks role=meter only; value semantics are outside this rule.",
    ),
    rule(
      "role-img-name-missing",
      "Image role lacks a supported static name.",
      ContentName,
      DocumentOrFragment,
      ConfirmedFinding,
      aria,
      "Checks role=img only; nested SVG naming is reviewed conservatively.",
    ),
    rule(
      "role-dialog-name-missing",
      "Dialog role lacks a supported static name.",
      ContentName,
      DocumentOrFragment,
      ConfirmedFinding,
      aria,
      "Checks role=dialog and role=alertdialog only.",
    ),
    rule(
      "role-heading-level-missing",
      "ARIA heading lacks an integer aria-level from 1 to 6.",
      Aria,
      DocumentOrFragment,
      ConfirmedFinding,
      aria,
      "Checks only role=heading and a simple integer token.",
    ),
    rule(
      "role-tab-selected-missing",
      "Tab role omits aria-selected.",
      Aria,
      DocumentOrFragment,
      ConfirmedFinding,
      aria,
      "Checks role=tab only; selected-state validity remains covered by aria-state-value-invalid.",
    ),
    rule(
      "role-switch-checked-missing",
      "Switch role omits aria-checked.",
      Aria,
      DocumentOrFragment,
      ConfirmedFinding,
      aria,
      "Checks role=switch only.",
    ),
    rule(
      "nested-interactive",
      "Interactive content is nested inside an interactive ancestor.",
      DocumentStructure,
      DocumentOrFragment,
      ConfirmedFinding,
      wcag,
      "Conservatively checks interactive descendants of links and buttons, excluding label/control relationships.",
    ),
    rule(
      "list-structure-invalid",
      "List has a direct child other than list item or inert markup.",
      DocumentStructure,
      DocumentOrFragment,
      ConfirmedFinding,
      wcag,
      "Checks direct ul/ol children only; parser recovery can change malformed structures.",
    ),
    rule(
      "definition-list-structure-invalid",
      "Definition list has an unsupported direct child.",
      DocumentStructure,
      DocumentOrFragment,
      ConfirmedFinding,
      wcag,
      "Checks direct dl children against dt, dd, div, script, and template.",
    ),
    rule(
      "table-header-name-missing",
      "Table header cell lacks a supported static name.",
      ContentName,
      DocumentOrFragment,
      ConfirmedFinding,
      wcag,
      "Checks th only; header scope and visual relationships need separate review.",
    ),
    rule(
      "viewport-zoom-disabled",
      "Viewport metadata disables or severely limits zoom.",
      DocumentStructure,
      Document,
      StaticHint,
      wcag,
      "Static token check only; rendered zoom behavior and WCAG exceptions require review.",
    ),
    rule(
      "media-alternative-review",
      "Media needs review for equivalent alternatives and captions.",
      ContentName,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "Audio/video tracks and hosted media behavior cannot be proved from static HTML.",
    ),
    rule(
      "media-autoplay-audio-review",
      "Autoplaying media may need an audio control.",
      Timing,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "Static HTML cannot determine audibility, duration, or runtime controls.",
    ),
    rule(
      "keyboard-operation-review",
      "Custom interactive role needs keyboard-operation review.",
      Interaction,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "Keyboard handlers, script behavior, and focus movement require a browser.",
    ),
    rule(
      "keyboard-trap-review",
      "Dialog-like component needs keyboard-trap review.",
      Interaction,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "Static markup cannot prove focus can leave a component.",
    ),
    rule(
      "character-key-shortcut-review",
      "Access-key shortcut needs a WCAG 2.1.4 review.",
      Interaction,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "Verify disable, remap, or focus-only behavior in the running page.",
    ),
    rule(
      "focus-order-review",
      "Focusable element needs rendered focus-order review.",
      Interaction,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "CSS, DOM mutation, and script determine the actual sequence.",
    ),
    rule(
      "focus-visible-review",
      "Focusable element needs visible-focus review.",
      Interaction,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "Focus indicator is a rendered-state question.",
    ),
    rule(
      "focus-obscured-review",
      "Focusable element needs focus-obscured review.",
      Interaction,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "CSS geometry, overlays, and browser viewport are unavailable in static HTML.",
    ),
    rule(
      "target-size-review",
      "Pointer target needs rendered-size review.",
      Interaction,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "CSS pixels, spacing, and WCAG exceptions require rendered layout.",
    ),
    rule(
      "dragging-movement-review",
      "Draggable element needs a non-dragging alternative review.",
      Interaction,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "A draggable attribute alone cannot establish all interactions or exceptions.",
    ),
    rule(
      "pointer-cancellation-review",
      "Pointer down-event handler needs cancellation review.",
      Interaction,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "Script event behavior cannot be inferred from an event attribute.",
    ),
    rule(
      "motion-actuation-review",
      "Motion event handler needs an alternative-control review.",
      Interaction,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "Device motion behavior is runtime-only.",
    ),
    rule(
      "label-in-name-review",
      "Visible control label and computed name need comparison.",
      ContentName,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "Static text is not a full browser accessible-name computation.",
    ),
    rule(
      "language-of-parts-review",
      "Element language change needs language-of-parts review.",
      ContentName,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "Static HTML cannot identify every natural-language change or exception.",
    ),
    rule(
      "on-focus-context-review",
      "Focus event handler needs unexpected-context-change review.",
      Interaction,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "Event handler text does not prove browser behavior.",
    ),
    rule(
      "on-input-context-review",
      "Input change handler needs unexpected-context-change review.",
      Interaction,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "Event handler text does not prove browser behavior.",
    ),
    rule(
      "form-error-review",
      "Form needs error identification and suggestion review.",
      Interaction,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "Validation behavior and submitted errors are runtime concerns.",
    ),
    rule(
      "authentication-review",
      "Password control needs accessible-authentication review.",
      Interaction,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "Authentication flow and cognitive-function tests are not decidable statically.",
    ),
    rule(
      "status-message-review",
      "Live-region status message needs assistive-technology review.",
      Aria,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "Dynamic update timing and exposure need a browser and assistive technology.",
    ),
    rule(
      "bypass-blocks-review",
      "Complete page needs a repeated-block bypass review.",
      DocumentStructure,
      Document,
      ManualReview,
      wcag,
      "Cross-page repetition and keyboard operation cannot be established from one DOM.",
    ),
    rule(
      "meaningful-sequence-review",
      "Content needs rendered-order review.",
      DocumentStructure,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "CSS layout and responsive order are outside static parsing.",
    ),
    rule(
      "contrast-review",
      "Text and non-text contrast need rendered-color review.",
      ContentName,
      DocumentOrFragment,
      ManualReview,
      wcag,
      "Computed color, background, states, and imagery require rendering.",
    ),
    rule(
      "text-resize-review",
      "Text resize and reflow need browser review.",
      DocumentStructure,
      Document,
      ManualReview,
      wcag,
      "Zoom and viewport behavior require rendered testing.",
    ),
    rule(
      "aria-checked-role-incompatible",
      "Explicit role does not support aria-checked.",
      Aria,
      DocumentOrFragment,
      ConfirmedFinding,
      "https://www.w3.org/TR/wai-aria-1.2/#aria-checked",
      "Checks aria-checked only on an effective explicit WAI-ARIA 1.2 role; native host-language semantics and other ARIA properties are outside scope.",
    ),
    rule(
      "listitem-orphan",
      "List item is not a direct child of a list container.",
      DocumentStructure,
      DocumentOrFragment,
      ConfirmedFinding,
      "https://html.spec.whatwg.org/multipage/grouping-content.html#the-li-element",
      "Checks recovered li parentage only; parser recovery can change malformed source structure.",
    ),
    rule(
      "fieldset-legend-missing",
      "Fieldset grouping multiple known controls lacks a named first legend.",
      DocumentStructure,
      DocumentOrFragment,
      StaticHint,
      "https://www.w3.org/WAI/tutorials/forms/grouping/",
      "Prompts only fieldsets with multiple known native labelable descendants; it does not prove a WCAG failure or inspect custom form association.",
    ),
    rule(
      "aria-role-attribute-incompatible",
      "Explicit ARIA role does not support a selected ARIA attribute.",
      Aria,
      DocumentOrFragment,
      ConfirmedFinding,
      "https://www.w3.org/TR/wai-aria-1.2/#state_prop_values",
      "Checks only the documented explicit-role/property pairs in A11yTrace's limited WAI-ARIA 1.2 table; global attributes, unmodeled roles/properties, native form controls, and host-language constraints are outside scope.",
    ),
    rule(
      "html-lang-invalid",
      "Complete document html lang lacks a known primary language subtag.",
      DocumentStructure,
      Document,
      ConfirmedFinding,
      "https://www.w3.org/WAI/standards-guidelines/act/rules/html-page-lang-valid-bf051a/",
      "Checks a non-empty static lang value against the generated IANA primary-language set and basic separator shape only; it does not infer the page language or validate later BCP 47 subtags.",
    ),
    rule(
      "aria-required-context-role",
      "Selected ARIA role lacks a modeled required context relationship.",
      Aria,
      DocumentOrFragment,
      ConfirmedFinding,
      "https://www.w3.org/TR/wai-aria-1.2/#required-context-role",
      "Checks only documented tab, option, table, listitem, menu-item, and treeitem context pairs against recovered DOM ancestry or one unique valid local aria-owns owner. Root-level, hidden, presentational, ambiguous, and unmodeled relationships are deliberately skipped because static DOM is not the browser accessibility tree.",
    ),
  ]
}

///|
fn rule(
  id : String,
  summary : String,
  category : RuleCategory,
  scope : RuleScope,
  result_kind : RuleResultKind,
  reference_url : String,
  boundary : String,
) -> RuleMetadata {
  { id, summary, category, scope, result_kind, reference_url, boundary, }
}

///|
fn wcag_entry(
  id : String,
  title : String,
  level : WcagLevel,
  in_wcag22 : Bool,
  coverage_status : WcagCoverageStatus,
  rule_ids : Array[String],
  review_steps : String,
) -> WcagCriterion {
  {
    id,
    title,
    level,
    in_wcag21: true,
    in_wcag22,
    coverage_status,
    rule_ids,
    review_steps,
    reference_url: "https://www.w3.org/TR/WCAG22/",
  }
}

///|
fn wcag_criteria_catalog() -> Array[WcagCriterion] {
  [
    wcag_entry(
      "1.1.1",
      "Non-text Content",
      A,
      true,
      PartialAutomatic,
      ["img-alt-missing", "area-alt-missing"],
      "Review image purpose and alternative-text quality with subject-matter context.",
    ),
    wcag_entry(
      "1.2.1",
      "Audio-only and Video-only (Prerecorded)",
      A,
      true,
      ManualReview,
      [],
      "For each prerecorded audio-only or video-only asset, verify an equivalent transcript or media alternative.",
    ),
    wcag_entry(
      "1.2.2",
      "Captions (Prerecorded)",
      A,
      true,
      ManualReview,
      [],
      "Play each prerecorded video with sound and verify synchronized captions convey dialogue and important sounds.",
    ),
    wcag_entry(
      "1.2.3",
      "Audio Description or Media Alternative (Prerecorded)",
      A,
      true,
      ManualReview,
      [],
      "Verify prerecorded video has audio description or a complete media alternative where visual information is needed.",
    ),
    wcag_entry(
      "1.2.4",
      "Captions (Live)",
      AA,
      true,
      ManualReview,
      [],
      "For each live synchronized media experience, verify real-time captions are available.",
    ),
    wcag_entry(
      "1.2.5",
      "Audio Description (Prerecorded)",
      AA,
      true,
      ManualReview,
      [],
      "Verify prerecorded video has audio description for meaningful visual information.",
    ),
    wcag_entry(
      "1.3.1",
      "Info and Relationships",
      A,
      true,
      PartialAutomatic,
      ["table-headers-invalid", "heading-level-skipped"],
      "Review visual relationships, grouping, and semantics not represented by the checked HTML structures.",
    ),
    wcag_entry(
      "1.3.2",
      "Meaningful Sequence",
      A,
      true,
      ManualReview,
      [],
      "Read the DOM order and rendered order with CSS disabled or assistive technology; verify meaning is preserved.",
    ),
    wcag_entry(
      "1.3.3",
      "Sensory Characteristics",
      A,
      true,
      NotAssessed,
      [],
      "Check instructions do not rely solely on shape, color, size, visual location, orientation, or sound.",
    ),
    wcag_entry(
      "1.3.4",
      "Orientation",
      AA,
      true,
      NotAssessed,
      [],
      "Test portrait and landscape orientations and verify no non-essential orientation lock blocks operation.",
    ),
    wcag_entry(
      "1.3.5",
      "Identify Input Purpose",
      AA,
      true,
      ManualReview,
      [],
      "For personal-data inputs, verify valid autocomplete purpose tokens and that the intended purpose applies.",
    ),
    wcag_entry(
      "1.4.1",
      "Use of Color",
      A,
      true,
      NotAssessed,
      [],
      "Inspect rendered UI to verify color is not the sole means of conveying information or state.",
    ),
    wcag_entry(
      "1.4.2",
      "Audio Control",
      A,
      true,
      ManualReview,
      [],
      "Play media and verify audio that starts automatically for more than three seconds can be paused, stopped, or independently controlled.",
    ),
    wcag_entry(
      "1.4.3",
      "Contrast (Minimum)",
      AA,
      true,
      ManualReview,
      [],
      "Measure rendered text contrast in each relevant state, including text over images and gradients.",
    ),
    wcag_entry(
      "1.4.4",
      "Resize Text",
      AA,
      true,
      ManualReview,
      [],
      "Zoom text to 200% in a browser and verify content and functionality remain available.",
    ),
    wcag_entry(
      "1.4.5",
      "Images of Text",
      AA,
      true,
      NotAssessed,
      [],
      "Review images of text and verify real text is used unless an exception applies.",
    ),
    wcag_entry(
      "1.4.10",
      "Reflow",
      AA,
      true,
      ManualReview,
      [],
      "At 320 CSS pixels wide / 400% zoom, verify content reflows without two-dimensional scrolling except allowed cases.",
    ),
    wcag_entry(
      "1.4.11",
      "Non-text Contrast",
      AA,
      true,
      ManualReview,
      [],
      "Measure contrast of controls, focus indicators, and meaningful graphical objects in rendered states.",
    ),
    wcag_entry(
      "1.4.12",
      "Text Spacing",
      AA,
      true,
      NotAssessed,
      [],
      "Apply WCAG text-spacing overrides and verify no clipping, overlap, or lost functionality.",
    ),
    wcag_entry(
      "1.4.13",
      "Content on Hover or Focus",
      AA,
      true,
      NotAssessed,
      [],
      "For hover/focus-triggered content, verify it is dismissible, hoverable, and persistent as required.",
    ),
    wcag_entry(
      "2.1.1",
      "Keyboard",
      A,
      true,
      ManualReview,
      [],
      "Operate every interaction by keyboard alone and verify no pointer-only action is required.",
    ),
    wcag_entry(
      "2.1.2",
      "No Keyboard Trap",
      A,
      true,
      ManualReview,
      [],
      "Move focus into and out of each component with standard keyboard commands and verify escape is possible.",
    ),
    wcag_entry(
      "2.1.4",
      "Character Key Shortcuts",
      A,
      true,
      ManualReview,
      [],
      "For single-character shortcuts, verify they can be turned off, remapped, or are active only on focus.",
    ),
    wcag_entry(
      "2.2.1",
      "Timing Adjustable",
      A,
      true,
      PartialAutomatic,
      ["meta-refresh-delay"],
      "Review every time limit and verify users can turn off, adjust, or extend it when required.",
    ),
    wcag_entry(
      "2.2.2",
      "Pause, Stop, Hide",
      A,
      true,
      NotAssessed,
      [],
      "Verify moving, blinking, scrolling, or auto-updating content can be paused, stopped, or hidden when applicable.",
    ),
    wcag_entry(
      "2.3.1",
      "Three Flashes or Below Threshold",
      A,
      true,
      NotAssessed,
      [],
      "Inspect animated media and rendered effects for flash frequency and threshold using an appropriate analysis tool.",
    ),
    wcag_entry(
      "2.4.1",
      "Bypass Blocks",
      A,
      true,
      ManualReview,
      [],
      "Verify a skip mechanism or landmark navigation bypasses repeated blocks on each page.",
    ),
    wcag_entry(
      "2.4.2",
      "Page Titled",
      A,
      true,
      PartialAutomatic,
      ["document-title-missing"],
      "Review that each title describes its page purpose or topic.",
    ),
    wcag_entry(
      "2.4.3",
      "Focus Order",
      A,
      true,
      ManualReview,
      [],
      "Tab through each workflow and verify focus order preserves meaning and operability.",
    ),
    wcag_entry(
      "2.4.4",
      "Link Purpose (In Context)",
      A,
      true,
      PartialAutomatic,
      ["link-name-missing"],
      "Review whether each non-empty link name identifies its purpose in context.",
    ),
    wcag_entry(
      "2.4.5",
      "Multiple Ways",
      AA,
      true,
      NotAssessed,
      [],
      "For a set of pages, verify more than one way to locate pages unless an exception applies.",
    ),
    wcag_entry(
      "2.4.6",
      "Headings and Labels",
      AA,
      true,
      PartialAutomatic,
      ["heading-name-missing", "heading-level-skipped"],
      "Review whether headings and labels describe topic or purpose.",
    ),
    wcag_entry(
      "2.4.7",
      "Focus Visible",
      AA,
      true,
      ManualReview,
      [],
      "Use keyboard navigation and verify a visible focus indicator in every keyboard-operable state.",
    ),
    wcag_entry(
      "2.4.11",
      "Focus Not Obscured (Minimum)",
      AA,
      true,
      ManualReview,
      [],
      "At each focusable target, verify the focus indicator is not entirely hidden by author-created content.",
    ),
    wcag_entry(
      "2.5.1",
      "Pointer Gestures",
      A,
      true,
      ManualReview,
      [],
      "Verify multipoint or path-based gestures have a single-pointer alternative unless essential.",
    ),
    wcag_entry(
      "2.5.2",
      "Pointer Cancellation",
      A,
      true,
      ManualReview,
      [],
      "Verify pointer activation is not irrevocable on down-event unless an exception applies.",
    ),
    wcag_entry(
      "2.5.3",
      "Label in Name",
      A,
      true,
      ManualReview,
      [],
      "Compare each visible control label with its computed accessible name; the visible text must be included.",
    ),
    wcag_entry(
      "2.5.4",
      "Motion Actuation",
      A,
      true,
      ManualReview,
      [],
      "Verify motion-operated functionality has a UI alternative and can be disabled when required.",
    ),
    wcag_entry(
      "2.5.7",
      "Dragging Movements",
      AA,
      true,
      ManualReview,
      [],
      "For drag interactions, verify a non-dragging single-pointer alternative is available unless essential.",
    ),
    wcag_entry(
      "2.5.8",
      "Target Size (Minimum)",
      AA,
      true,
      ManualReview,
      [],
      "Measure rendered pointer target sizes and spacing; evaluate WCAG exceptions before deciding.",
    ),
    wcag_entry(
      "3.1.1",
      "Language of Page",
      A,
      true,
      PartialAutomatic,
      ["html-lang-missing", "html-lang-invalid"],
      "Verify the non-empty lang value accurately identifies the default language and page context.",
    ),
    wcag_entry(
      "3.1.2",
      "Language of Parts",
      AA,
      true,
      PartialAutomatic,
      ["element-lang-invalid"],
      "Review passages in another human language and verify accurate lang changes, inherited text, and exceptions where they apply.",
    ),
    wcag_entry(
      "3.2.1",
      "On Focus",
      A,
      true,
      ManualReview,
      [],
      "Focus each component and verify focus alone does not unexpectedly change context.",
    ),
    wcag_entry(
      "3.2.2",
      "On Input",
      A,
      true,
      ManualReview,
      [],
      "Change inputs and verify input alone does not unexpectedly change context without prior notice.",
    ),
    wcag_entry(
      "3.2.3",
      "Consistent Navigation",
      AA,
      true,
      NotAssessed,
      [],
      "Across a page set, compare repeated navigation order for the same page variation.",
    ),
    wcag_entry(
      "3.2.4",
      "Consistent Identification",
      AA,
      true,
      NotAssessed,
      [],
      "Across a page set, verify components with the same function are identified consistently.",
    ),
    wcag_entry(
      "3.2.6",
      "Consistent Help",
      A,
      true,
      NotAssessed,
      [],
      "Across a page set, verify repeated help mechanisms remain in the same relative order.",
    ),
    wcag_entry(
      "3.3.1",
      "Error Identification",
      A,
      true,
      ManualReview,
      [],
      "Submit invalid form data and verify errors are identified in text to users.",
    ),
    wcag_entry(
      "3.3.2",
      "Labels or Instructions",
      A,
      true,
      PartialAutomatic,
      ["form-control-name-missing"],
      "Review instructions, required-state communication, and whether labels describe purpose.",
    ),
    wcag_entry(
      "3.3.3",
      "Error Suggestion",
      AA,
      true,
      ManualReview,
      [],
      "Submit correctable invalid data and verify suggestions are provided when known and appropriate.",
    ),
    wcag_entry(
      "3.3.4",
      "Error Prevention (Legal, Financial, Data)",
      AA,
      true,
      ManualReview,
      [],
      "For consequential submissions, verify reversible, checked, or review-and-confirm mechanisms.",
    ),
    wcag_entry(
      "3.3.7",
      "Redundant Entry",
      A,
      true,
      NotAssessed,
      [],
      "Test multi-step flows and verify previously supplied information is auto-populated or selectable when applicable.",
    ),
    wcag_entry(
      "3.3.8",
      "Accessible Authentication (Minimum)",
      AA,
      true,
      ManualReview,
      [],
      "Test authentication without relying on a cognitive function test, or verify an applicable exception.",
    ),
    wcag_entry(
      "4.1.1",
      "Parsing",
      A,
      false,
      RemovedInWcag22,
      ["duplicate-id"],
      "Historical WCAG 2.1 entry only: do not report it as a WCAG 2.2 criterion.",
    ),
    wcag_entry(
      "4.1.2",
      "Name, Role, Value",
      A,
      true,
      PartialAutomatic,
      [
        "form-control-name-missing", "link-name-missing", "button-name-missing",
        "aria-state-value-invalid",
      ],
      "Review roles, states, properties, and dynamic changes not covered by static checks.",
    ),
    wcag_entry(
      "4.1.3",
      "Status Messages",
      AA,
      true,
      ManualReview,
      [],
      "Trigger status updates and verify they are exposed to assistive technologies without moving focus.",
    ),
  ]
}

///|
fn unknown_rule_id(rule_ids : Array[String]) -> String? {
  for rule_id in rule_ids {
    if !is_available_rule_id(rule_id) {
      return Some(rule_id)
    }
  }
  None
}

///|
fn is_available_rule_id(rule_id : String) -> Bool {
  rule_catalog().any(fn(rule) { rule.id == rule_id })
}

///|
fn is_rule_enabled(enabled_rule_ids : Array[String], rule_id : String) -> Bool {
  for enabled_rule_id in enabled_rule_ids {
    if enabled_rule_id == rule_id {
      return true
    }
  }
  false
}

///|
priv struct AuditContext {
  id_nodes : Map[String, Array[@dom.Node]]
  id_paths : Map[String, Array[String]]
  id_table_paths : Map[String, Array[String?]]
  aria_owners_by_id : Map[String, Array[@dom.Node]]
  labels_by_for : Map[String, Array[@dom.Node]]
  page_has_nonempty_title : Bool
  first_page_title_path : String?
  main_paths : Array[String]
  navigation_nodes : Array[(@dom.Node, String)]
  is_document : Bool
}

///|
fn build_audit_context(root : @dom.Node, is_document : Bool) -> AuditContext {
  let id_nodes : Map[String, Array[@dom.Node]] = Map([])
  let id_paths : Map[String, Array[String]] = Map([])
  let id_table_paths : Map[String, Array[String?]] = Map([])
  let aria_owners_by_id : Map[String, Array[@dom.Node]] = Map([])
  let labels_by_for : Map[String, Array[@dom.Node]] = Map([])
  let page_titles : Array[(@dom.Node, String)] = []
  let main_paths : Array[String] = []
  let navigation_nodes : Array[(@dom.Node, String)] = []
  index_context_children(
    root,
    "",
    false,
    None,
    id_nodes,
    id_paths,
    id_table_paths,
    aria_owners_by_id,
    labels_by_for,
    page_titles,
    main_paths,
    navigation_nodes,
  )
  let mut page_has_nonempty_title = false
  let mut first_page_title_path = None
  if is_document {
    for entry in page_titles {
      let (title, path) = entry
      if first_page_title_path is None {
        first_page_title_path = Some(path)
      }
      if has_nonempty_visible_text(title) {
        page_has_nonempty_title = true
      }
    }
  }
  {
    id_nodes,
    id_paths,
    id_table_paths,
    aria_owners_by_id,
    labels_by_for,
    page_has_nonempty_title,
    first_page_title_path,
    main_paths,
    navigation_nodes,
    is_document,
  }
}

///|
fn index_context_children(
  node : @dom.Node,
  parent_path : String,
  inside_head : Bool,
  enclosing_table_path : String?,
  id_nodes : Map[String, Array[@dom.Node]],
  id_paths : Map[String, Array[String]],
  id_table_paths : Map[String, Array[String?]],
  aria_owners_by_id : Map[String, Array[@dom.Node]],
  labels_by_for : Map[String, Array[@dom.Node]],
  page_titles : Array[(@dom.Node, String)],
  main_paths : Array[String],
  navigation_nodes : Array[(@dom.Node, String)],
) -> Unit {
  let children = node.children()
  let mut position = 0
  while position < children.length() {
    let child = children[position]
    if child.kind() is @dom.Element {
      let name = child.name()
      let occurrence = nth_of_type(children, position, name)
      let path = child_path(parent_path, name, occurrence)
      index_context_element(
        child, path, enclosing_table_path, id_nodes, id_paths, id_table_paths, aria_owners_by_id,
        labels_by_for,
      )
      let child_inside_head = inside_head || name == "head"
      let child_table_path = if name == "table" {
        Some(path)
      } else {
        enclosing_table_path
      }
      if child_inside_head && name == "title" {
        page_titles.push((child, path))
      }
      if name == "main" {
        main_paths.push(path)
      }
      if name == "nav" {
        navigation_nodes.push((child, path))
      }
      // Template contents are inert markup, not part of this audit's input
      // scope. The template element itself can still be indexed.
      if name != "template" {
        index_context_children(
          child, path, child_inside_head, child_table_path, id_nodes, id_paths, id_table_paths,
          aria_owners_by_id, labels_by_for, page_titles, main_paths, navigation_nodes,
        )
      }
    } else {
      index_context_children(
        child, parent_path, inside_head, enclosing_table_path, id_nodes, id_paths,
        id_table_paths, aria_owners_by_id, labels_by_for, page_titles, main_paths,
        navigation_nodes,
      )
    }
    position = position + 1
  }
}

///|
fn index_context_element(
  node : @dom.Node,
  path : String,
  enclosing_table_path : String?,
  id_nodes : Map[String, Array[@dom.Node]],
  id_paths : Map[String, Array[String]],
  id_table_paths : Map[String, Array[String?]],
  aria_owners_by_id : Map[String, Array[@dom.Node]],
  labels_by_for : Map[String, Array[@dom.Node]],
) -> Unit {
  match node.attrs().get("id") {
    Some(Some(id)) if !id.trim().is_empty() => {
      append_index_node(id_nodes, id, node)
      append_index_path(id_paths, id, path)
      append_index_table_path(id_table_paths, id, enclosing_table_path)
    }
    _ => ()
  }
  if node.name() == "label" {
    match node.attrs().get("for") {
      Some(Some(id)) if !id.trim().is_empty() =>
        append_index_node(labels_by_for, id, node)
      _ => ()
    }
  }
  if node.name() != "template" {
    match node.attrs().get("aria-owns") {
      Some(Some(value)) =>
        for id in idref_tokens(value) {
          append_index_node(aria_owners_by_id, id, node)
        }
      _ => ()
    }
  }
}

///|
fn append_index_node(
  index : Map[String, Array[@dom.Node]],
  key : String,
  node : @dom.Node,
) -> Unit {
  match index.get(key) {
    Some(nodes) => nodes.push(node)
    None => index[key] = [node]
  }
}

///|
fn append_index_path(
  index : Map[String, Array[String]],
  key : String,
  path : String,
) -> Unit {
  match index.get(key) {
    Some(paths) => paths.push(path)
    None => index[key] = [path]
  }
}

///|
fn append_index_table_path(
  index : Map[String, Array[String?]],
  key : String,
  table_path : String?,
) -> Unit {
  match index.get(key) {
    Some(paths) => paths.push(table_path)
    None => index[key] = [table_path]
  }
}

///|
fn collect_findings(
  node : @dom.Node,
  parent_path : String,
  enclosing_table_path : String?,
  findings : Array[Finding],
  review_items : Array[ReviewItem],
  enabled_rule_ids : Array[String],
  previous_heading_level : Int?,
  context : AuditContext,
  aria_hidden_ancestor : Bool,
  seen_navigation_names : Array[String],
) -> Int? {
  let children = node.children()
  let mut last_heading_level = previous_heading_level
  let mut position = 0
  while position < children.length() {
    let child = children[position]
    if child.kind() is @dom.Element {
      let name = child.name()
      let occurrence = nth_of_type(children, position, name)
      let path = child_path(parent_path, name, occurrence)
      let child_table_path = if name == "table" {
        Some(path)
      } else {
        enclosing_table_path
      }
      let hidden_by_aria = aria_hidden_ancestor || has_aria_hidden_true(child)
      if context.is_document && name == "html" {
        if is_rule_enabled(enabled_rule_ids, "html-lang-missing") &&
          !has_nonempty_attribute(child, "lang") {
          findings.push(missing_html_lang_finding(child, path))
        }
        if is_rule_enabled(enabled_rule_ids, "html-lang-invalid") &&
          has_nonempty_attribute(child, "lang") &&
          !has_known_primary_language_tag(child) {
          findings.push(invalid_html_lang_finding(child, path))
        }
        if is_rule_enabled(enabled_rule_ids, "document-title-missing") &&
          !context.page_has_nonempty_title &&
          context.first_page_title_path is None {
          findings.push(missing_document_title_finding(child, path))
        }
      }
      if is_rule_enabled(enabled_rule_ids, "element-lang-invalid") &&
        is_element_lang_in_audited_content_scope(child, context) &&
        !has_known_primary_language_tag(child) {
        findings.push(invalid_element_lang_finding(child, path))
      }
      if context.is_document &&
        name == "body" &&
        is_rule_enabled(enabled_rule_ids, "body-aria-hidden") &&
        has_aria_hidden_true(child) {
        findings.push(body_aria_hidden_finding(child, path))
      }
      if context.is_document &&
        name == "main" &&
        is_rule_enabled(enabled_rule_ids, "multiple-main") &&
        context.main_paths.length() > 1 &&
        context.main_paths[0] != path {
        findings.push(multiple_main_finding(child, path))
      }
      if context.is_document &&
        name == "nav" &&
        context.navigation_nodes.length() > 1 {
        collect_navigation_landmark_findings(
          child, path, findings, review_items, enabled_rule_ids, context, seen_navigation_names,
        )
      }
      if is_rule_enabled(enabled_rule_ids, "aria-abstract-role") &&
        has_abstract_role_token(child) {
        findings.push(abstract_role_finding(child, path))
      }
      if is_rule_enabled(enabled_rule_ids, "aria-attribute-undefined") &&
        has_undefined_aria_attribute(child) {
        findings.push(
          generic_finding(
            child, path, "aria-attribute-undefined", "This element uses an aria-* attribute not defined by WAI-ARIA 1.2.",
            "Use a WAI-ARIA 1.2 attribute name, or remove the unsupported aria-* attribute.",
          ),
        )
      }
      if is_rule_enabled(enabled_rule_ids, "role-value-invalid") &&
        has_role_value_without_known_fallback(child) {
        findings.push(
          generic_finding(
            child, path, "role-value-invalid", "This role value has no concrete WAI-ARIA 1.2 fallback role.",
            "Use a concrete WAI-ARIA 1.2 role token, or remove the invalid role attribute.",
          ),
        )
      }
      let checked_role_incompatible = has_aria_checked_incompatible_with_explicit_role(
        child,
      )
      if is_rule_enabled(enabled_rule_ids, "aria-state-value-invalid") &&
        (
          has_invalid_selected_aria_state_except_checked(child) ||
          (
            has_invalid_aria_checked_value(child) &&
            !(checked_role_incompatible &&
            is_rule_enabled(enabled_rule_ids, "aria-checked-role-incompatible"))
          )
        ) {
        findings.push(invalid_aria_state_value_finding(child, path))
      }
      collect_extended_aria_findings(
        child, path, findings, review_items, enabled_rule_ids, context,
      )
      collect_extended_structure_findings(
        child, path, findings, enabled_rule_ids, context,
      )
      collect_extended_review_items(
        child, path, review_items, enabled_rule_ids, context,
      )
      if context.is_document &&
        is_rule_enabled(enabled_rule_ids, "meta-refresh-delay") &&
        has_supported_nonzero_meta_refresh_delay(child) {
        findings.push(meta_refresh_delay_finding(child, path))
      }
      if is_rule_enabled(enabled_rule_ids, "aria-hidden-focus-review") &&
        hidden_by_aria &&
        is_potentially_focusable(child) {
        review_items.push(aria_hidden_focus_review_item(child, path))
      }
      if context.is_document &&
        name == "title" &&
        is_rule_enabled(enabled_rule_ids, "document-title-missing") &&
        !context.page_has_nonempty_title &&
        context.first_page_title_path is Some(title_path) &&
        path == title_path {
        findings.push(missing_document_title_finding(child, path))
      }
      if is_rule_enabled(enabled_rule_ids, "img-alt-missing") &&
        name == "img" &&
        child.attrs().get("alt") is None {
        findings.push(missing_alt_finding(child, path))
      }
      if is_rule_enabled(enabled_rule_ids, "duplicate-id") &&
        is_later_duplicate_id(child, path, context) {
        findings.push(duplicate_id_finding(child, path))
      }
      if is_rule_enabled(enabled_rule_ids, "reference-target-invalid") &&
        has_invalid_reference_target(child, context) {
        findings.push(invalid_reference_target_finding(child, path))
      }
      if is_rule_enabled(enabled_rule_ids, "label-for-target-not-labelable") &&
        label_for_targets_unique_nonlabelable_builtin(child, context) {
        findings.push(
          generic_finding(
            child, path, "label-for-target-not-labelable", "This label for attribute targets a built-in element that is not labelable.",
            "Point for to a labelable built-in form-related element, or use an appropriate control.",
          ),
        )
      }
      if is_rule_enabled(
          enabled_rule_ids, "label-multiple-labelable-descendants",
        ) &&
        label_has_multiple_known_labelable_descendants(child) {
        findings.push(
          generic_finding(
            child, path, "label-multiple-labelable-descendants", "This label contains more than one known labelable built-in descendant.",
            "Associate the label with one control and move additional controls outside the label.",
          ),
        )
      }
      if is_rule_enabled(enabled_rule_ids, "form-control-name-missing") &&
        is_form_control_to_check(child) &&
        !has_recognizable_form_control_name(child, context) {
        findings.push(missing_form_control_name_finding(child, path))
      }
      if is_rule_enabled(enabled_rule_ids, "link-name-missing") &&
        is_link_to_check(child) &&
        !has_recognizable_link_name(child, context) {
        findings.push(missing_link_name_finding(child, path))
      }
      if is_rule_enabled(enabled_rule_ids, "button-name-missing") &&
        is_button_to_check(child) &&
        !has_recognizable_button_name(child, context) {
        findings.push(missing_button_name_finding(child, path))
      }
      if is_rule_enabled(enabled_rule_ids, "button-implicit-submit") &&
        name == "button" &&
        has_implicit_submit_type(child) &&
        has_form_ancestor(child) {
        findings.push(implicit_submit_button_finding(child, path))
      }
      if is_rule_enabled(enabled_rule_ids, "iframe-name-missing") &&
        name == "iframe" &&
        !has_recognizable_iframe_name(child, context) {
        findings.push(missing_iframe_name_finding(child, path))
      }
      if is_rule_enabled(enabled_rule_ids, "area-alt-missing") &&
        is_clickable_area(child) &&
        !has_nonempty_attribute(child, "alt") {
        findings.push(missing_area_alt_finding(child, path))
      }
      if is_rule_enabled(enabled_rule_ids, "table-headers-invalid") &&
        is_table_cell(child) &&
        enclosing_table_path is Some(_) &&
        has_invalid_table_headers(child, enclosing_table_path, context) {
        findings.push(invalid_table_headers_finding(child, path))
      }
      if is_rule_enabled(enabled_rule_ids, "table-scope-invalid") &&
        has_invalid_explicit_table_scope(child) {
        findings.push(
          generic_finding(
            child, path, "table-scope-invalid", "This table header has an explicit scope value that is not a valid HTML table scope keyword.",
            "Use row, col, rowgroup, or colgroup, or omit scope when the default association is appropriate.",
          ),
        )
      }
      match heading_level(child) {
        Some(level) => {
          if is_rule_enabled(enabled_rule_ids, "heading-level-skipped") &&
            last_heading_level is Some(previous) &&
            level >= previous + 2 {
            findings.push(skipped_heading_level_finding(child, path))
          }
          last_heading_level = Some(level)
          if is_rule_enabled(enabled_rule_ids, "heading-name-missing") &&
            !has_recognizable_heading_name(child, context) {
            findings.push(missing_heading_name_finding(child, path))
          }
        }
        None => ()
      }
      if name != "template" {
        last_heading_level = collect_findings(
          child, path, child_table_path, findings, review_items, enabled_rule_ids,
          last_heading_level, context, hidden_by_aria, seen_navigation_names,
        )
      }
    } else {
      last_heading_level = collect_findings(
        child, parent_path, enclosing_table_path, findings, review_items, enabled_rule_ids,
        last_heading_level, context, aria_hidden_ancestor, seen_navigation_names,
      )
    }
    position = position + 1
  }
  last_heading_level
}

///|
fn collect_extended_aria_findings(
  node : @dom.Node,
  path : String,
  findings : Array[Finding],
  review_items : Array[ReviewItem],
  enabled : Array[String],
  context : AuditContext,
) -> Unit {
  if is_rule_enabled(enabled, "aria-checked-role-incompatible") &&
    has_aria_checked_incompatible_with_explicit_role(node) {
    findings.push(
      generic_finding(
        node, path, "aria-checked-role-incompatible", "This explicit ARIA role does not support aria-checked.",
        "Use aria-checked only with a role that supports checked state, or use the state supported by this role.",
      ),
    )
  }
  if is_rule_enabled(enabled, "aria-role-attribute-incompatible") {
    for attribute in incompatible_modeled_aria_role_attributes(node) {
      findings.push(
        generic_finding(
          node,
          path,
          "aria-role-attribute-incompatible",
          "This explicit ARIA role does not support " + attribute + ".",
          "Remove " + attribute + ", or use an ARIA role that supports it.",
        ),
      )
    }
  }
  if is_rule_enabled(enabled, "aria-required-property-missing") {
    match missing_selected_required_aria_property_message(node) {
      Some(message) =>
        findings.push(
          generic_finding(
            node,
            path,
            "aria-required-property-missing",
            message,
            missing_selected_required_aria_property_suggestion(node),
          ),
        )
      None => ()
    }
  }
  if is_rule_enabled(enabled, "aria-range-value-invalid") {
    match aria_range_value_check(node) {
      Finding(message) =>
        findings.push(
          generic_finding(
            node, path, "aria-range-value-invalid", message, "Use finite numeric aria-valuemin, aria-valuemax, and aria-valuenow values within a consistent range.",
          ),
        )
      Review(reason) =>
        review_items.push({
          rule_id: "aria-range-value-invalid",
          reason,
          element_path: path,
          line: node.origin_line(),
          column: node.origin_col(),
        })
      Valid => ()
    }
  }
  if is_rule_enabled(enabled, "aria-idref-invalid") &&
    has_invalid_selected_aria_idref(node, context) {
    findings.push(
      generic_finding(
        node, path, "aria-idref-invalid", "This ARIA relationship references one or more missing or ambiguous local IDs.",
        "Reference a unique non-empty ID in the same audited document or fragment.",
      ),
    )
  }
  if is_rule_enabled(enabled, "aria-required-context-role") {
    match missing_required_context_role_message(node, context) {
      Some(message) =>
        findings.push(
          generic_finding(
            node, path, "aria-required-context-role", message, "Place this role in one of its documented required contexts, or use one unique valid aria-owns relationship; verify the browser accessibility tree.",
          ),
        )
      None => ()
    }
  }
  let role = primary_role(node)
  if role is Some(role_name) {
    let custom_name_rule = custom_role_name_rule_id(role_name)
    if custom_name_rule is Some(rule_id) &&
      is_rule_enabled(enabled, rule_id) &&
      is_custom_role_name_target(node) &&
      !has_custom_role_static_name(node, role_name, context) {
      if has_custom_role_uncertain_name_source(node, role_name, context) {
        review_items_for_svg_name(node, path, review_items, rule_id)
      } else {
        findings.push(
          generic_finding(
            node, path, rule_id, "This custom ARIA role has no recognizable accessible name in static markup.",
            "Add visible text, an image with non-empty alt text, aria-label, aria-labelledby, or title.",
          ),
        )
      }
    }
    let name_rule = role_name_rule_id(role_name)
    if name_rule is Some(rule_id) && is_rule_enabled(enabled, rule_id) {
      if !has_role_static_name(node, role_name, context) {
        if has_potential_svg_name_source(node) {
          review_items_for_svg_name(node, path, review_items, rule_id)
        } else {
          findings.push(
            generic_finding(
              node, path, rule_id, "This ARIA role has no recognizable accessible name in static markup.",
              "Add visible text, aria-label, aria-labelledby, or title as appropriate.",
            ),
          )
        }
      }
    }
    if role_name == "heading" &&
      is_rule_enabled(enabled, "role-heading-level-missing") &&
      !has_heading_level_attribute(node) {
      findings.push(
        generic_finding(
          node, path, "role-heading-level-missing", "This ARIA heading has no aria-level from 1 through 6.",
          "Add an integer aria-level from 1 through 6, or use a native heading.",
        ),
      )
    }
    if role_name == "tab" &&
      is_rule_enabled(enabled, "role-tab-selected-missing") &&
      node.attrs().get("aria-selected") is None {
      findings.push(
        generic_finding(
          node, path, "role-tab-selected-missing", "This tab role omits aria-selected.",
          "Set aria-selected to true or false for each tab.",
        ),
      )
    }
    if role_name == "switch" &&
      is_rule_enabled(enabled, "role-switch-checked-missing") &&
      node.attrs().get("aria-checked") is None {
      findings.push(
        generic_finding(
          node, path, "role-switch-checked-missing", "This switch role omits aria-checked.",
          "Set aria-checked to true or false for the switch state.",
        ),
      )
    }
  }
}

///|
fn review_items_for_svg_name(
  node : @dom.Node,
  path : String,
  review_items : Array[ReviewItem],
  rule_id : String,
) -> Unit {
  // A Finding is deliberately not emitted: SVG title/desc and browser naming
  // rules can change the result.
  review_items.push({
    rule_id,
    reason: "This role may derive a name from SVG or browser name-computation behavior; verify it in a browser.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  })
}

///|
fn collect_extended_structure_findings(
  node : @dom.Node,
  path : String,
  findings : Array[Finding],
  enabled : Array[String],
  context : AuditContext,
) -> Unit {
  if is_rule_enabled(enabled, "nested-interactive") &&
    has_interactive_ancestor(node) &&
    is_nested_interactive_descendant(node) {
    findings.push(
      generic_finding(
        node, path, "nested-interactive", "This interactive element is nested inside a link or button.",
        "Do not nest interactive controls inside links or buttons.",
      ),
    )
  }
  if is_rule_enabled(enabled, "list-structure-invalid") &&
    is_invalid_list_child(node) {
    findings.push(
      generic_finding(
        node, path, "list-structure-invalid", "This direct list child is not a list item.",
        "Use li elements as direct children of ul or ol (apart from inert markup).",
      ),
    )
  }
  if is_rule_enabled(enabled, "definition-list-structure-invalid") &&
    is_invalid_definition_list_child(node) {
    findings.push(
      generic_finding(
        node, path, "definition-list-structure-invalid", "This direct definition-list child is not dt, dd, or an allowed grouping element.",
        "Use dt and dd (optionally grouped in div) as direct dl children.",
      ),
    )
  }
  if is_rule_enabled(enabled, "listitem-orphan") && is_orphan_listitem(node) {
    findings.push(
      generic_finding(
        node, path, "listitem-orphan", "This list item is not a direct child of ul, ol, or menu.",
        "Place li directly inside a ul, ol, or menu list container.",
      ),
    )
  }
  if is_rule_enabled(enabled, "fieldset-legend-missing") &&
    fieldset_needs_named_legend_hint(node, context) {
    findings.push(
      generic_finding(
        node, path, "fieldset-legend-missing", "This fieldset groups multiple known controls without a named first legend.",
        "Add a non-empty first legend that describes the group of related controls.",
      ),
    )
  }
  if is_rule_enabled(enabled, "table-header-name-missing") &&
    node.name() == "th" &&
    !has_generic_static_name(node, context) &&
    !has_potential_svg_name_source(node) {
    findings.push(
      generic_finding(
        node, path, "table-header-name-missing", "This table header cell has no recognizable static text alternative.",
        "Add header text, an image alt, aria-label, aria-labelledby, or title.",
      ),
    )
  }
  if context.is_document &&
    is_rule_enabled(enabled, "viewport-zoom-disabled") &&
    has_zoom_disabling_viewport(node) {
    findings.push(
      generic_finding(
        node, path, "viewport-zoom-disabled", "This viewport declaration disables or severely limits zoom.",
        "Avoid user-scalable=no and maximum-scale values below 2.",
      ),
    )
  }
}

///|
fn collect_extended_review_items(
  node : @dom.Node,
  path : String,
  reviews : Array[ReviewItem],
  enabled : Array[String],
  context : AuditContext,
) -> Unit {
  if node.name() is ("audio" | "video") {
    add_review_if_enabled(
      reviews, enabled, "media-alternative-review", node, path, "Verify equivalent alternatives, captions, and audio description for this media in its delivered experience.",
    )
    if node.attrs().get("autoplay") is Some(_) {
      add_review_if_enabled(
        reviews, enabled, "media-autoplay-audio-review", node, path, "Verify whether autoplaying audio is audible for more than three seconds and can be paused, stopped, or independently controlled.",
      )
    }
  }
  if is_custom_interactive_role(node) {
    add_review_if_enabled(
      reviews, enabled, "keyboard-operation-review", node, path, "Verify this custom interactive role is fully operable by keyboard in the running page.",
    )
  }
  if primary_role(node) is Some(role) && role is ("dialog" | "alertdialog") {
    add_review_if_enabled(
      reviews, enabled, "keyboard-trap-review", node, path, "Verify keyboard focus can enter and leave this dialog appropriately, including any modal behavior.",
    )
  }
  if node.attrs().get("accesskey") is Some(_) {
    add_review_if_enabled(
      reviews, enabled, "character-key-shortcut-review", node, path, "Verify the character shortcut can be disabled, remapped, or is active only while this control has focus.",
    )
  }
  if is_potentially_focusable(node) && has_explicit_tabindex(node) {
    add_review_if_enabled(
      reviews, enabled, "focus-order-review", node, path, "Verify rendered keyboard focus order preserves meaning and operability.",
    )
    add_review_if_enabled(
      reviews, enabled, "focus-visible-review", node, path, "Verify this focusable target has a visible focus indicator in each rendered state.",
    )
    add_review_if_enabled(
      reviews, enabled, "focus-obscured-review", node, path, "Verify author-created content does not entirely obscure this target's focus indicator.",
    )
    add_review_if_enabled(
      reviews, enabled, "target-size-review", node, path, "Measure this pointer target and its spacing in rendered CSS pixels, considering WCAG exceptions.",
    )
  }
  if node.attrs().get("draggable") is Some(_) {
    add_review_if_enabled(
      reviews, enabled, "dragging-movement-review", node, path, "Verify any dragging operation has a non-dragging single-pointer alternative unless essential.",
    )
  }
  if node.attrs().get("onmousedown") is Some(_) ||
    node.attrs().get("onpointerdown") is Some(_) {
    add_review_if_enabled(
      reviews, enabled, "pointer-cancellation-review", node, path, "Verify pointer-down activation can be aborted or reversed as required.",
    )
  }
  if node.attrs().get("ondevicemotion") is Some(_) ||
    node.attrs().get("ondeviceorientation") is Some(_) {
    add_review_if_enabled(
      reviews, enabled, "motion-actuation-review", node, path, "Verify motion-actuated behavior has an accessible UI alternative and can be disabled when required.",
    )
  }
  if has_nonempty_attribute(node, "aria-label") &&
    has_nonempty_visible_text(node) {
    add_review_if_enabled(
      reviews, enabled, "label-in-name-review", node, path, "Compare the visible label with the browser-computed accessible name; visible text should be included.",
    )
  }
  if is_element_lang_in_audited_content_scope(node, context) {
    add_review_if_enabled(
      reviews, enabled, "language-of-parts-review", node, path, "Verify this lang value accurately marks a changed human language when WCAG exceptions do not apply.",
    )
  }
  if node.attrs().get("onfocus") is Some(_) {
    add_review_if_enabled(
      reviews, enabled, "on-focus-context-review", node, path, "Verify focusing this element alone does not unexpectedly change context.",
    )
  }
  if node.attrs().get("onchange") is Some(_) && is_form_control_to_check(node) {
    add_review_if_enabled(
      reviews, enabled, "on-input-context-review", node, path, "Verify changing this control does not unexpectedly change context without advance notice.",
    )
  }
  if node.name() == "form" {
    add_review_if_enabled(
      reviews, enabled, "form-error-review", node, path, "Submit invalid data and verify text error identification, suggestions, and consequential-submission safeguards.",
    )
  }
  if node.name() == "input" && input_type(node) == "password" {
    add_review_if_enabled(
      reviews, enabled, "authentication-review", node, path, "Verify this authentication flow does not require a cognitive-function test without a compliant alternative or exception.",
    )
  }
  if (primary_role(node) is Some(role) && role is ("status" | "alert")) ||
    node.attrs().get("aria-live") is Some(_) {
    add_review_if_enabled(
      reviews, enabled, "status-message-review", node, path, "Trigger updates and verify the status message is exposed to assistive technology without an unexpected focus move.",
    )
  }
  if node.name() == "html" {
    add_review_if_enabled(
      reviews, enabled, "bypass-blocks-review", node, path, "Verify this page provides a keyboard-operable way to bypass repeated blocks.",
    )
    add_review_if_enabled(
      reviews, enabled, "text-resize-review", node, path, "At browser zoom and narrow viewports, verify text resize and reflow preserve content and functionality.",
    )
  }
  if node.name()
    is ("p"
    | "span"
    | "a"
    | "button"
    | "input"
    | "textarea"
    | "select"
    | "h1"
    | "h2"
    | "h3"
    | "h4"
    | "h5"
    | "h6") {
    add_review_if_enabled(
      reviews, enabled, "contrast-review", node, path, "Measure text and relevant non-text contrast in rendered states; static HTML has no computed colors.",
    )
    add_review_if_enabled(
      reviews, enabled, "meaningful-sequence-review", node, path, "Verify rendered visual order and DOM reading order preserve meaning.",
    )
  }
}

///|
fn add_review_if_enabled(
  reviews : Array[ReviewItem],
  enabled : Array[String],
  rule_id : String,
  node : @dom.Node,
  path : String,
  reason : String,
) -> Unit {
  if is_rule_enabled(enabled, rule_id) {
    reviews.push({
      rule_id,
      reason,
      element_path: path,
      line: node.origin_line(),
      column: node.origin_col(),
    })
  }
}

///|
fn generic_finding(
  node : @dom.Node,
  path : String,
  rule_id : String,
  message : String,
  suggestion : String,
) -> Finding {
  {
    rule_id,
    message,
    suggestion,
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn primary_role(node : @dom.Node) -> String? {
  for token in role_tokens(node) {
    if is_concrete_aria_role(token) {
      return Some(token.to_lower())
    }
  }
  None
}

///|
fn role_tokens(node : @dom.Node) -> Array[String] {
  match node.attrs().get("role") {
    Some(Some(value)) => idref_tokens(value).map(fn(token) { token.to_lower() })
    _ => []
  }
}

///|
fn has_role_value_without_known_fallback(node : @dom.Node) -> Bool {
  let tokens = role_tokens(node)
  !tokens.is_empty() &&
  !tokens.any(is_concrete_aria_role) &&
  !tokens.any(is_abstract_aria_role)
}

///|
fn has_undefined_aria_attribute(node : @dom.Node) -> Bool {
  for name, _ in node.attrs() {
    let normalized = name.to_lower()
    if normalized.has_prefix("aria-") && !is_wai_aria_12_attribute(normalized) {
      return true
    }
  }
  false
}

///|
fn incompatible_modeled_aria_role_attributes(node : @dom.Node) -> Array[String] {
  // This intentionally does not try to reconstruct the complete ARIA role
  // characteristic table.  `aria-checked` belongs to the established
  // aria-checked-role-incompatible rule, and value/required-property rules
  // retain their existing owners.
  if is_native_control_for_role_attribute_compatibility(node) {
    return []
  }
  match primary_role(node) {
    Some(role) => {
      let incompatible = []
      if is_modeled_role_for_attribute_compatibility(role) {
        for
          attribute in [
            "aria-label", "aria-labelledby", "aria-autocomplete", "aria-placeholder",
            "aria-multiselectable", "aria-selected",
          ] {
          if node.attrs().get(attribute) is Some(_) &&
            !modeled_aria_role_supports_attribute(role, attribute) {
            incompatible.push(attribute)
          }
        }
      }
      incompatible
    }
    None => []
  }
}

///|
fn is_modeled_role_for_attribute_compatibility(role : String) -> Bool {
  // A concrete role outside this list is intentionally not inferred from a
  // partial table.  Add a role only with a reviewed WAI-ARIA 1.2 row.
  role
  is ("button"
  | "combobox"
  | "emphasis"
  | "grid"
  | "listbox"
  | "option"
  | "searchbox"
  | "tab"
  | "tablist"
  | "textbox"
  | "tree"
  | "treegrid")
}

///|
fn is_native_control_for_role_attribute_compatibility(node : @dom.Node) -> Bool {
  // ARIA in HTML has host-language-specific authoring restrictions.  This
  // small ARIA-role table is deliberately for custom markup, so native form
  // controls keep their existing HTML-specific checks instead.
  node.name() is ("button" | "input" | "select" | "textarea")
}

///|
fn modeled_aria_role_supports_attribute(
  role : String,
  attribute : String,
) -> Bool {
  match attribute {
    // aria-label and aria-labelledby are globally usable except where a role
    // explicitly prohibits naming.  This batch models that prohibition for
    // the WAI-ARIA 1.2 emphasis role only.
    "aria-label" | "aria-labelledby" => role != "emphasis"
    // textbox supports these properties; searchbox inherits from textbox.
    "aria-autocomplete" => role is ("combobox" | "textbox" | "searchbox")
    "aria-placeholder" => role is ("textbox" | "searchbox")
    // treegrid inherits the property from grid.
    "aria-multiselectable" =>
      role is ("grid" | "listbox" | "tablist" | "tree" | "treegrid")
    "aria-selected" => role is ("option" | "tab")
    _ => true
  }
}

///|
fn is_wai_aria_12_attribute(name : String) -> Bool {
  name
  is ("aria-activedescendant"
  | "aria-atomic"
  | "aria-autocomplete"
  | "aria-braillelabel"
  | "aria-brailleroledescription"
  | "aria-busy"
  | "aria-checked"
  | "aria-colcount"
  | "aria-colindex"
  | "aria-colindextext"
  | "aria-colspan"
  | "aria-controls"
  | "aria-current"
  | "aria-describedby"
  | "aria-description"
  | "aria-details"
  | "aria-disabled"
  | "aria-dropeffect"
  | "aria-errormessage"
  | "aria-expanded"
  | "aria-flowto"
  | "aria-grabbed"
  | "aria-haspopup"
  | "aria-hidden"
  | "aria-invalid"
  | "aria-keyshortcuts"
  | "aria-label"
  | "aria-labelledby"
  | "aria-level"
  | "aria-live"
  | "aria-modal"
  | "aria-multiline"
  | "aria-multiselectable"
  | "aria-orientation"
  | "aria-owns"
  | "aria-placeholder"
  | "aria-posinset"
  | "aria-pressed"
  | "aria-readonly"
  | "aria-relevant"
  | "aria-required"
  | "aria-roledescription"
  | "aria-rowcount"
  | "aria-rowindex"
  | "aria-rowindextext"
  | "aria-rowspan"
  | "aria-selected"
  | "aria-setsize"
  | "aria-sort"
  | "aria-valuemax"
  | "aria-valuemin"
  | "aria-valuenow"
  | "aria-valuetext")
}

///|
fn has_invalid_explicit_table_scope(node : @dom.Node) -> Bool {
  if node.name() != "th" {
    return false
  }
  match node.attrs().get("scope") {
    Some(Some(value)) =>
      !(value.trim().to_lower() is ("row" | "col" | "rowgroup" | "colgroup"))
    Some(None) => true
    None => false
  }
}

///|
fn role_name_rule_id(role : String) -> String? {
  match role {
    "checkbox" => Some("role-checkbox-name-missing")
    "combobox" => Some("role-combobox-name-missing")
    "slider" => Some("role-slider-name-missing")
    "progressbar" => Some("role-progressbar-name-missing")
    "meter" => Some("role-meter-name-missing")
    "img" => Some("role-img-name-missing")
    "dialog" | "alertdialog" => Some("role-dialog-name-missing")
    _ => None
  }
}

///|
fn custom_role_name_rule_id(role : String) -> String? {
  match role {
    "button" => Some("role-button-name-missing")
    "link" => Some("role-link-name-missing")
    "radio" => Some("role-radio-name-missing")
    "textbox" => Some("role-textbox-name-missing")
    "searchbox" => Some("role-searchbox-name-missing")
    _ => None
  }
}

///|
fn is_custom_role_name_target(node : @dom.Node) -> Bool {
  !is_button_to_check(node) &&
  !is_link_to_check(node) &&
  !is_form_control_to_check(node)
}

///|
fn has_generic_static_name(node : @dom.Node, context : AuditContext) -> Bool {
  has_name_from_labelledby(node, context) ||
  has_nonempty_attribute(node, "aria-label") ||
  has_nonempty_visible_text(node) ||
  has_nonempty_image_alt(node) ||
  has_nonempty_attribute(node, "title")
}

///|
fn has_custom_role_static_name(
  node : @dom.Node,
  role : String,
  context : AuditContext,
) -> Bool {
  if role is ("textbox" | "searchbox") {
    return has_author_only_text_input_name(node, context)
  }
  has_generic_static_name(node, context)
}

///|
fn has_custom_role_uncertain_name_source(
  node : @dom.Node,
  role : String,
  context : AuditContext,
) -> Bool {
  if role is ("textbox" | "searchbox") {
    return has_uncertain_labelledby_svg_source(node, context)
  }
  has_potential_svg_name_source(node)
}

///|
fn has_author_only_text_input_name(
  node : @dom.Node,
  context : AuditContext,
) -> Bool {
  has_supported_text_input_labelledby_name(node, context) ||
  has_nonempty_attribute(node, "aria-label") ||
  has_nonempty_attribute(node, "title")
}

///|
fn has_supported_text_input_labelledby_name(
  node : @dom.Node,
  context : AuditContext,
) -> Bool {
  match node.attrs().get("aria-labelledby") {
    Some(Some(value)) => {
      for id in labelledby_idref_tokens(value) {
        match context.id_nodes.get(id) {
          Some([target]) if !has_potential_svg_name_source(target) =>
            if static_name_from_labelledby_target(target) is Some(_) {
              return true
            }
          Some([target]) if has_nonempty_attribute(target, "aria-label") ||
            has_nonempty_attribute(target, "title") => return true
          _ => ()
        }
      }
      false
    }
    _ => false
  }
}

///|
fn has_uncertain_labelledby_svg_source(
  node : @dom.Node,
  context : AuditContext,
) -> Bool {
  match node.attrs().get("aria-labelledby") {
    Some(Some(value)) => {
      for id in labelledby_idref_tokens(value) {
        match context.id_nodes.get(id) {
          Some([target]) if has_potential_svg_name_source(target) => return true
          _ => ()
        }
      }
      false
    }
    _ => false
  }
}

///|
fn has_role_static_name(
  node : @dom.Node,
  role : String,
  context : AuditContext,
) -> Bool {
  // role=img derives its name from author-provided sources. Descendant text is
  // exposed as part of the graphic only in browser-specific cases, so it must
  // not suppress a definite static missing-name signal here.
  if role == "img" {
    return has_name_from_labelledby(node, context) ||
      has_nonempty_attribute(node, "aria-label") ||
      has_nonempty_attribute(node, "title")
  }
  has_generic_static_name(node, context)
}

///|
fn missing_selected_required_aria_property_message(node : @dom.Node) -> String? {
  match primary_role(node) {
    Some("checkbox") | Some("switch") =>
      if node.attrs().get("aria-checked") is None {
        Some("This ARIA role omits a required state or property.")
      } else {
        None
      }
    Some("radio") =>
      if !is_native_radio_input(node) &&
        node.attrs().get("aria-checked") is None {
        Some("This ARIA role omits a required state or property.")
      } else {
        None
      }
    Some("combobox") =>
      if node.attrs().get("aria-expanded") is None {
        Some("This ARIA role omits a required state or property.")
      } else {
        None
      }
    Some("slider") =>
      if node.attrs().get("aria-valuenow") is None {
        Some("This ARIA role omits a required state or property.")
      } else {
        None
      }
    Some("scrollbar") => {
      let missing_controls = node.attrs().get("aria-controls") is None
      let missing_valuenow = node.attrs().get("aria-valuenow") is None
      if missing_controls && missing_valuenow {
        Some(
          "This scrollbar role omits required aria-controls and aria-valuenow.",
        )
      } else if missing_controls {
        Some("This scrollbar role omits required aria-controls.")
      } else if missing_valuenow {
        Some("This scrollbar role omits required aria-valuenow.")
      } else {
        None
      }
    }
    _ => None
  }
}

///|
fn missing_selected_required_aria_property_suggestion(
  node : @dom.Node,
) -> String {
  match primary_role(node) {
    Some("scrollbar") =>
      "Add aria-controls and aria-valuenow to the scrollbar role, or use a native element."
    _ =>
      "Add the required ARIA state or property for the role, or use a native element."
  }
}

///|
fn aria_range_value_check(node : @dom.Node) -> RangeValueCheck {
  match primary_role(node) {
    Some(role) if role is ("slider" | "scrollbar") &&
      is_custom_range_value_target(node) => {
      // Missing aria-valuenow is deliberately left to
      // aria-required-property-missing, which owns that requirement.
      if node.attrs().get("aria-valuenow") is None {
        return Valid
      }
      let min = parse_explicit_range_value(
        node,
        "aria-valuemin",
        decimal_zero(),
      )
      let max = parse_explicit_range_value(
        node,
        "aria-valuemax",
        decimal_one_hundred(),
      )
      let now = parse_explicit_range_value(
        node,
        "aria-valuenow",
        decimal_zero(),
      )
      let invalid_attributes : Array[String] = []
      let too_large_attributes : Array[String] = []
      collect_range_parse_status(
        "aria-valuemin", min, invalid_attributes, too_large_attributes,
      )
      collect_range_parse_status(
        "aria-valuemax", max, invalid_attributes, too_large_attributes,
      )
      collect_range_parse_status(
        "aria-valuenow", now, invalid_attributes, too_large_attributes,
      )
      if !invalid_attributes.is_empty() {
        return Finding(invalid_range_value_message(role, invalid_attributes))
      }
      if !too_large_attributes.is_empty() {
        return Review(
          "This " +
          role +
          " role uses an exponent too large for the static exact-decimal range comparison in " +
          attribute_list(too_large_attributes) +
          ". Verify its value range in a browser.",
        )
      }
      match (min, max, now) {
        (Valid(minimum), Valid(maximum), Valid(current)) =>
          if compare_exact_decimals(minimum, maximum) > 0 {
            Finding(
              "This " +
              role +
              " role has aria-valuemin greater than aria-valuemax.",
            )
          } else if compare_exact_decimals(current, minimum) < 0 ||
            compare_exact_decimals(current, maximum) > 0 {
            Finding(
              "This " +
              role +
              " role has aria-valuenow outside its aria-valuemin/aria-valuemax range.",
            )
          } else {
            Valid
          }
        _ => Valid
      }
    }
    _ => Valid
  }
}

///|
fn is_custom_range_value_target(node : @dom.Node) -> Bool {
  match node.name() {
    "input" | "select" | "textarea" => false
    _ => true
  }
}

///|
fn decimal_zero() -> RangeDecimalParse {
  Valid({ negative: false, digits: ['0'], scale: 0, })
}

///|
fn decimal_one_hundred() -> RangeDecimalParse {
  Valid({ negative: false, digits: ['1', '0', '0'], scale: 0, })
}

///|
fn parse_explicit_range_value(
  node : @dom.Node,
  attribute : String,
  default : RangeDecimalParse,
) -> RangeDecimalParse {
  match node.attrs().get(attribute) {
    None => default
    Some(Some(value)) => parse_exact_range_decimal(value)
    Some(None) => Invalid
  }
}

///|
fn collect_range_parse_status(
  attribute : String,
  parsed : RangeDecimalParse,
  invalid_attributes : Array[String],
  too_large_attributes : Array[String],
) -> Unit {
  match parsed {
    Invalid => invalid_attributes.push(attribute)
    TooLargeExponent => too_large_attributes.push(attribute)
    Valid(_) => ()
  }
}

///|
fn invalid_range_value_message(
  role : String,
  attributes : Array[String],
) -> String {
  "This " +
  role +
  " role has an invalid finite numeric value in " +
  attribute_list(attributes) +
  "."
}

///|
fn attribute_list(attributes : Array[String]) -> String {
  if attributes.length() == 1 {
    attributes[0]
  } else if attributes.length() == 2 {
    attributes[0] + " and " + attributes[1]
  } else {
    let output = StringBuilder()
    let mut index = 0
    while index < attributes.length() {
      if index > 0 {
        if index + 1 == attributes.length() {
          output.write_string(", and ")
        } else {
          output.write_string(", ")
        }
      }
      output.write_string(attributes[index])
      index = index + 1
    }
    output.to_string()
  }
}

///|
// Parses a finite signed decimal or scientific-decimal literal without using
// binary floating point. Exponents with more than six digits are retained as
// review items rather than being expanded or compared speculatively.
fn parse_exact_range_decimal(value : String) -> RangeDecimalParse {
  let chars = value.trim().to_array()
  if chars.is_empty() {
    return Invalid
  }
  let mut index = 0
  let mut negative = false
  if chars[index] is ('-' | '+') {
    negative = chars[index] == '-'
    index = index + 1
  }
  let digits : Array[Char] = []
  let mut fractional_digits = 0
  let mut has_decimal_point = false
  while index < chars.length() && !(chars[index] is ('e' | 'E')) {
    let character = chars[index]
    if character == '.' {
      if has_decimal_point {
        return Invalid
      }
      has_decimal_point = true
    } else if character >= '0' && character <= '9' {
      digits.push(character)
      if has_decimal_point {
        fractional_digits = fractional_digits + 1
      }
    } else {
      return Invalid
    }
    index = index + 1
  }
  if digits.is_empty() {
    return Invalid
  }
  let mut exponent = 0
  let mut exponent_negative = false
  if index < chars.length() {
    index = index + 1
    if index < chars.length() && chars[index] is ('-' | '+') {
      exponent_negative = chars[index] == '-'
      index = index + 1
    }
    let mut exponent_digits = 0
    while index < chars.length() {
      let character = chars[index]
      if !(character >= '0' && character <= '9') {
        return Invalid
      }
      exponent_digits = exponent_digits + 1
      if exponent_digits > 6 {
        return TooLargeExponent
      }
      exponent = exponent * 10 + character.to_int() - '0'.to_int()
      index = index + 1
    }
    if exponent_digits == 0 {
      return Invalid
    }
  }
  let normalized_digits : Array[Char] = []
  let mut found_nonzero = false
  for digit in digits {
    if digit != '0' || found_nonzero {
      normalized_digits.push(digit)
      found_nonzero = true
    }
  }
  if normalized_digits.is_empty() {
    return Valid({ negative: false, digits: ['0'], scale: 0, })
  }
  let scale = if exponent_negative {
    fractional_digits + exponent
  } else {
    fractional_digits - exponent
  }
  Valid({ negative, digits: normalized_digits, scale, })
}

///|
fn compare_exact_decimals(left : ExactDecimal, right : ExactDecimal) -> Int {
  let left_is_zero = is_exact_decimal_zero(left)
  let right_is_zero = is_exact_decimal_zero(right)
  if left_is_zero && right_is_zero {
    return 0
  }
  if left_is_zero {
    return if right.negative { 1 } else { -1 }
  }
  if right_is_zero {
    return if left.negative { -1 } else { 1 }
  }
  if left.negative && !right.negative {
    return -1
  }
  if !left.negative && right.negative {
    return 1
  }
  let magnitude = compare_exact_decimal_magnitudes(left, right)
  if left.negative {
    -magnitude
  } else {
    magnitude
  }
}

///|
fn is_exact_decimal_zero(value : ExactDecimal) -> Bool {
  value.digits.length() == 1 && value.digits[0] == '0'
}

///|
fn compare_exact_decimal_magnitudes(
  left : ExactDecimal,
  right : ExactDecimal,
) -> Int {
  let left_integer_digits = left.digits.length() - left.scale
  let right_integer_digits = right.digits.length() - right.scale
  if left_integer_digits < right_integer_digits {
    return -1
  }
  if left_integer_digits > right_integer_digits {
    return 1
  }
  let length = if left.digits.length() > right.digits.length() {
    left.digits.length()
  } else {
    right.digits.length()
  }
  let mut index = 0
  while index < length {
    let left_digit = if index < left.digits.length() {
      left.digits[index].to_int()
    } else {
      '0'.to_int()
    }
    let right_digit = if index < right.digits.length() {
      right.digits[index].to_int()
    } else {
      '0'.to_int()
    }
    if left_digit < right_digit {
      return -1
    }
    if left_digit > right_digit {
      return 1
    }
    index = index + 1
  }
  0
}

///|
fn is_native_radio_input(node : @dom.Node) -> Bool {
  node.name() == "input" && input_type(node) == "radio"
}

///|
fn has_invalid_selected_aria_idref(
  node : @dom.Node,
  context : AuditContext,
) -> Bool {
  has_invalid_single_id_reference(node, "aria-activedescendant", context) ||
  has_invalid_single_id_reference(node, "aria-details", context) ||
  has_invalid_idref_list(node, "aria-controls", context) ||
  has_invalid_idref_list(node, "aria-owns", context)
}

///|
fn missing_required_context_role_message(
  node : @dom.Node,
  context : AuditContext,
) -> String? {
  // This is intentionally narrower than a browser accessibility-tree check.
  // A root-level role, an aria-hidden/presentational relationship, or an
  // ambiguous aria-owns graph can all have semantics that static DOM ancestry
  // cannot establish reliably, so they are left to browser review.
  match primary_role(node) {
    Some(child_role) if is_modeled_required_context_role(child_role) =>
      if has_uncertain_required_context_semantics(node) ||
        !has_element_parent(node) ||
        has_allowed_dom_required_context(node, child_role) ||
        has_allowed_owned_required_context(node, child_role, context) ||
        has_ambiguous_aria_owns_relationship(node, context) {
        None
      } else {
        Some(
          "This " +
          child_role +
          " role has no modeled required context role in the recovered static relationship.",
        )
      }
    _ => None
  }
}

///|
fn is_modeled_required_context_role(role : String) -> Bool {
  role
  is ("cell"
  | "columnheader"
  | "gridcell"
  | "listitem"
  | "menuitem"
  | "menuitemcheckbox"
  | "menuitemradio"
  | "option"
  | "row"
  | "rowheader"
  | "tab"
  | "treeitem")
}

///|
fn required_context_allows_child(
  context_role : String,
  child_role : String,
) -> Bool {
  match child_role {
    "cell" | "columnheader" | "gridcell" | "rowheader" => context_role == "row"
    "listitem" => context_role is ("directory" | "list")
    "menuitem" | "menuitemcheckbox" | "menuitemradio" =>
      context_role is ("group" | "menu" | "menubar")
    "option" => context_role is ("group" | "listbox")
    "row" => context_role is ("grid" | "rowgroup" | "table" | "treegrid")
    "tab" => context_role == "tablist"
    "treeitem" => context_role is ("group" | "tree")
    _ => false
  }
}

///|
fn has_element_parent(node : @dom.Node) -> Bool {
  match node.parent() {
    Some(parent) => parent.kind() is @dom.Element
    None => false
  }
}

///|
fn has_allowed_dom_required_context(
  node : @dom.Node,
  child_role : String,
) -> Bool {
  let mut ancestor = node.parent()
  while ancestor is Some(parent) {
    match effective_context_role(parent) {
      Some(context_role) if required_context_allows_child(
          context_role, child_role,
        ) => return true
      _ => ()
    }
    ancestor = parent.parent()
  }
  false
}

///|
fn has_allowed_owned_required_context(
  node : @dom.Node,
  child_role : String,
  context : AuditContext,
) -> Bool {
  match unique_valid_aria_owns_owner(node, context) {
    Some(owner) =>
      match effective_context_role(owner) {
        Some(owner_role) =>
          required_context_allows_child(owner_role, child_role) ||
          has_allowed_dom_required_context(owner, child_role)
        None => false
      }
    None => false
  }
}

///|
fn has_ambiguous_aria_owns_relationship(
  node : @dom.Node,
  context : AuditContext,
) -> Bool {
  match node.attrs().get("id") {
    Some(Some(id)) if !id.trim().is_empty() =>
      match context.aria_owners_by_id.get(id) {
        Some([owner]) =>
          !has_unique_id_target(context, id) ||
          !has_only_unique_valid_aria_owns_targets(owner, context) ||
          owner_is_aria_owns_self_reference(owner, id) ||
          has_uncertain_required_context_semantics(owner) ||
          effective_context_role(owner) is None
        Some(_) => true
        None => false
      }
    _ => false
  }
}

///|
fn unique_valid_aria_owns_owner(
  node : @dom.Node,
  context : AuditContext,
) -> @dom.Node? {
  match node.attrs().get("id") {
    Some(Some(id)) if !id.trim().is_empty() =>
      match context.aria_owners_by_id.get(id) {
        Some([owner]) if has_unique_id_target(context, id) &&
          has_only_unique_valid_aria_owns_targets(owner, context) &&
          !owner_is_aria_owns_self_reference(owner, id) &&
          !has_uncertain_required_context_semantics(owner) &&
          effective_context_role(owner) is Some(_) => Some(owner)
        _ => None
      }
    _ => None
  }
}

///|
fn has_only_unique_valid_aria_owns_targets(
  owner : @dom.Node,
  context : AuditContext,
) -> Bool {
  match owner.attrs().get("aria-owns") {
    Some(Some(value)) => {
      let ids = idref_tokens(value)
      !ids.is_empty() &&
      ids.all(fn(id) { has_unique_id_target(context, id) }) &&
      !has_duplicate_idref_token(ids)
    }
    _ => false
  }
}

///|
fn has_duplicate_idref_token(ids : Array[String]) -> Bool {
  let mut index = 0
  while index < ids.length() {
    let mut later = index + 1
    while later < ids.length() {
      if ids[index] == ids[later] {
        return true
      }
      later = later + 1
    }
    index = index + 1
  }
  false
}

///|
fn owner_is_aria_owns_self_reference(
  owner : @dom.Node,
  owned_id : String,
) -> Bool {
  match owner.attrs().get("id") {
    Some(Some(owner_id)) => owner_id == owned_id
    _ => false
  }
}

///|
fn has_uncertain_required_context_semantics(node : @dom.Node) -> Bool {
  let mut current = Some(node)
  while current is Some(element) {
    if has_aria_hidden_true(element) || is_presentational_context_role(element) {
      return true
    }
    current = element.parent()
  }
  false
}

///|
fn is_presentational_context_role(node : @dom.Node) -> Bool {
  primary_role(node) is Some(role) && role is ("none" | "presentation")
}

///|
fn effective_context_role(node : @dom.Node) -> String? {
  match node.attrs().get("role") {
    Some(_) => primary_role(node)
    None => implicit_context_role(node)
  }
}

///|
fn implicit_context_role(node : @dom.Node) -> String? {
  match node.name() {
    "ol" | "ul" => Some("list")
    "table" => Some("table")
    "tbody" | "tfoot" | "thead" => Some("rowgroup")
    "tr" => Some("row")
    _ => None
  }
}

///|
fn has_heading_level_attribute(node : @dom.Node) -> Bool {
  match node.attrs().get("aria-level") {
    Some(Some(value)) => value.trim() is ("1" | "2" | "3" | "4" | "5" | "6")
    _ => false
  }
}

///|
fn has_interactive_ancestor(node : @dom.Node) -> Bool {
  let mut ancestor = node.parent()
  while ancestor is Some(parent) {
    if parent.kind() is @dom.Element &&
      (
        (parent.name() == "a" && parent.attrs().get("href") is Some(_)) ||
        parent.name() == "button"
      ) {
      return true
    }
    ancestor = parent.parent()
  }
  false
}

///|
fn is_nested_interactive_descendant(node : @dom.Node) -> Bool {
  if is_natively_focusable(node) {
    return true
  }
  match primary_role(node) {
    Some(role) =>
      role
      is ("button"
      | "link"
      | "checkbox"
      | "switch"
      | "textbox"
      | "combobox"
      | "menuitem"
      | "option"
      | "tab"
      | "slider")
    None => false
  }
}

///|
fn is_invalid_list_child(node : @dom.Node) -> Bool {
  match node.parent() {
    Some(parent) if parent.kind() is @dom.Element &&
      parent.name() is ("ul" | "ol") =>
      !(node.name() is ("li" | "script" | "template"))
    _ => false
  }
}

///|
fn is_invalid_definition_list_child(node : @dom.Node) -> Bool {
  match node.parent() {
    Some(parent) if parent.kind() is @dom.Element && parent.name() == "dl" =>
      !(node.name() is ("dt" | "dd" | "div" | "script" | "template"))
    _ => false
  }
}

///|
fn is_orphan_listitem(node : @dom.Node) -> Bool {
  if node.name() != "li" {
    return false
  }
  match node.parent() {
    Some(parent) if parent.kind() is @dom.Element =>
      !(parent.name() is ("ul" | "ol" | "menu"))
    _ => true
  }
}

///|
fn has_aria_checked_incompatible_with_explicit_role(node : @dom.Node) -> Bool {
  match (node.attrs().get("aria-checked"), primary_role(node)) {
    (Some(_), Some(role)) =>
      !(role
      is ("checkbox"
      | "menuitemcheckbox"
      | "menuitemradio"
      | "option"
      | "radio"
      | "switch"
      | "treeitem"))
    _ => false
  }
}

///|
fn fieldset_needs_named_legend_hint(
  node : @dom.Node,
  context : AuditContext,
) -> Bool {
  node.name() == "fieldset" &&
  count_known_labelable_descendants(node) > 1 &&
  !fieldset_has_named_first_legend(node, context)
}

///|
fn fieldset_has_named_first_legend(
  node : @dom.Node,
  context : AuditContext,
) -> Bool {
  for child in node.children() {
    if child.kind() is @dom.Element {
      return child.name() == "legend" && has_generic_static_name(child, context)
    }
  }
  false
}

///|
fn has_zoom_disabling_viewport(node : @dom.Node) -> Bool {
  if node.name() != "meta" {
    return false
  }
  match (node.attrs().get("name"), node.attrs().get("content")) {
    (Some(Some(name)), Some(Some(content))) if name.trim().to_lower() ==
      "viewport" => {
      let normalized = content.to_lower().replace_all(old=" ", new="")
      if normalized.contains("user-scalable=no") ||
        normalized.contains("user-scalable=0") {
        return true
      }
      for setting in normalized.split(",") {
        if setting.has_prefix("maximum-scale=") {
          let value = setting.replace_all(old="maximum-scale=", new="")
          if value == "0" ||
            value == "1" ||
            value.has_prefix("0.") ||
            value.has_prefix("1.") {
            return true
          }
        }
      }
      false
    }
    _ => false
  }
}

///|
fn is_custom_interactive_role(node : @dom.Node) -> Bool {
  match primary_role(node) {
    Some(role) =>
      role
      is ("button"
      | "link"
      | "checkbox"
      | "switch"
      | "textbox"
      | "combobox"
      | "menuitem"
      | "option"
      | "tab"
      | "slider"
      | "treeitem")
    None => false
  }
}

///|
fn input_type(node : @dom.Node) -> String {
  match node.attrs().get("type") {
    Some(Some(value)) => value.trim().to_lower().to_owned()
    _ => "text"
  }
}

///|
fn has_aria_hidden_true(node : @dom.Node) -> Bool {
  match node.attrs().get("aria-hidden") {
    Some(Some(value)) => value.trim().to_lower() == "true"
    _ => false
  }
}

///|
fn is_potentially_focusable(node : @dom.Node) -> Bool {
  has_explicit_tabindex(node) || is_natively_focusable(node)
}

///|
fn has_explicit_tabindex(node : @dom.Node) -> Bool {
  match node.attrs().get("tabindex") {
    Some(Some(value)) => !value.trim().is_empty()
    _ => false
  }
}

///|
fn is_natively_focusable(node : @dom.Node) -> Bool {
  match node.name() {
    "a" | "area" => node.attrs().get("href") is Some(_)
    "button" | "select" | "textarea" => !has_disabled_attribute(node)
    "input" =>
      if has_disabled_attribute(node) {
        false
      } else {
        match node.attrs().get("type") {
          Some(Some(type_)) => type_.trim().to_lower() != "hidden"
          _ => true
        }
      }
    "summary" => true
    "audio" | "video" => node.attrs().get("controls") is Some(_)
    "iframe" => true
    _ => false
  }
}

///|
fn has_disabled_attribute(node : @dom.Node) -> Bool {
  node.attrs().get("disabled") is Some(_)
}

///|
fn collect_navigation_landmark_findings(
  node : @dom.Node,
  path : String,
  findings : Array[Finding],
  review_items : Array[ReviewItem],
  enabled_rule_ids : Array[String],
  context : AuditContext,
  seen_names : Array[String],
) -> Unit {
  match static_landmark_name(node, context) {
    Some(name) => {
      if is_rule_enabled(enabled_rule_ids, "navigation-landmark-name-duplicate") &&
        seen_names.contains(name) {
        findings.push(duplicate_navigation_landmark_name_finding(node, path))
      }
      seen_names.push(name)
    }
    None =>
      if has_potential_svg_name_source(node) {
        if is_rule_enabled(enabled_rule_ids, "navigation-landmark-name-missing") {
          review_items.push(navigation_landmark_name_review_item(node, path))
        }
      } else if is_rule_enabled(
          enabled_rule_ids, "navigation-landmark-name-missing",
        ) {
        findings.push(missing_navigation_landmark_name_finding(node, path))
      }
  }
}

///|
fn static_landmark_name(node : @dom.Node, context : AuditContext) -> String? {
  match static_name_from_labelledby(node, context) {
    Some(name) => Some(name)
    None =>
      match node.attrs().get("aria-label") {
        Some(Some(name)) if !name.trim().is_empty() =>
          Some(name.trim().to_owned())
        _ =>
          match node.attrs().get("title") {
            Some(Some(name)) if !name.trim().is_empty() =>
              Some(name.trim().to_owned())
            _ => None
          }
      }
  }
}

///|
fn static_name_from_labelledby(
  node : @dom.Node,
  context : AuditContext,
) -> String? {
  match node.attrs().get("aria-labelledby") {
    Some(Some(value)) => {
      let output = StringBuilder()
      // Keep IDREF order because it determines the resulting flat name. This
      // deliberately does not recursively follow a referenced node's own
      // aria-labelledby: the full traversal/visited-node algorithm requires
      // browser semantics and is outside this static subset.
      for id in labelledby_idref_tokens(value) {
        match context.id_nodes.get(id) {
          Some([target]) =>
            match static_name_from_labelledby_target(target) {
              Some(text) => {
                if !output.to_string().is_empty() {
                  output.write_string(" ")
                }
                output.write_string(text)
              }
              None => ()
            }
          _ => ()
        }
      }
      let name = output.to_string().trim().to_owned()
      if name.is_empty() {
        None
      } else {
        Some(name)
      }
    }
    _ => None
  }
}

///|
fn static_name_from_labelledby_target(node : @dom.Node) -> String? {
  match node.attrs().get("aria-label") {
    Some(Some(value)) if !value.trim().is_empty() =>
      Some(value.trim().to_owned())
    _ => {
      let text = static_text_content(node).trim().to_owned()
      if !text.is_empty() {
        return Some(text)
      }
      match node.attrs().get("title") {
        Some(Some(value)) if !value.trim().is_empty() =>
          Some(value.trim().to_owned())
        _ => None
      }
    }
  }
}

///|
fn static_text_content(node : @dom.Node) -> String {
  let output = StringBuilder()
  append_static_text_content(node, output)
  output.to_string()
}

///|
fn append_static_text_content(node : @dom.Node, output : StringBuilder) -> Unit {
  if node.kind() is @dom.Text {
    output.write_string(node.data())
    return
  }
  if node.kind() is @dom.Element && is_non_name_text_element(node.name()) {
    return
  }
  if node.kind() is @dom.Element && node.name() == "img" {
    match node.attrs().get("alt") {
      Some(Some(alt)) if !alt.trim().is_empty() =>
        output.write_string(alt.trim().to_owned())
      _ => ()
    }
  }
  for child in node.children() {
    append_static_text_content(child, output)
  }
}

///|
fn has_abstract_role_token(node : @dom.Node) -> Bool {
  let tokens = role_tokens(node)
  !tokens.any(is_concrete_aria_role) && tokens.any(is_abstract_aria_role)
}

///|
fn is_abstract_aria_role(role : String) -> Bool {
  role.to_lower()
  is ("command"
  | "composite"
  | "input"
  | "landmark"
  | "range"
  | "roletype"
  | "section"
  | "sectionhead"
  | "select"
  | "structure"
  | "widget"
  | "window")
}

///|
fn is_concrete_aria_role(role : String) -> Bool {
  role.to_lower()
  is ("alert"
  | "alertdialog"
  | "application"
  | "article"
  | "banner"
  | "blockquote"
  | "button"
  | "caption"
  | "cell"
  | "checkbox"
  | "code"
  | "columnheader"
  | "combobox"
  | "complementary"
  | "contentinfo"
  | "definition"
  | "deletion"
  | "dialog"
  | "directory"
  | "document"
  | "emphasis"
  | "feed"
  | "figure"
  | "form"
  | "generic"
  | "grid"
  | "gridcell"
  | "group"
  | "heading"
  | "img"
  | "insertion"
  | "link"
  | "list"
  | "listbox"
  | "listitem"
  | "log"
  | "main"
  | "marquee"
  | "math"
  | "menu"
  | "menubar"
  | "menuitem"
  | "menuitemcheckbox"
  | "menuitemradio"
  | "meter"
  | "navigation"
  | "none"
  | "note"
  | "option"
  | "paragraph"
  | "presentation"
  | "progressbar"
  | "radio"
  | "radiogroup"
  | "region"
  | "row"
  | "rowgroup"
  | "rowheader"
  | "scrollbar"
  | "search"
  | "searchbox"
  | "separator"
  | "slider"
  | "spinbutton"
  | "status"
  | "strong"
  | "subscript"
  | "suggestion"
  | "superscript"
  | "switch"
  | "tab"
  | "table"
  | "tablist"
  | "tabpanel"
  | "term"
  | "textbox"
  | "time"
  | "timer"
  | "toolbar"
  | "tooltip"
  | "tree"
  | "treegrid"
  | "treeitem")
}

///|
fn has_supported_nonzero_meta_refresh_delay(node : @dom.Node) -> Bool {
  if node.name() != "meta" {
    return false
  }
  match (node.attrs().get("http-equiv"), node.attrs().get("content")) {
    (Some(Some(http_equiv)), Some(Some(content))) if http_equiv
      .trim()
      .to_lower() ==
      "refresh" => {
      let mut delay = ""
      for part in content.split(";") {
        delay = part.trim().to_owned()
        break
      }
      is_supported_nonzero_decimal(delay)
    }
    _ => false
  }
}

///|
fn is_supported_nonzero_decimal(value : String) -> Bool {
  if value.is_empty() {
    return false
  }
  let non_decimal = value
    .replace_all(old="0", new="")
    .replace_all(old="1", new="")
    .replace_all(old="2", new="")
    .replace_all(old="3", new="")
    .replace_all(old="4", new="")
    .replace_all(old="5", new="")
    .replace_all(old="6", new="")
    .replace_all(old="7", new="")
    .replace_all(old="8", new="")
    .replace_all(old="9", new="")
    .replace_all(old=".", new="")
  if !non_decimal.is_empty() {
    return false
  }
  let nonzero = value.replace_all(old="0", new="").replace_all(old=".", new="")
  !nonzero.is_empty()
}

///|
fn has_invalid_selected_aria_state_except_checked(node : @dom.Node) -> Bool {
  has_invalid_aria_token(node, "aria-busy", is_true_false) ||
  has_invalid_aria_token(node, "aria-disabled", is_true_false) ||
  has_invalid_aria_token(node, "aria-expanded", is_true_false_or_undefined) ||
  has_invalid_aria_token(node, "aria-hidden", is_true_false_or_undefined) ||
  has_invalid_aria_token(node, "aria-modal", is_true_false) ||
  has_invalid_aria_token(node, "aria-multiline", is_true_false) ||
  has_invalid_aria_token(node, "aria-multiselectable", is_true_false) ||
  has_invalid_aria_token(node, "aria-readonly", is_true_false) ||
  has_invalid_aria_token(node, "aria-required", is_true_false) ||
  has_invalid_aria_token(node, "aria-pressed", is_tristate) ||
  has_invalid_aria_token(node, "aria-current", is_current_token) ||
  has_invalid_aria_token(node, "aria-invalid", is_invalid_token)
}

///|
fn has_invalid_aria_checked_value(node : @dom.Node) -> Bool {
  match primary_role(node) {
    Some("radio") => has_invalid_aria_token(node, "aria-checked", is_true_false)
    _ => has_invalid_aria_token(node, "aria-checked", is_tristate)
  }
}

///|
fn has_invalid_aria_token(
  node : @dom.Node,
  attribute : String,
  is_allowed : (String) -> Bool,
) -> Bool {
  match node.attrs().get(attribute) {
    Some(Some(value)) if !value.trim().is_empty() =>
      !is_allowed(value.trim().to_lower().to_owned())
    _ => false
  }
}

///|
fn is_true_false(value : String) -> Bool {
  value is ("true" | "false")
}

///|
fn is_true_false_or_undefined(value : String) -> Bool {
  value is ("true" | "false" | "undefined")
}

///|
fn is_tristate(value : String) -> Bool {
  value is ("true" | "false" | "mixed" | "undefined")
}

///|
fn is_current_token(value : String) -> Bool {
  value is ("false" | "true" | "page" | "step" | "location" | "date" | "time")
}

///|
fn is_invalid_token(value : String) -> Bool {
  value is ("false" | "true" | "grammar" | "spelling")
}

///|
fn heading_level(node : @dom.Node) -> Int? {
  match node.name() {
    "h1" => Some(1)
    "h2" => Some(2)
    "h3" => Some(3)
    "h4" => Some(4)
    "h5" => Some(5)
    "h6" => Some(6)
    _ => None
  }
}

///|
fn has_recognizable_heading_name(
  heading : @dom.Node,
  context : AuditContext,
) -> Bool {
  has_name_from_labelledby(heading, context) ||
  has_nonempty_attribute(heading, "aria-label") ||
  has_nonempty_visible_text(heading) ||
  has_nonempty_image_alt(heading) ||
  has_potential_svg_name_source(heading)
}

///|
fn is_button_to_check(node : @dom.Node) -> Bool {
  match node.name() {
    "button" => true
    "input" =>
      match node.attrs().get("type") {
        Some(Some(type_)) => type_.trim().to_lower() is ("button" | "image")
        _ => false
      }
    _ => false
  }
}

///|
fn has_implicit_submit_type(button : @dom.Node) -> Bool {
  match button.attrs().get("type") {
    None => true
    Some(Some(value)) => value.trim().is_empty()
    Some(None) => true
  }
}

///|
fn has_form_ancestor(node : @dom.Node) -> Bool {
  let mut ancestor = node.parent()
  while ancestor is Some(parent) {
    if parent.kind() is @dom.Element && parent.name() == "form" {
      return true
    }
    ancestor = parent.parent()
  }
  false
}

///|
fn is_element_lang_in_audited_content_scope(
  node : @dom.Node,
  context : AuditContext,
) -> Bool {
  if node.name() == "html" || !has_nonempty_attribute(node, "lang") {
    return false
  }
  !context.is_document || has_body_ancestor_or_self(node)
}

///|
fn has_body_ancestor_or_self(node : @dom.Node) -> Bool {
  if node.name() == "body" {
    return true
  }
  let mut ancestor = node.parent()
  while ancestor is Some(parent) {
    if parent.kind() is @dom.Element && parent.name() == "body" {
      return true
    }
    ancestor = parent.parent()
  }
  false
}

///|
fn has_known_primary_language_tag(node : @dom.Node) -> Bool {
  match node.attrs().get("lang") {
    Some(Some(value)) => has_known_primary_language_tag_value(value)
    _ => false
  }
}

///|
fn has_known_primary_language_tag_value(value : String) -> Bool {
  let tag = value.trim()
  if tag.is_empty() ||
    has_obviously_malformed_language_tag_shape(tag.to_owned()) {
    return false
  }
  let mut primary = ""
  for subtag in tag.split("-") {
    primary = subtag.to_owned()
    break
  }
  has_iana_registered_primary_language_subtag(primary)
}

///|
fn has_obviously_malformed_language_tag_shape(tag : String) -> Bool {
  let mut subtag_length = 0
  for character in tag.to_array() {
    if character == '-' {
      if subtag_length == 0 {
        return true
      }
      subtag_length = 0
    } else if !is_ascii_alphanumeric(character) {
      return true
    } else {
      subtag_length = subtag_length + 1
    }
  }
  subtag_length == 0
}

///|
fn is_ascii_alphanumeric(character : Char) -> Bool {
  (character >= 'a' && character <= 'z') ||
  (character >= 'A' && character <= 'Z') ||
  (character >= '0' && character <= '9')
}

///|
fn has_recognizable_button_name(
  button : @dom.Node,
  context : AuditContext,
) -> Bool {
  match button.name() {
    "button" =>
      has_name_from_labelledby(button, context) ||
      has_nonempty_attribute(button, "aria-label") ||
      has_nonempty_visible_text(button) ||
      has_nonempty_image_alt(button) ||
      has_nonempty_attribute(button, "title") ||
      has_potential_svg_name_source(button)
    "input" =>
      match button.attrs().get("type") {
        Some(Some(type_)) =>
          match type_.trim().to_lower() {
            "button" =>
              has_name_from_labelledby(button, context) ||
              has_nonempty_attribute(button, "aria-label") ||
              has_nonempty_attribute(button, "value") ||
              has_nonempty_attribute(button, "title")
            "image" =>
              has_name_from_labelledby(button, context) ||
              has_nonempty_attribute(button, "aria-label") ||
              has_nonempty_attribute(button, "alt") ||
              has_nonempty_attribute(button, "title")
            _ => false
          }
        _ => false
      }
    _ => false
  }
}

///|
fn is_link_to_check(node : @dom.Node) -> Bool {
  node.name() == "a" && node.attrs().get("href") is Some(_)
}

///|
fn is_clickable_area(node : @dom.Node) -> Bool {
  node.name() == "area" && node.attrs().get("href") is Some(_)
}

///|
fn is_table_cell(node : @dom.Node) -> Bool {
  node.name() is ("td" | "th")
}

///|
fn has_invalid_table_headers(
  cell : @dom.Node,
  enclosing_table_path : String?,
  context : AuditContext,
) -> Bool {
  match cell.attrs().get("headers") {
    Some(Some(value)) => {
      let cell_id = match cell.attrs().get("id") {
        Some(Some(id)) => Some(id)
        _ => None
      }
      for id in idref_tokens(value) {
        if (cell_id is Some(own_id) && id == own_id) ||
          !is_same_table_cell_target(context, id, enclosing_table_path) {
          return true
        }
      }
      false
    }
    _ => false
  }
}

///|
fn is_same_table_cell_target(
  context : AuditContext,
  id : String,
  source_table_path : String?,
) -> Bool {
  match context.id_nodes.get(id) {
    Some([target]) =>
      match context.id_table_paths.get(id) {
        Some([Some(target_table_path)]) =>
          source_table_path is Some(source_table_path) &&
          source_table_path == target_table_path &&
          is_table_cell(target)
        _ => false
      }
    _ => false
  }
}

///|
fn has_recognizable_iframe_name(
  iframe : @dom.Node,
  context : AuditContext,
) -> Bool {
  has_nonempty_attribute(iframe, "aria-label") ||
  has_name_from_labelledby(iframe, context) ||
  has_nonempty_attribute(iframe, "title")
}

///|
fn has_recognizable_link_name(link : @dom.Node, context : AuditContext) -> Bool {
  has_name_from_labelledby(link, context) ||
  has_nonempty_attribute(link, "aria-label") ||
  has_nonempty_visible_text(link) ||
  has_nonempty_image_alt(link) ||
  has_nonempty_attribute(link, "title") ||
  has_potential_svg_name_source(link)
}

///|
fn has_nonempty_visible_text(node : @dom.Node) -> Bool {
  if node.kind() is @dom.Text {
    return !node.data().trim().is_empty()
  }
  if node.kind() is @dom.Element && is_non_name_text_element(node.name()) {
    return false
  }
  for child in node.children() {
    if has_nonempty_visible_text(child) {
      return true
    }
  }
  false
}

///|
fn is_non_name_text_element(name : String) -> Bool {
  name is ("script" | "style" | "template")
}

///|
fn has_nonempty_image_alt(node : @dom.Node) -> Bool {
  if node.kind() is @dom.Element && is_non_name_text_element(node.name()) {
    return false
  }
  if node.kind() is @dom.Element &&
    node.name() == "img" &&
    has_nonempty_attribute(node, "alt") {
    return true
  }
  for child in node.children() {
    if has_nonempty_image_alt(child) {
      return true
    }
  }
  false
}

///|
fn has_potential_svg_name_source(node : @dom.Node) -> Bool {
  if node.kind() is @dom.Element && is_non_name_text_element(node.name()) {
    return false
  }
  if node.kind() is @dom.Element && node.name() == "svg" {
    return true
  }
  for child in node.children() {
    if has_potential_svg_name_source(child) {
      return true
    }
  }
  false
}

///|
fn is_form_control_to_check(node : @dom.Node) -> Bool {
  match node.name() {
    "select" | "textarea" => true
    "input" =>
      match node.attrs().get("type") {
        Some(Some(type_)) => !is_excluded_input_type(type_)
        _ => true
      }
    _ => false
  }
}

///|
fn is_excluded_input_type(type_ : String) -> Bool {
  match type_.trim().to_lower() {
    "hidden" | "submit" | "reset" | "button" | "image" => true
    _ => false
  }
}

///|
fn has_recognizable_form_control_name(
  control : @dom.Node,
  context : AuditContext,
) -> Bool {
  has_name_from_labelledby(control, context) ||
  has_nonempty_attribute(control, "aria-label") ||
  has_explicit_label(control, context) ||
  has_wrapping_label(control) ||
  has_nonempty_attribute(control, "title")
}

///|
fn has_nonempty_attribute(node : @dom.Node, name : String) -> Bool {
  match node.attrs().get(name) {
    Some(Some(value)) => !value.trim().is_empty()
    _ => false
  }
}

///|
fn has_name_from_labelledby(
  control : @dom.Node,
  context : AuditContext,
) -> Bool {
  static_name_from_labelledby(control, context) is Some(_)
}

///|
fn idref_tokens(value : String) -> Array[String] {
  let normalized = value
    .replace_all(old="\t", new=" ")
    .replace_all(old="\n", new=" ")
    .replace_all(old="\r", new=" ")
    .replace_all(old="\u{000C}", new=" ")
  let tokens : Array[String] = []
  for token in normalized.split(" ") {
    let id = token.trim()
    let owned_id = id.to_owned()
    if !id.is_empty() && !tokens.contains(owned_id) {
      tokens.push(owned_id)
    }
  }
  tokens
}

///|
fn labelledby_idref_tokens(value : String) -> Array[String] {
  let normalized = value
    .replace_all(old="\t", new=" ")
    .replace_all(old="\n", new=" ")
    .replace_all(old="\r", new=" ")
    .replace_all(old="\u{000C}", new=" ")
  let tokens : Array[String] = []
  for token in normalized.split(" ") {
    let id = token.trim()
    if !id.is_empty() {
      tokens.push(id.to_owned())
    }
  }
  tokens
}

///|
fn has_unique_id_target(context : AuditContext, id : String) -> Bool {
  context.id_nodes.get(id) is Some([_])
}

///|
fn has_invalid_reference_target(
  node : @dom.Node,
  context : AuditContext,
) -> Bool {
  (
    node.name() == "label" &&
    has_invalid_single_id_reference(node, "for", context)
  ) ||
  has_invalid_idref_list(node, "aria-labelledby", context) ||
  has_invalid_idref_list(node, "aria-describedby", context)
}

///|
fn label_for_targets_unique_nonlabelable_builtin(
  node : @dom.Node,
  context : AuditContext,
) -> Bool {
  if node.name() != "label" {
    return false
  }
  match node.attrs().get("for") {
    Some(Some(value)) if !value.trim().is_empty() =>
      match context.id_nodes.get(value.trim().to_owned()) {
        Some([target]) => is_known_nonlabelable_builtin_element(target)
        _ => false
      }
    _ => false
  }
}

///|
fn is_known_nonlabelable_builtin_element(node : @dom.Node) -> Bool {
  match node.name() {
    "button" | "meter" | "output" | "progress" | "select" | "textarea" => false
    "input" => input_type(node) == "hidden"
    "a"
    | "abbr"
    | "address"
    | "area"
    | "article"
    | "aside"
    | "audio"
    | "b"
    | "base"
    | "bdi"
    | "bdo"
    | "blockquote"
    | "body"
    | "br"
    | "canvas"
    | "caption"
    | "cite"
    | "code"
    | "col"
    | "colgroup"
    | "data"
    | "datalist"
    | "dd"
    | "del"
    | "details"
    | "dfn"
    | "dialog"
    | "div"
    | "dl"
    | "dt"
    | "em"
    | "embed"
    | "fieldset"
    | "figcaption"
    | "figure"
    | "footer"
    | "form"
    | "h1"
    | "h2"
    | "h3"
    | "h4"
    | "h5"
    | "h6"
    | "head"
    | "header"
    | "hgroup"
    | "hr"
    | "html"
    | "i"
    | "iframe"
    | "img"
    | "ins"
    | "kbd"
    | "label"
    | "legend"
    | "li"
    | "link"
    | "main"
    | "map"
    | "mark"
    | "math"
    | "menu"
    | "meta"
    | "nav"
    | "noscript"
    | "object"
    | "ol"
    | "optgroup"
    | "option"
    | "p"
    | "picture"
    | "pre"
    | "q"
    | "rb"
    | "rp"
    | "rt"
    | "rtc"
    | "ruby"
    | "s"
    | "samp"
    | "script"
    | "search"
    | "section"
    | "slot"
    | "small"
    | "source"
    | "span"
    | "strong"
    | "style"
    | "sub"
    | "summary"
    | "sup"
    | "svg"
    | "table"
    | "tbody"
    | "td"
    | "template"
    | "tfoot"
    | "th"
    | "thead"
    | "time"
    | "title"
    | "tr"
    | "track"
    | "u"
    | "ul"
    | "var"
    | "video"
    | "wbr" => true
    _ => false
  }
}

///|
fn label_has_multiple_known_labelable_descendants(node : @dom.Node) -> Bool {
  node.name() == "label" && count_known_labelable_descendants(node) > 1
}

///|
fn count_known_labelable_descendants(node : @dom.Node) -> Int {
  let mut count = 0
  for child in node.children() {
    if child.kind() is @dom.Element {
      if child.name() == "template" {
        continue
      }
      if is_known_labelable_builtin_element(child) {
        count = count + 1
      }
      count = count + count_known_labelable_descendants(child)
    }
  }
  count
}

///|
fn is_known_labelable_builtin_element(node : @dom.Node) -> Bool {
  match node.name() {
    "button" | "meter" | "output" | "progress" | "select" | "textarea" => true
    "input" => input_type(node) != "hidden"
    _ => false
  }
}

///|
fn has_invalid_single_id_reference(
  node : @dom.Node,
  attribute : String,
  context : AuditContext,
) -> Bool {
  match node.attrs().get(attribute) {
    Some(Some(id)) => {
      let trimmed = id.trim()
      !trimmed.is_empty() && !has_unique_id_target(context, trimmed.to_owned())
    }
    _ => false
  }
}

///|
fn has_invalid_idref_list(
  node : @dom.Node,
  attribute : String,
  context : AuditContext,
) -> Bool {
  match node.attrs().get(attribute) {
    Some(Some(value)) =>
      idref_tokens(value).any(fn(id) { !has_unique_id_target(context, id) })
    _ => false
  }
}

///|
fn has_explicit_label(control : @dom.Node, context : AuditContext) -> Bool {
  match control.attrs().get("id") {
    Some(Some(id)) if !id.trim().is_empty() =>
      indexed_label_for_id_has_text(context, id)
    _ => false
  }
}

///|
fn indexed_label_for_id_has_text(context : AuditContext, id : String) -> Bool {
  match context.id_nodes.get(id) {
    Some([_]) =>
      match context.labels_by_for.get(id) {
        Some(labels) => labels.any(has_nonempty_visible_text)
        None => false
      }
    _ => false
  }
}

///|
fn is_later_duplicate_id(
  node : @dom.Node,
  path : String,
  context : AuditContext,
) -> Bool {
  match node.attrs().get("id") {
    Some(Some(id)) if !id.trim().is_empty() =>
      match context.id_paths.get(id) {
        Some(paths) => paths.length() > 1 && paths[0] != path
        None => false
      }
    _ => false
  }
}

///|
fn has_wrapping_label(control : @dom.Node) -> Bool {
  let mut ancestor = control.parent()
  while ancestor is Some(node) {
    if node.kind() is @dom.Element &&
      node.name() == "label" &&
      has_nonempty_visible_text(node) &&
      wrapping_label_applies_to_control(node, control) {
      return true
    }
    ancestor = node.parent()
  }
  false
}

///|
fn wrapping_label_applies_to_control(
  label : @dom.Node,
  control : @dom.Node,
) -> Bool {
  match label.attrs().get("for") {
    None => true
    Some(Some(for_id)) =>
      match control.attrs().get("id") {
        Some(Some(control_id)) => for_id == control_id
        _ => false
      }
    Some(None) => false
  }
}

///|
fn parse_diagnostic(error : @html_parser.ParseError) -> ParseDiagnostic {
  {
    code: error.code,
    message: error.message,
    line: error.line,
    column: error.column,
  }
}

///|
fn parse_failure(error : @html_parser.HtmlError) -> ParseDiagnostic {
  match error {
    @html_parser.HtmlError::StrictMode(parse_error) =>
      parse_diagnostic(parse_error)
    @html_parser.HtmlError::InvalidSerialization(message) =>
      parser_failure_message(message)
    @html_parser.HtmlError::UnsafeHtml(message) =>
      parser_failure_message(message)
    @html_parser.HtmlError::SelectorError(message) =>
      parser_failure_message(message)
  }
}

///|
fn parser_failure_message(message : String) -> ParseDiagnostic {
  { code: "parser-failure", message, line: None, column: None, }
}

///|
fn missing_alt_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "img-alt-missing",
    message: "This image has no alt attribute.",
    suggestion: "Add an alt attribute; use alt=\"\" only when the image is decorative.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn missing_document_title_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "document-title-missing",
    message: "This HTML document has no non-empty title.",
    suggestion: "Add a non-empty  in the document head that identifies the page.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn missing_html_lang_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "html-lang-missing",
    message: "This HTML document has no non-empty lang attribute on its html element.",
    suggestion: "Add a non-empty lang attribute to the document's <html> element, such as lang=\"en\".",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn invalid_html_lang_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "html-lang-invalid",
    message: "This document html lang attribute has no known primary language subtag or has an obviously malformed separator.",
    suggestion: "Use a language tag beginning with an IANA-registered primary subtag, such as en, zh-Hans, or fr-CH.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn invalid_element_lang_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "element-lang-invalid",
    message: "This explicit lang attribute has no known primary language subtag or has an obviously malformed separator.",
    suggestion: "Use a language tag beginning with an IANA-registered primary subtag, such as en, zh-Hans, or fr-CH.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn meta_refresh_delay_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "meta-refresh-delay",
    message: "This meta refresh uses a non-zero delay.",
    suggestion: "Avoid timed refreshes; if a static redirect is necessary, use an immediate 0-second redirect.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn invalid_aria_state_value_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "aria-state-value-invalid",
    message: "This element has an invalid value for a selected ARIA state.",
    suggestion: "Use the documented token for the ARIA attribute, or remove the attribute when it is not applicable.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn missing_iframe_name_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "iframe-name-missing",
    message: "This iframe has no recognizable accessible name in static markup.",
    suggestion: "Add a non-empty title, aria-label, or aria-labelledby reference.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn duplicate_id_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "duplicate-id",
    message: "This id value duplicates an earlier id in the audited input scope.",
    suggestion: "Make each non-empty id unique within this document or fragment input.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn invalid_reference_target_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "reference-target-invalid",
    message: "This element references one or more missing or ambiguous ID targets.",
    suggestion: "Reference a unique non-empty id in the same audited input scope.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn invalid_table_headers_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "table-headers-invalid",
    message: "This table cell has a headers reference that is missing, self-referential, ambiguous, or outside its table.",
    suggestion: "Reference unique IDs of other cells in the same table.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn missing_area_alt_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "area-alt-missing",
    message: "This clickable image-map area has no non-empty alt text.",
    suggestion: "Add non-empty alt text that identifies this area's destination or action.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn body_aria_hidden_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "body-aria-hidden",
    message: "This document hides its body from the accessibility tree.",
    suggestion: "Remove aria-hidden=\"true\" from body and hide only the specific inactive content when appropriate.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn multiple_main_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "multiple-main",
    message: "This document has more than one main element.",
    suggestion: "Review the page structure and keep one primary main landmark unless a documented exception applies.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn missing_navigation_landmark_name_finding(
  node : @dom.Node,
  path : String,
) -> Finding {
  {
    rule_id: "navigation-landmark-name-missing",
    message: "This page has multiple navigation landmarks, and this one has no static distinguishing name.",
    suggestion: "Add a non-empty aria-label, aria-labelledby reference, or title that distinguishes this navigation landmark.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn duplicate_navigation_landmark_name_finding(
  node : @dom.Node,
  path : String,
) -> Finding {
  {
    rule_id: "navigation-landmark-name-duplicate",
    message: "This navigation landmark repeats a static name used by an earlier navigation landmark.",
    suggestion: "Give same-type navigation landmarks distinct names when users need to tell them apart.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn abstract_role_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "aria-abstract-role",
    message: "This role attribute contains an abstract ARIA role token.",
    suggestion: "Use a concrete ARIA role or remove the abstract role token.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn aria_hidden_focus_review_item(node : @dom.Node, path : String) -> ReviewItem {
  let tabindex_reason = match node.attrs().get("tabindex") {
    Some(Some(value)) if value.trim() == "-1" =>
      "This aria-hidden subtree contains tabindex=\"-1\". Programmatic focus and CSS/runtime state require browser review."
    _ =>
      "This aria-hidden subtree contains a potentially focusable element. CSS and runtime focus state require browser review."
  }
  {
    rule_id: "aria-hidden-focus-review",
    reason: tabindex_reason,
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn navigation_landmark_name_review_item(
  node : @dom.Node,
  path : String,
) -> ReviewItem {
  {
    rule_id: "navigation-landmark-name-missing",
    reason: "This navigation landmark may derive a name from SVG or runtime content, which static HTML cannot determine reliably.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn missing_form_control_name_finding(
  node : @dom.Node,
  path : String,
) -> Finding {
  {
    rule_id: "form-control-name-missing",
    message: "This form control has no recognizable accessible name.",
    suggestion: "Add visible label text or a non-empty aria-label, aria-labelledby reference, or title.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn missing_link_name_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "link-name-missing",
    message: "This link has no recognizable accessible name.",
    suggestion: "Add link text, an image with non-empty alt text, aria-label, aria-labelledby, or title.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn missing_button_name_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "button-name-missing",
    message: "This button has no recognizable accessible name.",
    suggestion: "Add visible button text, a non-empty value or alt attribute as appropriate, aria-label, aria-labelledby, or title.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn implicit_submit_button_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "button-implicit-submit",
    message: "This button inside a form has no explicit type and therefore defaults to submit.",
    suggestion: "Set type=\"button\" for a non-submit action or type=\"submit\" when submission is intended.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn skipped_heading_level_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "heading-level-skipped",
    message: "建议检查标题层级:this heading skips one or more levels from the preceding heading.",
    suggestion: "Review whether this heading level should follow the preceding native heading.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn missing_heading_name_finding(node : @dom.Node, path : String) -> Finding {
  {
    rule_id: "heading-name-missing",
    message: "This heading has no recognizable accessible name.",
    suggestion: "Add heading text, an image with non-empty alt text, aria-label, or aria-labelledby.",
    element_path: path,
    line: node.origin_line(),
    column: node.origin_col(),
  }
}

///|
fn nth_of_type(
  siblings : Array[@dom.Node],
  position : Int,
  name : String,
) -> Int {
  let mut count = 0
  let mut index = 0
  while index <= position {
    let sibling = siblings[index]
    if sibling.kind() is @dom.Element && sibling.name() == name {
      count = count + 1
    }
    index = index + 1
  }
  count
}

///|
fn child_path(parent_path : String, name : String, occurrence : Int) -> String {
  if parent_path == "" {
    "\{name}:nth-of-type(\{occurrence})"
  } else {
    "\{parent_path} > \{name}:nth-of-type(\{occurrence})"
  }
}
</code></pre>
  <script>
    let moonbitLanguageFn = hljs => {
      return {
        case_insensitive: true,
        keywords: {
          keyword: 'func fn enum struct type if else match return continue break while let var interface pub priv readonly',
          literal: 'true false',
          type: "Int Int64 Double String Bool Char Bytes Option Array Result",
          built_in: 'lsl lsr asr shl shr land lor lxor Show Debug Hash Eq Compare Some None'
        },
        contains: [
          {
            scope: "char",
            begin: "'", end: "'"
          },
          {
            scope: "string",
            begin: "\"", end: "\""
          },
          {
            scope: "number",
            begin: "\\b\\d+(\\.\\d+)?\\b"
          },
          {
            scope: "codelink",
            match: /\<a href\="(?<link>[^<>]+?)"\>(?<code>[^\/<>]+?)\<\/a\>/g
          },
          hljs.COMMENT(
            '//', // begin
            '\n', // end
          )
        ]
      }
    }

    hljs.registerLanguage('moonbit', moonbitLanguageFn);
    hljs.highlightAll();
    hljs.initLineNumbersOnLoad();

    const number = window.location.href.split('#')[1];

    function waitForLineNumbers() {
      setTimeout(function () {
        const target = document.querySelector(`.hljs-ln-line[data-line-number="${number}"]`);
        if (target == null) waitForLineNumbers();
        else target.scrollIntoView();
      }, 50);
    }

    waitForLineNumbers()

  </script>
  <style>
    .hljs-ln-numbers {
      -webkit-touch-callout: none;
      -webkit-user-select: none;
      -khtml-user-select: none;
      -moz-user-select: none;
      -ms-user-select: none;
      user-select: none;
    }

    .hljs-ln-n {
      color: #ccc;
      border-right: 1px solid #dfdddd;
      margin-right: 1em;
      text-align: center;
      vertical-align: top;
      padding-right: 0.5em;
    }

    .hljs {
      background: none;
    }

    body {
      background-color: #fafafa;
    }
  </style>
</body>

</html>