@surea11y/core 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +3 -0
  3. package/bin/core.js +107 -3
  4. package/docs/API_STABILITY.md +61 -0
  5. package/docs/BASELINE.md +66 -0
  6. package/docs/CLI.md +26 -0
  7. package/docs/ENGINE_OPTIONS.md +2 -0
  8. package/docs/INTEGRATION.md +1 -1
  9. package/docs/OUTPUT_SCHEMA.md +1 -1
  10. package/docs/REPORT.md +33 -0
  11. package/docs/RULE_AUTHORING.md +31 -0
  12. package/package.json +1 -1
  13. package/src/baseline.js +0 -0
  14. package/src/checks/automatic/aria-allowed-attr.js +2 -3
  15. package/src/checks/automatic/aria-allowed-role.js +2 -3
  16. package/src/checks/automatic/aria-braille-equivalent.js +3 -4
  17. package/src/checks/automatic/aria-conditional-attr.js +3 -4
  18. package/src/checks/automatic/aria-deprecated-role.js +2 -3
  19. package/src/checks/automatic/aria-hidden-body.js +9 -1
  20. package/src/checks/automatic/aria-prohibited-attr.js +2 -3
  21. package/src/checks/automatic/aria-prohibited-children.js +73 -17
  22. package/src/checks/automatic/aria-required-attr.js +2 -3
  23. package/src/checks/automatic/aria-required-children.js +2 -3
  24. package/src/checks/automatic/aria-required-parent.js +3 -4
  25. package/src/checks/automatic/aria-roles-valid.js +2 -3
  26. package/src/checks/automatic/aria-valid-attr-value.js +2 -3
  27. package/src/checks/automatic/aria-valid-attr.js +2 -3
  28. package/src/checks/automatic/autocomplete-valid.js +2 -3
  29. package/src/checks/automatic/avoid-inline-spacing.js +2 -3
  30. package/src/checks/automatic/binary-control-name-present.js +2 -3
  31. package/src/checks/automatic/button-name-present.js +14 -4
  32. package/src/checks/automatic/bypass-blocks-present.js +44 -8
  33. package/src/checks/automatic/combobox-name-present.js +2 -3
  34. package/src/checks/automatic/css-orientation-lock.js +9 -1
  35. package/src/checks/automatic/definition-list-children-valid.js +2 -3
  36. package/src/checks/automatic/deprecated-elements-not-used.js +2 -3
  37. package/src/checks/automatic/dialog-name-present.js +2 -3
  38. package/src/checks/automatic/dlitem-parent-valid.js +2 -3
  39. package/src/checks/automatic/form-control-single-label.js +2 -3
  40. package/src/checks/automatic/html-xml-lang-mismatch.js +9 -1
  41. package/src/checks/automatic/iframe-focusable-content.js +2 -3
  42. package/src/checks/automatic/iframe-name-present.js +2 -3
  43. package/src/checks/automatic/iframe-title-unique.js +2 -3
  44. package/src/checks/automatic/label-in-name.js +7 -8
  45. package/src/checks/automatic/language-page-present.js +9 -1
  46. package/src/checks/automatic/link-in-text-block.js +2 -3
  47. package/src/checks/automatic/link-name-present.js +16 -4
  48. package/src/checks/automatic/list-children-valid.js +2 -3
  49. package/src/checks/automatic/listbox-name-present.js +2 -3
  50. package/src/checks/automatic/listitem-parent-valid.js +2 -3
  51. package/src/checks/automatic/menuitem-name-present.js +2 -3
  52. package/src/checks/automatic/meta-refresh-no-exceptions.js +9 -1
  53. package/src/checks/automatic/meta-refresh-timing-absent.js +9 -1
  54. package/src/checks/automatic/meta-viewport-zoom-enabled.js +9 -1
  55. package/src/checks/automatic/meter-name-present.js +2 -3
  56. package/src/checks/automatic/nested-interactive-controls-absent.js +2 -3
  57. package/src/checks/automatic/option-name-present.js +2 -3
  58. package/src/checks/automatic/page-title-present.js +9 -1
  59. package/src/checks/automatic/progressbar-name-present.js +2 -3
  60. package/src/checks/automatic/searchbox-name-present.js +2 -3
  61. package/src/checks/automatic/server-side-image-map-absent.js +2 -3
  62. package/src/checks/automatic/slider-name-present.js +2 -3
  63. package/src/checks/automatic/spinbutton-name-present.js +2 -3
  64. package/src/checks/automatic/summary-name-present.js +2 -3
  65. package/src/checks/automatic/tab-name-present.js +2 -3
  66. package/src/checks/automatic/table-headers-attr-valid.js +2 -3
  67. package/src/checks/automatic/table-th-has-data-cells.js +2 -3
  68. package/src/checks/automatic/td-has-header.js +2 -3
  69. package/src/checks/automatic/textbox-name-present.js +2 -3
  70. package/src/checks/automatic/tooltip-name-present.js +2 -3
  71. package/src/checks/automatic/treeitem-name-present.js +2 -3
  72. package/src/checks/automatic/valid-lang.js +2 -3
  73. package/src/checks/manual/accesskeys-manual.js +1 -7
  74. package/src/checks/manual/aria-checked-state-mismatch-manual.js +3 -4
  75. package/src/checks/manual/aria-text-manual.js +2 -3
  76. package/src/checks/manual/empty-heading-manual.js +1 -6
  77. package/src/checks/manual/empty-table-header-manual.js +5 -9
  78. package/src/checks/manual/focus-order-semantics-manual.js +2 -3
  79. package/src/checks/manual/heading-order-manual.js +1 -6
  80. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -3
  81. package/src/checks/manual/image-redundant-alt-manual.js +2 -3
  82. package/src/checks/manual/label-title-only-manual.js +2 -3
  83. package/src/checks/manual/landmark-banner-is-top-level-manual.js +30 -14
  84. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +30 -14
  85. package/src/checks/manual/landmark-main-is-top-level-manual.js +30 -14
  86. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +25 -13
  87. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +25 -13
  88. package/src/checks/manual/landmark-one-main-manual.js +9 -1
  89. package/src/checks/manual/landmark-unique-manual.js +32 -30
  90. package/src/checks/manual/link-name-quality-manual.js +2 -3
  91. package/src/checks/manual/meta-viewport-large-manual.js +9 -1
  92. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -3
  93. package/src/checks/manual/no-autoplay-audio-manual.js +2 -3
  94. package/src/checks/manual/p-as-heading-manual.js +2 -3
  95. package/src/checks/manual/page-has-heading-one-manual.js +40 -3
  96. package/src/checks/manual/page-title-patterns-manual.js +9 -1
  97. package/src/checks/manual/presentation-role-conflict-manual.js +3 -4
  98. package/src/checks/manual/region-manual.js +34 -14
  99. package/src/checks/manual/scope-attr-valid-manual.js +2 -7
  100. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -3
  101. package/src/checks/manual/skip-link-manual.js +91 -9
  102. package/src/checks/manual/tabindex-manual.js +2 -7
  103. package/src/checks/manual/table-duplicate-name-manual.js +2 -3
  104. package/src/checks/manual/table-fake-caption-manual.js +2 -3
  105. package/src/checks/manual/video-caption-manual.js +2 -3
  106. package/src/core/aria-helpers.js +82 -18
  107. package/src/core/dom-helpers.js +88 -3
  108. package/src/core/dom-runner.js +23 -0
  109. package/src/core/rule-meta.js +19 -0
  110. package/src/core.js +2632 -885
  111. package/src/i18n/en.js +6 -2
  112. package/src/i18n/fr.js +6 -2
  113. package/src/report.js +482 -0
@@ -38,6 +38,16 @@ function createAriaHelpers(opts, shared) {
38
38
  const trim = (shared && shared.trim) || ((v) => (v == null ? '' : String(v)).trim());
39
39
  const lower = (v) => trim(v).toLowerCase();
40
40
  const ariaDocument = opts && opts.document;
41
+ // Same normalization as createDomHelpers's `roots` (src/core/dom-helpers.js)
42
+ // -- opts.root accepts a single element or an array (multi-region
43
+ // contextSelector support). Used to bound hasLandmarkScopingAncestor's
44
+ // ancestor walk to the scanned scope; see that function below.
45
+ const ariaRoots = (() => {
46
+ const r = opts && opts.root;
47
+ if (Array.isArray(r)) return r.filter((x) => x && typeof x === 'object');
48
+ if (r && typeof r === 'object') return [r];
49
+ return [];
50
+ })();
41
51
 
42
52
  // Existence check for a single ID token — never throws, returns false
43
53
  // (not "unknown") when the document isn't available so callers degrade
@@ -75,25 +85,70 @@ function createAriaHelpers(opts, shared) {
75
85
  return false;
76
86
  }
77
87
 
78
- // Used for <header>/<footer>'s own conditional implicit role: per the
79
- // W3C ARIA-in-HTML spec (matching a widely-used reference engine's own
80
- // element-spec table for "header"/"footer" implicit-role functions, which check
81
- // getSectioningContentPlusMainSelector — a fixed HTML content-type list,
82
- // not a deep accname-style computation), a <header>/<footer> is only a
83
- // "banner"/"contentinfo" landmark when it is NOT nested inside sectioning
84
- // content (article/aside/nav/section) or <main>; nested, its implicit
85
- // role is generic/null instead. Simplified to a tag-name ancestor walk
86
- // (skipping that engine's additional role=article/complementary/navigation/
87
- // region/main equivalences) — deliberately pragmatic, matching this
88
- // table's existing precedent (hasAccessibleNameHint above takes the same
89
- // "close enough, tag-based" approach rather than a full spec re-implementation).
90
- function hasSectioningAncestor(el) {
88
+ // Shared "does this element have a landmark-scoping ancestor" primitive
89
+ // — <header>'s "banner", <footer>'s "contentinfo", and <aside>'s
90
+ // "complementary" implicit roles are all conditioned on the same W3C
91
+ // ARIA-in-HTML exclusion: suppressed when nested inside sectioning
92
+ // content (article/aside/nav/section), and for header/footer only,
93
+ // also suppressed when nested inside <main> (pass includeMain: true).
94
+ // <aside> itself omits <main> from its own exclusion list — see the
95
+ // `aside` case in getElementRoleKey below — so callers must pass the
96
+ // right includeMain for the role they're computing.
97
+ //
98
+ // ROLE-AWARE, not tag-only: an ancestor's bare TAG only counts when it
99
+ // has NO role attribute at all; once ANY role attribute is present,
100
+ // only that attribute's own (first-token) value decides membership —
101
+ // matching a widely-used reference engine's real
102
+ // getSectioningContentSelector/getSectioningContentPlusMainSelector,
103
+ // verified 2026-07-30 by reading that engine's source directly:
104
+ // `${tag}:not([role])` for the tag-based branch, OR'd with a wholly
105
+ // separate ` [role=article], [role=complementary], [role=navigation],
106
+ // [role=region]` branch (plus `, main:not([role]), [role=main]` for
107
+ // the plus-main variant) — never a tag-AND-role intersection.
108
+ //
109
+ // This replaced a tag-name-only ancestor walk (used only for <header>,
110
+ // and separately re-duplicated with the same tag-only bug across 6
111
+ // manual landmark-check files — see each file's own delegation to
112
+ // helpers.hasLandmarkScopingAncestor now) that missed a real page:
113
+ // handsontable.com's docs-assistant side panel is an
114
+ // <aside role="dialog"> containing its own <header>. role="dialog" is
115
+ // not one of the four scoping roles, so per spec the nested <header>
116
+ // DOES keep its implicit "banner" role (confirmed against that
117
+ // reference engine's real output via a minimal repro) — but the old
118
+ // tag-only check unconditionally suppressed it purely because the
119
+ // ancestor TAG was <aside>, regardless of its role override. A real
120
+ // false negative in landmark-no-duplicate-banner/landmark-unique,
121
+ // found via the cross-engine comparisons project 2026-07-30.
122
+ const LANDMARK_SCOPING_TAGS = new Set(['article', 'aside', 'nav', 'section']);
123
+ const LANDMARK_SCOPING_ROLE_TOKENS = new Set(['article', 'complementary', 'navigation', 'region']);
124
+
125
+ function isLandmarkScopingAncestorElement(el, includeMain) {
126
+ const tag = lower(el.tagName || '');
127
+ const roleAttr = getAttr(el, 'role');
128
+ if (roleAttr == null) {
129
+ // No role attribute at all: falls back to the plain HTML tag.
130
+ if (LANDMARK_SCOPING_TAGS.has(tag)) return true;
131
+ return includeMain && tag === 'main';
132
+ }
133
+ // A role attribute is present (even empty/invalid) — the element's
134
+ // bare TAG no longer counts; only an explicit, scoping-relevant
135
+ // role value does.
136
+ const token = trim(roleAttr).split(/\s+/)[0].toLowerCase();
137
+ if (LANDMARK_SCOPING_ROLE_TOKENS.has(token)) return true;
138
+ return includeMain && token === 'main';
139
+ }
140
+
141
+ function hasLandmarkScopingAncestor(el, opts) {
91
142
  if (!isElement(el)) return false;
143
+ const includeMain = !!(opts && opts.includeMain);
92
144
  let cur = el.parentElement;
93
145
  let guard = 0;
94
146
  while (cur && guard++ < 200) {
95
- const t = lower(cur.tagName || '');
96
- if (t === 'article' || t === 'aside' || t === 'main' || t === 'nav' || t === 'section') return true;
147
+ if (isLandmarkScopingAncestorElement(cur, includeMain)) return true;
148
+ // Don't climb past the scanned scope -- a contextSelector-scoped
149
+ // (or fragment) scan should never let ancestry OUTSIDE the
150
+ // analyzed subtree affect a role computed WITHIN it.
151
+ if (ariaRoots.includes(cur)) break;
97
152
  cur = cur.parentElement;
98
153
  }
99
154
  return false;
@@ -749,14 +804,16 @@ function createAriaHelpers(opts, shared) {
749
804
  if (tag === 'header') {
750
805
  // <header>'s own implicit role is conditional: "banner" when
751
806
  // top-level (not nested inside sectioning content/<main>),
752
- // generic/null when nested — see hasSectioningAncestor above.
807
+ // generic/null when nested — see hasLandmarkScopingAncestor
808
+ // above (includeMain: true, matching <header>'s real exclusion
809
+ // list, which does include <main>).
753
810
  // role="banner" restated is only a no-op restatement of the
754
811
  // native role (and therefore permitted) at the top level; a
755
812
  // widely-used reference engine's own allowedRoles array for
756
813
  // <header> doesn't include 'banner'
757
814
  // at all (reached only via the native-role-match branch), same
758
815
  // shape as <section>'s 'region'.
759
- return hasSectioningAncestor(el) ? 'header' : 'header[toplevel]';
816
+ return hasLandmarkScopingAncestor(el, { includeMain: true }) ? 'header' : 'header[toplevel]';
760
817
  }
761
818
 
762
819
  if (tag === 'label') {
@@ -899,7 +956,14 @@ function createAriaHelpers(opts, shared) {
899
956
  getRequiredOwnedRoles,
900
957
  getRequiredContextRoles,
901
958
  isRoleAllowedOnElement,
902
- getContainmentRole
959
+ getContainmentRole,
960
+
961
+ // Shared "does this element have a landmark-scoping ancestor"
962
+ // primitive — see its own header comment above. Re-exported at
963
+ // helpers' top level too (src/core/dom-helpers.js), matching
964
+ // getLandmarkNameInfo's precedent, for the manual landmark-check
965
+ // files that used to each carry their own (buggy, tag-only) copy.
966
+ hasLandmarkScopingAncestor
903
967
  };
904
968
  }
905
969
 
@@ -120,6 +120,11 @@ function createDomHelpers(opts) {
120
120
  // opt out with includeHiddenElements:true.
121
121
  const includeHiddenElements = !!(opts && opts.includeHiddenElements === true);
122
122
  const excludeSelectors = Array.isArray(opts && opts.excludeSelectors) ? opts.excludeSelectors : [];
123
+ // Default off: explicit opt-in for "this scan target was never meant to
124
+ // represent a real page" (e.g. a raw component fragment parsed on its
125
+ // own), regardless of whether document.documentElement happens to be in
126
+ // scope. See isWholeDocumentScope() below.
127
+ const fragment = !!(opts && opts.fragment === true);
123
128
 
124
129
  // Rule-scoped excludes (engineOptions.rules[ruleId].excludeSelectors), set
125
130
  // by dom-runner.js immediately before invoking each rule's applicability/
@@ -1092,7 +1097,8 @@ function createDomHelpers(opts) {
1092
1097
  'templateContent',
1093
1098
  'nonRenderedElement',
1094
1099
  'inputHidden',
1095
- 'visibilityHidden'
1100
+ 'visibilityHidden',
1101
+ 'contentVisibilityHidden'
1096
1102
  ]);
1097
1103
 
1098
1104
  function queryAllSmart(sel) {
@@ -1109,6 +1115,23 @@ function createDomHelpers(opts) {
1109
1115
  for (const r of reasons) {
1110
1116
  if (HARD_HIDDEN_REASONS.has(r)) return false;
1111
1117
  }
1118
+
1119
+ // `isAccTreeEligible` can short-circuit on an inert ancestor
1120
+ // before it reaches an outer hard-hidden ancestor (e.g.
1121
+ // display:none wrapper). In that case the node is still
1122
+ // structurally hidden and should be excluded by the default
1123
+ // hidden-content policy.
1124
+ if (reasons.includes('inert')) {
1125
+ const domVis = isDomVisibleEligible(el, null, {
1126
+ visibilityMode: 'styleOnly',
1127
+ disableGeometry: true,
1128
+ ignoreOpacity: true
1129
+ });
1130
+ const domReasons = Array.isArray(domVis && domVis.reasons) ? domVis.reasons : [];
1131
+ for (const r of domReasons) {
1132
+ if (HARD_HIDDEN_REASONS.has(r)) return false;
1133
+ }
1134
+ }
1112
1135
  return true;
1113
1136
  } catch {
1114
1137
  return true;
@@ -3685,6 +3708,36 @@ function createDomHelpers(opts) {
3685
3708
  let node = el;
3686
3709
  let safety = 0;
3687
3710
 
3711
+ // Only apply the "stop climbing once we reach a contextSelector-
3712
+ // matched root" shortcut when there's a single (or no) matched
3713
+ // root -- resolveContextRoots() falls back to `[documentElement]`
3714
+ // when no contextSelector is given, so this is the overwhelmingly
3715
+ // common case and behaves exactly as before.
3716
+ //
3717
+ // With MULTIPLE matched roots (multi-region contextSelector
3718
+ // scans), stopping there without recording anything about which
3719
+ // root produced an ambiguous, non-unique selector string for two
3720
+ // structurally-identical regions -- a real, confirmed bug (found
3721
+ // 2026-07-29 via the cross-engine comparisons project): two
3722
+ // wrapper <div>s, each containing two identical ".widget"
3723
+ // sections scanned via `contextSelector: '.widget'`, produced the
3724
+ // *same* selector string ("section:nth-of-type(1) > div > div >
3725
+ // button") for the equivalent button in each wrapper --
3726
+ // resolving to 2 elements instead of 1 when queried, and pointing
3727
+ // at the wrong one for at least one of the two occurrences. The
3728
+ // existing `el.matches(candidate)` safety check below couldn't
3729
+ // catch this: it only verifies THIS element matches the string,
3730
+ // never that the string is unique document-wide.
3731
+ //
3732
+ // Fix: when multiple roots are in play, don't stop early --
3733
+ // keep climbing (same as the always-correct no-contextSelector
3734
+ // path) until finding a genuinely unique anchor or reaching the
3735
+ // true document root, which is always singular. That restores
3736
+ // the invariant the final safety-check comment below relies on,
3737
+ // rather than needing a separate (more expensive) document-wide
3738
+ // uniqueness re-check.
3739
+ const stopAtMatchedRoot = roots.length <= 1;
3740
+
3688
3741
  while (node && node.nodeType === 1 && safety++ < 20) {
3689
3742
  let anchor = null;
3690
3743
 
@@ -3724,7 +3777,7 @@ function createDomHelpers(opts) {
3724
3777
  parts.unshift(nthOfType(node));
3725
3778
  }
3726
3779
 
3727
- if (!node.parentElement || roots.includes(node)) break;
3780
+ if (!node.parentElement || (stopAtMatchedRoot && roots.includes(node))) break;
3728
3781
  node = node.parentElement;
3729
3782
  }
3730
3783
 
@@ -3743,7 +3796,12 @@ function createDomHelpers(opts) {
3743
3796
  // position relative to its own parent via `>` (child, not
3744
3797
  // descendant) combinators, so a correctly-matching chain can
3745
3798
  // only resolve to one element short of a malformed document
3746
- // (e.g. two <html> roots). Re-deriving that guarantee via a
3799
+ // (e.g. two <html> roots) -- true as long as the walk above
3800
+ // never stops short of a genuinely unique anchor/root, which is
3801
+ // exactly what `stopAtMatchedRoot` now guarantees (see its own
3802
+ // comment above; a multi-root contextSelector scan stopping
3803
+ // early used to violate this invariant silently). Re-deriving
3804
+ // that guarantee via a
3747
3805
  // document-wide :nth-of-type scan was measured to cost O(total
3748
3806
  // same-tag siblings) per call — pathological on pages with many
3749
3807
  // flat, unidentified siblings (e.g. hundreds of unlabeled
@@ -4069,6 +4127,22 @@ function createDomHelpers(opts) {
4069
4127
  {trim}
4070
4128
  );
4071
4129
 
4130
+ // For rules whose check is inherently about the WHOLE page (does the
4131
+ // page have a title? a declared language? a way to skip repeated
4132
+ // blocks?) rather than about elements found within a scanned subtree --
4133
+ // these can't be answered correctly by scoping via queryAllSmart/ctx.root
4134
+ // the way per-element checks can, since a subtree that never had (and
4135
+ // was never meant to have) e.g. its own <title> shouldn't be faulted for
4136
+ // lacking one. `false` when `fragment:true` was explicitly set, or when
4137
+ // `contextSelector` scoped this run narrower than the whole document
4138
+ // (roots doesn't include document.documentElement); `true` in the
4139
+ // default/unscoped case, so this is a no-op for the overwhelming
4140
+ // majority of existing (whole-page) scans.
4141
+ function isWholeDocumentScope() {
4142
+ if (fragment) return false;
4143
+ return roots.includes(document.documentElement);
4144
+ }
4145
+
4072
4146
  return {
4073
4147
  // Existing query/snippet utilities
4074
4148
  queryAll,
@@ -4084,6 +4158,7 @@ function createDomHelpers(opts) {
4084
4158
  isExcluded,
4085
4159
  isAccTreeEligible,
4086
4160
  isDomVisibleEligible,
4161
+ isWholeDocumentScope,
4087
4162
 
4088
4163
  // Engine-internal: sets which rule's rule-scoped excludeSelectors
4089
4164
  // (engineOptions.rules[ruleId].excludeSelectors) are currently in
@@ -4108,6 +4183,16 @@ function createDomHelpers(opts) {
4108
4183
  // see getLandmarkNameInfo's own header comment for why this replaced 7 duplicated copies)
4109
4184
  getLandmarkNameInfo,
4110
4185
 
4186
+ // "Does this element have a landmark-scoping ancestor" (role-aware
4187
+ // sectioning-content/<main> check backing <header>/<footer>/<aside>'s
4188
+ // conditional implicit roles) -- re-exported from aria helpers at
4189
+ // this top level, matching getLandmarkNameInfo just above, so the
4190
+ // manual landmark-check files that used to each carry their own
4191
+ // (buggy, tag-only) copy can call helpers.hasLandmarkScopingAncestor
4192
+ // directly. See aria.hasLandmarkScopingAncestor's own header comment
4193
+ // in src/core/aria-helpers.js for the full algorithm and rationale.
4194
+ hasLandmarkScopingAncestor: aria.hasLandmarkScopingAncestor,
4195
+
4111
4196
  // Name / description
4112
4197
  getAccessibleNameInfo,
4113
4198
  getAccessibleDescriptionInfo,
@@ -52,6 +52,9 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
52
52
  // unless the caller explicitly opts in.
53
53
  const includeHiddenElements = !!(engineOptionsResolved && engineOptionsResolved.includeHiddenElements === true);
54
54
  const excludeSelectors = normalizeSelectorList(engineOptionsResolved && engineOptionsResolved.excludeSelectors);
55
+ // Default off: explicit opt-in for "this scan target was never meant to
56
+ // represent a real page" -- see helpers.isWholeDocumentScope().
57
+ const fragment = !!(engineOptionsResolved && engineOptionsResolved.fragment === true);
55
58
 
56
59
  const url = pageUrl || (document.location && document.location.href) || null;
57
60
  const title = document.title || null;
@@ -61,6 +64,21 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
61
64
  ? engineOptionsResolved.timestamp.trim()
62
65
  : null;
63
66
 
67
+ // createDomHelpers()/createContrastHelpers() persist their element-keyed
68
+ // caches (outerHtmlCache, selectorCache, etc.) on window.__a11ycoreSharedCache
69
+ // so multiple helper instances created *within this run* can share them
70
+ // deterministically. But a window/document is frequently reused across
71
+ // SEPARATE runs -- e.g. Jest's jsdom environment creates one window per
72
+ // test file, and mutating document.body between it() blocks is standard.
73
+ // Those caches are keyed by element reference, not content, so a run that
74
+ // reuses an already-cached element (document.body never changes identity)
75
+ // would otherwise read stale data cached by an earlier, unrelated run on
76
+ // the same window. Clearing at the start of every run keeps sharing scoped
77
+ // to "this run" as intended, without leaking across runs.
78
+ try {
79
+ if (window && window.__a11ycoreSharedCache) window.__a11ycoreSharedCache = {};
80
+ } catch {}
81
+
64
82
  const sharedHelpers = createDomHelpers({
65
83
  document,
66
84
  window,
@@ -68,6 +86,7 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
68
86
  includeShadowDom,
69
87
  includeHiddenElements,
70
88
  excludeSelectors,
89
+ fragment,
71
90
  // Optional perf counters (bench/debug only). Deterministic and per-run.
72
91
  perfStats: !!(engineOptionsResolved && engineOptionsResolved.perfStats)
73
92
  });
@@ -204,6 +223,8 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
204
223
  ruleVersion: normalizedMeta.ruleVersion,
205
224
  normative: normalizedMeta.normative,
206
225
  atomic: normalizedMeta.atomic,
226
+ deprecated: normalizedMeta.deprecated,
227
+ deprecation: normalizedMeta.deprecation,
207
228
  category: normalizedMeta.category,
208
229
  standard: normalizedMeta.standard,
209
230
  applicability: normalizedMeta.applicability,
@@ -463,6 +484,8 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
463
484
  ruleVersion: '0.0.0',
464
485
  normative: true,
465
486
  atomic: false,
487
+ deprecated: false,
488
+ deprecation: null,
466
489
  category: null,
467
490
  standard: null,
468
491
  applicability: '',
@@ -80,6 +80,23 @@ function normalizeRuleMeta(ruleId, id, meta, engineTag) {
80
80
  const normative = (typeof m.normative === 'boolean') ? m.normative : true;
81
81
  const atomic = (typeof m.atomic === 'boolean') ? m.atomic : true;
82
82
 
83
+ // Deprecation signal for the rule catalog (see docs/API_STABILITY.md).
84
+ // Purely informational -- a deprecated rule still runs and produces
85
+ // results completely normally; this is a catalog-level migration signal
86
+ // for integrators, not an automatic exclusion.
87
+ const deprecated = (typeof m.deprecated === 'boolean') ? m.deprecated : false;
88
+ const deprecation = (deprecated && m.deprecation && typeof m.deprecation === 'object' && !Array.isArray(m.deprecation))
89
+ ? {
90
+ replacedBy: (typeof m.deprecation.replacedBy === 'string' && m.deprecation.replacedBy.trim()) ? m.deprecation.replacedBy.trim() : null,
91
+ reason: (typeof m.deprecation.reason === 'string') ? m.deprecation.reason.trim() : '',
92
+ sinceVersion: (typeof m.deprecation.sinceVersion === 'string') ? m.deprecation.sinceVersion.trim() : ''
93
+ }
94
+ : null;
95
+
96
+ if (deprecated && (!deprecation || !deprecation.reason || !deprecation.sinceVersion)) {
97
+ throw new Error(`Rule ${ruleId}: meta.deprecated:true requires meta.deprecation.reason and meta.deprecation.sinceVersion`);
98
+ }
99
+
83
100
  const category = (typeof m.category === 'string' && m.category.trim()) ? m.category.trim() : null;
84
101
  const standard = (typeof m.standard === 'string' && m.standard.trim()) ? m.standard.trim() : null;
85
102
 
@@ -127,6 +144,8 @@ function normalizeRuleMeta(ruleId, id, meta, engineTag) {
127
144
  ruleVersion,
128
145
  normative,
129
146
  atomic,
147
+ deprecated,
148
+ deprecation,
130
149
  category,
131
150
  standard,
132
151
  applicability,