@surea11y/core 1.1.2 → 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 (47) hide show
  1. package/CHANGELOG.md +14 -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-hidden-body.js +9 -1
  15. package/src/checks/automatic/aria-prohibited-children.js +71 -14
  16. package/src/checks/automatic/bypass-blocks-present.js +9 -1
  17. package/src/checks/automatic/css-orientation-lock.js +9 -1
  18. package/src/checks/automatic/html-xml-lang-mismatch.js +9 -1
  19. package/src/checks/automatic/language-page-present.js +9 -1
  20. package/src/checks/automatic/meta-refresh-no-exceptions.js +9 -1
  21. package/src/checks/automatic/meta-refresh-timing-absent.js +9 -1
  22. package/src/checks/automatic/meta-viewport-zoom-enabled.js +9 -1
  23. package/src/checks/automatic/page-title-present.js +9 -1
  24. package/src/checks/manual/empty-heading-manual.js +1 -6
  25. package/src/checks/manual/empty-table-header-manual.js +5 -9
  26. package/src/checks/manual/heading-order-manual.js +1 -6
  27. package/src/checks/manual/landmark-banner-is-top-level-manual.js +30 -14
  28. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +30 -14
  29. package/src/checks/manual/landmark-main-is-top-level-manual.js +30 -14
  30. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +25 -13
  31. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +25 -13
  32. package/src/checks/manual/landmark-one-main-manual.js +9 -1
  33. package/src/checks/manual/landmark-unique-manual.js +32 -30
  34. package/src/checks/manual/meta-viewport-large-manual.js +9 -1
  35. package/src/checks/manual/page-has-heading-one-manual.js +9 -1
  36. package/src/checks/manual/page-title-patterns-manual.js +9 -1
  37. package/src/checks/manual/region-manual.js +34 -14
  38. package/src/checks/manual/scope-attr-valid-manual.js +2 -7
  39. package/src/checks/manual/tabindex-manual.js +2 -7
  40. package/src/core/aria-helpers.js +82 -18
  41. package/src/core/dom-helpers.js +32 -0
  42. package/src/core/dom-runner.js +8 -0
  43. package/src/core/rule-meta.js +19 -0
  44. package/src/core.js +1781 -404
  45. package/src/i18n/en.js +2 -0
  46. package/src/i18n/fr.js +2 -0
  47. package/src/report.js +482 -0
@@ -71,25 +71,37 @@ function runInPage(ctx) {
71
71
  return raw.split(/\s+/)[0].toLowerCase();
72
72
  }
73
73
 
74
- const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
75
-
76
- function isSuppressedBySectioningAncestor(el) {
77
- let p = el.parentElement;
78
- while (p) {
79
- const tag = p.tagName ? p.tagName.toLowerCase() : '';
80
- if (SECTIONING_ANCESTORS.has(tag)) return true;
81
- p = p.parentElement;
82
- }
83
- return false;
74
+ // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
75
+ // question "does this element sit inside a sectioning-content/<main>
76
+ // ancestor that suppresses its conditional implicit role" — role-aware
77
+ // (an ancestor's bare TAG only counts when it carries no role attribute
78
+ // at all; an explicit role="dialog"-style override no longer suppresses)
79
+ // rather than a local tag-only copy. See that function's header comment
80
+ // in src/core/aria-helpers.js for the full algorithm and the real page
81
+ // (handsontable.com's docs-assistant side panel, an
82
+ // <aside role="dialog"> containing its own <header>) that surfaced this
83
+ // rule's own former tag-only copy as a false negative.
84
+ function hasSectioningAncestor(el, includeMain) {
85
+ return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
86
+ ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
87
+ : false;
84
88
  }
85
89
 
86
90
  function getImplicitLandmarkRole(el) {
87
91
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
88
- if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
89
- if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
92
+ if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
93
+ if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
90
94
  if (tag === 'main') return 'main';
91
95
  if (tag === 'nav') return 'navigation';
92
- if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
96
+ if (tag === 'aside') {
97
+ // A named <aside> is never suppressed, even when nested — matches
98
+ // landmark-unique's own verified-against-reference-engine precedent
99
+ // (that engine's real `aside` implicit-role function keeps
100
+ // "complementary" when the element has an accessible name, even
101
+ // inside sectioning content); propagated here for consistency.
102
+ if (!hasSectioningAncestor(el, false)) return 'complementary';
103
+ return getAccessibleLandmarkName(el) ? 'complementary' : '';
104
+ }
93
105
  if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
94
106
  if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
95
107
  return '';
@@ -61,6 +61,14 @@ const meta = {
61
61
  coverage: {}
62
62
  };
63
63
 
64
+ // This check is inherently whole-document (does the PAGE have this
65
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
66
+ // scoped this run narrower than the whole document, or when
67
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
68
+ function applicability(ctx) {
69
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
70
+ }
71
+
64
72
  function runInPage(ctx) {
65
73
  const { document, helpers, rule } = ctx;
66
74
 
@@ -148,4 +156,4 @@ function runInPage(ctx) {
148
156
  };
149
157
  }
150
158
 
151
- module.exports = { id, meta, runInPage };
159
+ module.exports = { id, meta, runInPage, applicability };
@@ -75,46 +75,48 @@ function runInPage(ctx) {
75
75
  return raw.split(/\s+/)[0].toLowerCase();
76
76
  }
77
77
 
78
- // Two distinct ancestor sets, verified 2026-07-20 against a widely-used
79
- // reference engine's own implicit-role functions directly rather than
80
- // assumed from one shared list: <header>/<footer> use "sectioning content
81
- // PLUS <main>" (that engine's getSectioningContentPlusMainSelector) to decide
82
- // banner/contentinfo suppression, but <aside> uses PLAIN sectioning
83
- // content only (article/aside/nav/section — NOT main) to decide
84
- // complementary suppression. The old single SECTIONING_ANCESTORS set
85
- // (which included 'main') was correct for header/footer but wrong for
86
- // aside — found via a real page: Know Your Meme's two unnamed
87
- // <aside class="extra-large-only"> elements are direct children of
88
- // <main>, which incorrectly suppressed their implicit "complementary"
89
- // role entirely, hiding a real duplicate-landmark violation that
90
- // reference engine correctly flags.
91
- const SECTIONING_ANCESTORS_PLUS_MAIN = new Set(['article', 'aside', 'main', 'nav', 'section']);
92
- const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'nav', 'section']);
93
-
94
- function hasSectioningAncestorFrom(el, set) {
95
- let p = el.parentElement;
96
- while (p) {
97
- const tag = p.tagName ? p.tagName.toLowerCase() : '';
98
- if (set.has(tag)) return true;
99
- p = p.parentElement;
100
- }
101
- return false;
78
+ // Delegates to the shared helpers.hasLandmarkScopingAncestor (role-aware:
79
+ // an ancestor's bare TAG only counts when it carries no role attribute at
80
+ // all; an explicit role="dialog"-style override no longer suppresses —
81
+ // see that function's header comment in src/core/aria-helpers.js) rather
82
+ // than the two local tag-only Sets this file used to carry. Two distinct
83
+ // ancestor scopes, verified 2026-07-20 against a widely-used reference
84
+ // engine's own implicit-role functions directly rather than assumed from
85
+ // one shared list: <header>/<footer> use "sectioning content PLUS <main>"
86
+ // (includeMain: true) to decide banner/contentinfo suppression, but
87
+ // <aside> uses PLAIN sectioning content only — NOT main (includeMain:
88
+ // false) — to decide complementary suppression. The old single
89
+ // SECTIONING_ANCESTORS set (which included 'main') was correct for
90
+ // header/footer but wrong for aside — found via a real page: Know Your
91
+ // Meme's two unnamed <aside class="extra-large-only"> elements are direct
92
+ // children of <main>, which incorrectly suppressed their implicit
93
+ // "complementary" role entirely, hiding a real duplicate-landmark
94
+ // violation that reference engine correctly flags. The tag-only
95
+ // (non-role-aware) half of this bug was separately found and fixed
96
+ // 2026-07-30 via the cross-engine comparisons project, on
97
+ // handsontable.com's docs-assistant side panel: an <aside role="dialog">
98
+ // containing its own <header> — role="dialog" isn't one of the four
99
+ // scoping roles, so the nested <header> keeps "banner" per spec, but a
100
+ // tag-only check unconditionally suppressed it just because the ancestor
101
+ // TAG was <aside>.
102
+ function hasSectioningAncestor(el, includeMain) {
103
+ return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
104
+ ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
105
+ : false;
102
106
  }
103
107
 
104
108
  function getImplicitLandmarkRole(el) {
105
109
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
106
- if (tag === 'header') return hasSectioningAncestorFrom(el, SECTIONING_ANCESTORS_PLUS_MAIN) ? '' : 'banner';
107
- if (tag === 'footer') return hasSectioningAncestorFrom(el, SECTIONING_ANCESTORS_PLUS_MAIN) ? '' : 'contentinfo';
110
+ if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
111
+ if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
108
112
  if (tag === 'main') return 'main';
109
113
  if (tag === 'nav') return 'navigation';
110
114
  if (tag === 'aside') {
111
115
  // Per a widely-used reference engine's own `aside` implicit-role function: suppressed by a
112
116
  // sectioning-content ancestor ONLY when the <aside> also has no
113
117
  // accessible name — a named <aside> is never suppressed, even when
114
- // nested. Not yet evidenced by a real page in this corpus, but
115
- // implemented to match the verified source exactly rather than
116
- // leaving a known partial fix in place.
117
- if (!hasSectioningAncestorFrom(el, SECTIONING_ANCESTORS)) return 'complementary';
118
+ // nested.
119
+ if (!hasSectioningAncestor(el, false)) return 'complementary';
118
120
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
119
121
  }
120
122
  if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
@@ -43,6 +43,14 @@ const meta = {
43
43
  coverage: {}
44
44
  };
45
45
 
46
+ // This check is inherently whole-document (does the PAGE have this
47
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
48
+ // scoped this run narrower than the whole document, or when
49
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
50
+ function applicability(ctx) {
51
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
52
+ }
53
+
46
54
  function runInPage(ctx) {
47
55
  const { document, helpers, rule } = ctx;
48
56
 
@@ -116,4 +124,4 @@ function runInPage(ctx) {
116
124
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
117
125
  }
118
126
 
119
- module.exports = { id, meta, runInPage };
127
+ module.exports = { id, meta, runInPage, applicability };
@@ -53,6 +53,14 @@ const meta = {
53
53
  coverage: {}
54
54
  };
55
55
 
56
+ // This check is inherently whole-document (does the PAGE have this
57
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
58
+ // scoped this run narrower than the whole document, or when
59
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
60
+ function applicability(ctx) {
61
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
62
+ }
63
+
56
64
  function runInPage(ctx) {
57
65
  const { document, helpers, rule } = ctx;
58
66
 
@@ -136,4 +144,4 @@ function runInPage(ctx) {
136
144
  };
137
145
  }
138
146
 
139
- module.exports = { id, meta, runInPage };
147
+ module.exports = { id, meta, runInPage, applicability };
@@ -24,6 +24,14 @@ const meta = {
24
24
  coverage: { facetsBySc: { '2.4.2': ['page-title-patterns'] } }
25
25
  };
26
26
 
27
+ // This check is inherently whole-document (does the PAGE have this
28
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
29
+ // scoped this run narrower than the whole document, or when
30
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
31
+ function applicability(ctx) {
32
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
33
+ }
34
+
27
35
  function runInPage(ctx) {
28
36
  const { document, helpers, rule } = ctx;
29
37
  const probes = ctx && ctx.inputs && ctx.inputs.probes && typeof ctx.inputs.probes === 'object'
@@ -259,4 +267,4 @@ function runInPage(ctx) {
259
267
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
260
268
  }
261
269
 
262
- module.exports = { id, meta, runInPage };
270
+ module.exports = { id, meta, runInPage, applicability };
@@ -46,6 +46,14 @@ const meta = {
46
46
  coverage: {}
47
47
  };
48
48
 
49
+ // This check is inherently whole-document (does the PAGE have this
50
+ // property?), not evaluable per-subtree -- notApplicable when contextSelector
51
+ // scoped this run narrower than the whole document, or when
52
+ // engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
53
+ function applicability(ctx) {
54
+ return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
55
+ }
56
+
49
57
  function runInPage(ctx) {
50
58
  const { document, helpers, rule } = ctx;
51
59
 
@@ -79,25 +87,37 @@ function runInPage(ctx) {
79
87
  return raw.split(/\s+/)[0].toLowerCase();
80
88
  }
81
89
 
82
- const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
83
-
84
- function isSuppressedBySectioningAncestor(el) {
85
- let p = el.parentElement;
86
- while (p) {
87
- const tag = p.tagName ? p.tagName.toLowerCase() : '';
88
- if (SECTIONING_ANCESTORS.has(tag)) return true;
89
- p = p.parentElement;
90
- }
91
- return false;
90
+ // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
91
+ // question "does this element sit inside a sectioning-content/<main>
92
+ // ancestor that suppresses its conditional implicit role" — role-aware
93
+ // (an ancestor's bare TAG only counts when it carries no role attribute
94
+ // at all; an explicit role="dialog"-style override no longer suppresses)
95
+ // rather than a local tag-only copy. See that function's header comment
96
+ // in src/core/aria-helpers.js for the full algorithm and the real page
97
+ // (handsontable.com's docs-assistant side panel, an
98
+ // <aside role="dialog"> containing its own <header>) that surfaced this
99
+ // rule's own former tag-only copy as a false negative.
100
+ function hasSectioningAncestor(el, includeMain) {
101
+ return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
102
+ ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
103
+ : false;
92
104
  }
93
105
 
94
106
  function getImplicitLandmarkRole(el) {
95
107
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
96
- if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
97
- if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
108
+ if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
109
+ if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
98
110
  if (tag === 'main') return 'main';
99
111
  if (tag === 'nav') return 'navigation';
100
- if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
112
+ if (tag === 'aside') {
113
+ // A named <aside> is never suppressed, even when nested — matches
114
+ // landmark-unique's own verified-against-reference-engine precedent
115
+ // (that engine's real `aside` implicit-role function keeps
116
+ // "complementary" when the element has an accessible name, even
117
+ // inside sectioning content); propagated here for consistency.
118
+ if (!hasSectioningAncestor(el, false)) return 'complementary';
119
+ return getAccessibleLandmarkName(el) ? 'complementary' : '';
120
+ }
101
121
  if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
102
122
  if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
103
123
  return '';
@@ -180,4 +200,4 @@ function runInPage(ctx) {
180
200
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
181
201
  }
182
202
 
183
- module.exports = { id, meta, runInPage };
203
+ module.exports = { id, meta, runInPage, applicability };
@@ -39,16 +39,11 @@ const meta = {
39
39
  };
40
40
 
41
41
  function runInPage(ctx) {
42
- const { document, helpers, rule } = ctx;
42
+ const { helpers, rule } = ctx;
43
43
 
44
44
  const VALID_SCOPES = new Set(['row', 'col', 'rowgroup', 'colgroup']);
45
45
 
46
- let nodes = [];
47
- try {
48
- nodes = document.querySelectorAll('[scope]');
49
- } catch {
50
- nodes = [];
51
- }
46
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[scope]') : helpers.queryAll('[scope]');
52
47
 
53
48
  const occurrences = [];
54
49
  let applicableCount = 0;
@@ -40,14 +40,9 @@ const meta = {
40
40
  };
41
41
 
42
42
  function runInPage(ctx) {
43
- const { document, helpers, rule } = ctx;
43
+ const { helpers, rule } = ctx;
44
44
 
45
- let nodes = [];
46
- try {
47
- nodes = document.querySelectorAll('[tabindex]');
48
- } catch {
49
- nodes = [];
50
- }
45
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[tabindex]') : helpers.queryAll('[tabindex]');
51
46
 
52
47
  const occurrences = [];
53
48
  let applicableCount = 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/
@@ -4122,6 +4127,22 @@ function createDomHelpers(opts) {
4122
4127
  {trim}
4123
4128
  );
4124
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
+
4125
4146
  return {
4126
4147
  // Existing query/snippet utilities
4127
4148
  queryAll,
@@ -4137,6 +4158,7 @@ function createDomHelpers(opts) {
4137
4158
  isExcluded,
4138
4159
  isAccTreeEligible,
4139
4160
  isDomVisibleEligible,
4161
+ isWholeDocumentScope,
4140
4162
 
4141
4163
  // Engine-internal: sets which rule's rule-scoped excludeSelectors
4142
4164
  // (engineOptions.rules[ruleId].excludeSelectors) are currently in
@@ -4161,6 +4183,16 @@ function createDomHelpers(opts) {
4161
4183
  // see getLandmarkNameInfo's own header comment for why this replaced 7 duplicated copies)
4162
4184
  getLandmarkNameInfo,
4163
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
+
4164
4196
  // Name / description
4165
4197
  getAccessibleNameInfo,
4166
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;
@@ -83,6 +86,7 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
83
86
  includeShadowDom,
84
87
  includeHiddenElements,
85
88
  excludeSelectors,
89
+ fragment,
86
90
  // Optional perf counters (bench/debug only). Deterministic and per-run.
87
91
  perfStats: !!(engineOptionsResolved && engineOptionsResolved.perfStats)
88
92
  });
@@ -219,6 +223,8 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
219
223
  ruleVersion: normalizedMeta.ruleVersion,
220
224
  normative: normalizedMeta.normative,
221
225
  atomic: normalizedMeta.atomic,
226
+ deprecated: normalizedMeta.deprecated,
227
+ deprecation: normalizedMeta.deprecation,
222
228
  category: normalizedMeta.category,
223
229
  standard: normalizedMeta.standard,
224
230
  applicability: normalizedMeta.applicability,
@@ -478,6 +484,8 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
478
484
  ruleVersion: '0.0.0',
479
485
  normative: true,
480
486
  atomic: false,
487
+ deprecated: false,
488
+ deprecation: null,
481
489
  category: null,
482
490
  standard: null,
483
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,