@surea11y/core 1.0.1 → 1.1.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.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,10 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.1.0] - 2026-07-28
8
+
7
9
  ### Added
10
+ - `engineOptions.rules[ruleId].excludeSelectors`: rule-scoped exclusions, narrowing candidates for exactly one rule on top of (never instead of) the existing global `excludeSelectors`. Resolves the class of false positive where one rule misfires on a component (e.g. Angular Material's `mat-select` tripping `aria-required-children`) while every other rule still needs to see it. Filtering happens upstream of each rule's own outcome decision, so no rule files changed. See `docs/ENGINE_OPTIONS.md`'s "Rule-scoped `excludeSelectors`" section.
8
11
  - Completed French (`fr`) localization: translated the 313 remaining `src/i18n/fr.js` keys, bringing French to full parity with English (600/600 keys, up from 287/600). Verified against a live scan (`locale: 'fr'`) and confirmed no key/placeholder mismatches against `src/i18n/en.js`. Landmark terminology uses "point de repère" per MDN's French ARIA documentation.
9
12
 
10
13
  ### Fixed
@@ -95,7 +95,9 @@ const engineOptions = {
95
95
  },
96
96
 
97
97
  rules: {
98
- 'some-rule-id': { /* per-rule config, currently unused — see note */ }
98
+ 'some-rule-id': {
99
+ excludeSelectors: ['.some-noisy-widget'] // narrows candidates for THIS rule only — see note
100
+ }
99
101
  },
100
102
 
101
103
  probes: { /* optional host-supplied evidence, see note */ },
@@ -113,18 +115,44 @@ const engineOptions = {
113
115
  |---|---|
114
116
  | `locale` | Any string; resolution is per-string with graceful fallback (requested locale → `en` → the rule's literal English fallback text), so a partially-translated locale never produces missing text. See [`I18N.md`](./I18N.md) for current locale coverage. |
115
117
  | `includeShadowDom` | Default `true`: rules using `helpers.queryAllSmart` traverse into open shadow roots. Set `false` to scan only the light DOM. Closed shadow roots are never reachable either way (no DOM API exposes them). |
116
- | `excludeSelectors` | Elements matching any of these selectors (and their descendants) are skipped entirely — useful for cookie banners, third-party embeds, or known-noisy widgets you don't control. |
118
+ | `excludeSelectors` | Elements matching any of these selectors (and their descendants) are skipped entirely, for **every** rule — useful for cookie banners, third-party embeds, or known-noisy widgets you don't control. To exclude something from just one specific rule instead, use `rules[ruleId].excludeSelectors` below. |
117
119
  | `timestamp` | Passed straight through to the result's top-level `timestamp` field; the engine does not generate one itself (deterministic-by-design). |
118
120
  | `contrast.mode` | `strictConformance` (default): contrast rules stay silent (`notApplicable`/skip) whenever the true rendered background isn't confidently computable, to protect against false `fail`s. `auditorAssist`: trades some of that safety margin for more findings, intended for a human auditor who will double-check flagged cases, not for unattended CI gating. |
119
121
  | `contrast.rootCanvasFallback` | The assumed page background color when it's not computable at all — only matters in `auditorAssist` mode. |
120
122
  | `visibilityMode` | Controls how strict the three contrast rules (`contrast-minimum`, `contrast-enhanced`, `contrast-computable`) are about deciding a text node is actually eligible to check. **Not read by any other rule.** `'styleOnly'` (default): eligibility is CSS-only — `display`, `visibility`, `opacity`, ancestor-hiding, etc. `'styleAndGeometry'`: adds real layout checks (`getClientRects()`/`getBoundingClientRect()`) on top of that — text with no client rects, or zero width/height, is excluded too. Reach for `'styleAndGeometry'` when running under a real browser/Playwright-Puppeteer (`runa11yCoreInPage`) and you want contrast findings to reflect actual rendered layout rather than just computed style; under plain jsdom (`runDomRulesInPage`) there's no real layout engine, so `'styleAndGeometry'` mostly just adds `getBoundingClientRect()` zero-size checks, not true clipping/overflow detection — see [`LIMITATIONS.md`](./LIMITATIONS.md). |
121
123
  | `policyContract` / `policy` | See [`POLICY.md`](./POLICY.md) — controls which outcomes/confidence values are allowed and whether manual rules' would-be `fail`s get coerced to `cantTell`. |
122
124
  | `output.includeSelector` / `.includeHtml` | Only affects the small number of rules (currently 4 of 125) that rely on the engine's automatic selector/HTML fill-in rather than building their own — most rules set `selector`/`html` themselves inside `runInPage` and are unaffected by this option. Not a reliable way to strip selectors/HTML from all output. |
123
- | `rules[ruleId]` | Passed through to that rule as `ctx.config`. The plumbing exists end-to-end, but **no shipped rule currently reads `ctx.config`** — this is infrastructure for future per-rule configurability, not a lever that changes any of today's 123 rules' behavior. |
125
+ | `rules[ruleId]` | Passed through to that rule as `ctx.config`, and — for `excludeSelectors` specifically — read by the engine itself before the rule ever runs. See "Rule-scoped `excludeSelectors`" below. Any other key is passthrough only: **no shipped rule currently reads `ctx.config`** for anything besides `excludeSelectors`. |
124
126
  | `probes` | An optional, JSON-safe evidence object your host application can supply (depth- and size-capped by the engine before rules see it, via `ctx.inputs.probes`) — for future rules that might accept externally-supplied signals (e.g. real layout measurements a static DOM scan can't compute itself). Not consumed by any current rule. |
125
127
  | `perfStats` / `profileRules` | Debug-only. `perfStats: true` returns internal counters on the result's `perfStats` field; `profileRules: true` additionally adds a per-rule timing breakdown. Shape is not part of the stable output contract — don't build on it. |
126
128
  | `pingWaitTime` / `frameWaitTime` | Only read by `runa11yCoreAcrossFrames` (see [`INTEGRATION.md`](./INTEGRATION.md#cross-frame-scanning-including-cross-origin)) — how long to wait for a child frame to answer a ping (default `500`ms) and a full run request (default `60000`ms) before treating it as unreachable. Ignored by `runDomRulesInPage`/`runa11yCoreInPage`. |
127
129
 
130
+ ### Rule-scoped `excludeSelectors`
131
+
132
+ The top-level `excludeSelectors` applies to *every* rule — there's no way to exclude an element from just one rule while still running every other rule against it. `rules[ruleId].excludeSelectors` fills that gap: it narrows candidates for **that one rule only**, on top of (never instead of) the global list.
133
+
134
+ ```js
135
+ const engineOptions = {
136
+ excludeSelectors: ['#cookie-banner'], // applies to every rule, as always
137
+ rules: {
138
+ 'aria-required-children': {
139
+ excludeSelectors: ['mat-select', 'mat-stepper', 'mat-horizontal-stepper', 'mat-vertical-stepper']
140
+ },
141
+ 'aria-allowed-attr': {
142
+ excludeSelectors: ['mat-progress-spinner']
143
+ }
144
+ }
145
+ };
146
+ ```
147
+
148
+ Why you'd want this: Angular Material's `<mat-select>` builds its internal ARIA structure in a way that trips a false positive on `aria-required-children` specifically, even though the component is otherwise fine. With only the global `excludeSelectors`, the only way to silence that false positive is `excludeSelectors: ['mat-select']` — which also hides `mat-select` from *every other rule*, including `color-contrast` and `aria-allowed-attr`, silently dropping real coverage those checks never had a problem with. The example above keeps `mat-select` fully visible to every rule except the one that misfires on it.
149
+
150
+ Effective exclusions for a given rule are the **union** of the global list and that rule's own list — an element matching either is dropped from that rule's candidates. A rule whose only would-be-failing elements are all excluded this way reports `outcome: 'pass'` or `'notApplicable'` (matching that rule's own no-candidates convention), with `occurrences: []` — never `outcome: 'fail'` with an empty `occurrences` array, since that exact shape is reserved elsewhere in the schema to mean "this rule threw" (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md)).
151
+
152
+ Accepts the same forms as the global option: an array (`['mat-select', 'mat-stepper']`) or a comma-separated string (`'mat-select, mat-stepper'`).
153
+
154
+ > If you're using a binding package (`@surea11y/binding-base` and its Playwright/Puppeteer wrappers), check that binding's own README for whether its `.exclude()` builder method has a rule-scoped form yet — this is an `engineOptions` shape documented here at the engine level; not every binding has picked it up.
155
+
128
156
  ## Recipes — composing options for real scenarios
129
157
 
130
158
  The reference above documents each option in isolation. These combine several at once, for scenarios you're likely to actually hit.
@@ -29,7 +29,7 @@ By design, this engine never enumerates the elements a rule *passed* — only th
29
29
  Two common causes, in order of likelihood:
30
30
 
31
31
  1. **The element is excluded from the accessibility tree** — `aria-hidden="true"`, `display: none`, `visibility: hidden`, `hidden`, or an `inert` ancestor. Most rules deliberately skip content that's already invisible to assistive technology (checking a hidden element would be meaningless, and could produce a misleading `fail` on content no user encounters). Some rules explicitly opt out of this gating when it wouldn't make sense to (e.g. `no-autoplay-audio` — hidden audio still plays sound) — check the specific rule's file header comment (`@applicability`) in `src/checks/`.
32
- 2. **`excludeSelectors`** — if you've configured this (directly or inherited from a shared config), confirm the element in question isn't matched by it.
32
+ 2. **`excludeSelectors`** — if you've configured this (directly or inherited from a shared config), confirm the element in question isn't matched by it. Remember this can also be scoped to a single rule via `engineOptions.rules[ruleId].excludeSelectors` (see [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#rule-scoped-excludeselectors)) — if a rule you expect to fire keeps coming back `notApplicable`/`pass` for one element only, check whether that rule specifically has its own exclude list configured, not just the global one.
33
33
 
34
34
  ## "Does a clean scan (`pass` everywhere) mean the page is WCAG conformant?"
35
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@surea11y/core",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Lightweight DOM rules accessibility core with modular rules.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -37,6 +37,7 @@
37
37
  ],
38
38
  "scripts": {
39
39
  "build": "node scripts/build-core.js",
40
+ "pretest": "playwright install chromium",
40
41
  "test": "npm run build && node scripts/run-tests.js",
41
42
  "test:contrast-helpers": "node tests/contrast-helpers.test.js",
42
43
  "helpers-perf-bench": "node --expose-gc scripts/dom-helpers-perf-bench.js",
@@ -117,14 +117,35 @@ function createDomHelpers(opts) {
117
117
  const includeShadowDom = !(opts && opts.includeShadowDom === false);
118
118
  const excludeSelectors = Array.isArray(opts && opts.excludeSelectors) ? opts.excludeSelectors : [];
119
119
 
120
+ // Rule-scoped excludes (engineOptions.rules[ruleId].excludeSelectors), set
121
+ // by dom-runner.js immediately before invoking each rule's applicability/
122
+ // run function via __setActiveRuleExcludeSelectors(). Safe as mutable
123
+ // closure state because rule execution is synchronous and single-rule-
124
+ // at-a-time: exactly one rule's excludes are ever "active" at once.
125
+ var __activeRuleExcludeSelectors = [];
126
+
127
+ function __getEffectiveExcludeSelectors() {
128
+ return __activeRuleExcludeSelectors.length
129
+ ? excludeSelectors.concat(__activeRuleExcludeSelectors)
130
+ : excludeSelectors;
131
+ }
132
+
133
+ function __setActiveRuleExcludeSelectors(list) {
134
+ __activeRuleExcludeSelectors = normalizeSelectorList(list);
135
+ }
136
+
120
137
  // Selector-related caches (selector uniqueness index, per-element built
121
- // selector strings) depend on includeShadowDom/excludeSelectors, since
122
- // those change which elements are considered when checking uniqueness.
123
- // The underlying storage is shared across createDomHelpers() calls on
124
- // the same window/document (see __domSharedCache below), so a run with
125
- // different options must not read/write another run's cached selectors.
126
- // This key partitions those caches per effective option set.
127
- const __selectorOptsKey = (includeShadowDom ? 'sd1' : 'sd0') + '|' + excludeSelectors.slice().sort().join(',');
138
+ // selector strings) depend on includeShadowDom/the effective exclude
139
+ // list, since those change which elements are considered when checking
140
+ // uniqueness. The underlying storage is shared across createDomHelpers()
141
+ // calls on the same window/document (see __domSharedCache below), so a
142
+ // run -- or a rule with its own rule-scoped excludes -- must not
143
+ // read/write another run/rule's cached selectors. This key partitions
144
+ // those caches per effective option set; recomputed per call (not a
145
+ // constant) since the effective list changes as the active rule changes.
146
+ function __getSelectorOptsKey() {
147
+ return (includeShadowDom ? 'sd1' : 'sd0') + '|' + __getEffectiveExcludeSelectors().slice().sort().join(',');
148
+ }
128
149
 
129
150
  // -------------------------------------------------------------------------
130
151
  // Per-run shared caches (DOM helpers)
@@ -886,9 +907,10 @@ function createDomHelpers(opts) {
886
907
 
887
908
 
888
909
  function isExcluded(el) {
889
- if (!excludeSelectors.length || !el || !el.closest) return false;
910
+ const eff = __getEffectiveExcludeSelectors();
911
+ if (!eff.length || !el || !el.closest) return false;
890
912
  try {
891
- return excludeSelectors.some((sel) => !!el.closest(sel));
913
+ return eff.some((sel) => !!el.closest(sel));
892
914
  } catch {
893
915
  return false;
894
916
  }
@@ -983,8 +1005,10 @@ function createDomHelpers(opts) {
983
1005
  if (!scope || !scope.querySelectorAll) return [];
984
1006
 
985
1007
  // Cache shadow root discovery per root to avoid repeated querySelectorAll('*') walks.
986
- // IMPORTANT: do not cache when excludeSelectors is non-empty (different helpers may differ).
987
- if (!excludeSelectors.length && __shadowRootsByRoot) {
1008
+ // IMPORTANT: do not cache when the effective exclude list (global
1009
+ // ∪ active rule-scoped excludes) is non-empty -- different rules
1010
+ // may have different effective lists and must not share results.
1011
+ if (!__getEffectiveExcludeSelectors().length && __shadowRootsByRoot) {
988
1012
  try {
989
1013
  const cached = __shadowRootsByRoot.get(scope);
990
1014
  if (cached) {
@@ -1059,7 +1083,7 @@ function createDomHelpers(opts) {
1059
1083
 
1060
1084
  function queryAllSmart(sel) {
1061
1085
  const list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
1062
- return excludeSelectors.length ? list.filter((el) => !isExcluded(el)) : list;
1086
+ return __getEffectiveExcludeSelectors().length ? list.filter((el) => !isExcluded(el)) : list;
1063
1087
  }
1064
1088
 
1065
1089
  // -------------------------------------------------------------------------
@@ -1093,10 +1117,11 @@ function createDomHelpers(opts) {
1093
1117
  function __getSelectorCacheForOpts() {
1094
1118
  if (!__selectorCache) return null;
1095
1119
  try {
1096
- let wm = __selectorCache.get(__selectorOptsKey);
1120
+ const key = __getSelectorOptsKey();
1121
+ let wm = __selectorCache.get(key);
1097
1122
  if (!(wm instanceof WeakMap)) {
1098
1123
  wm = new WeakMap();
1099
- __selectorCache.set(__selectorOptsKey, wm);
1124
+ __selectorCache.set(key, wm);
1100
1125
  }
1101
1126
  return wm;
1102
1127
  } catch {
@@ -3507,7 +3532,8 @@ function createDomHelpers(opts) {
3507
3532
  return createSelectorUniqIndex();
3508
3533
  }
3509
3534
 
3510
- const cached = perScope.get(__selectorOptsKey);
3535
+ const key = __getSelectorOptsKey();
3536
+ const cached = perScope.get(key);
3511
3537
  if (cached) {
3512
3538
  __perfInc('uniqIndex.hit');
3513
3539
  return cached;
@@ -3516,7 +3542,7 @@ function createDomHelpers(opts) {
3516
3542
  __perfInc('uniqIndex.miss');
3517
3543
  const idx = createSelectorUniqIndex();
3518
3544
  try {
3519
- perScope.set(__selectorOptsKey, idx);
3545
+ perScope.set(key, idx);
3520
3546
  } catch { /* ignore */
3521
3547
  }
3522
3548
  __perfInc('uniqIndex.build');
@@ -4026,6 +4052,12 @@ function createDomHelpers(opts) {
4026
4052
  isAccTreeEligible,
4027
4053
  isDomVisibleEligible,
4028
4054
 
4055
+ // Engine-internal: sets which rule's rule-scoped excludeSelectors
4056
+ // (engineOptions.rules[ruleId].excludeSelectors) are currently in
4057
+ // effect. Called by dom-runner.js before each rule invocation, not
4058
+ // intended for use by rule implementations.
4059
+ __setActiveRuleExcludeSelectors,
4060
+
4029
4061
  // Eligibility info wrapper
4030
4062
  getEligibilityInfo,
4031
4063
 
@@ -242,6 +242,15 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
242
242
  ? engineOptionsResolved.rules[defResolved.ruleId]
243
243
  : null;
244
244
 
245
+ // Rule-scoped excludeSelectors (engineOptions.rules[ruleId].excludeSelectors)
246
+ // apply on top of the global excludeSelectors for exactly this rule's
247
+ // applicability check + run, then are cleared once this rule is done.
248
+ // Safe because rule execution below is synchronous and one rule at a
249
+ // time -- sharedHelpers is reused across all rules in this loop.
250
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
251
+ sharedHelpers.__setActiveRuleExcludeSelectors(ruleConfig && ruleConfig.excludeSelectors);
252
+ }
253
+
245
254
  const ctx = {
246
255
  document,
247
256
  window,
@@ -312,6 +321,14 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
312
321
  if (ruleTimings) ruleTimings[defResolved.ruleId] = (ruleTimings[defResolved.ruleId] || 0) + (nowMs() - t0);
313
322
  }
314
323
 
324
+ // Composite rollups below carry no occurrences/nodes of their own, so
325
+ // they never exercise rule-scoped excludes -- but clear the "active
326
+ // rule" state on sharedHelpers regardless, so nothing after this point
327
+ // (composite aggregation, perf stats) can observe a stale rule's excludes.
328
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
329
+ sharedHelpers.__setActiveRuleExcludeSelectors(null);
330
+ }
331
+
315
332
  // =========================
316
333
  // Composite rule aggregation (data-only rollups)
317
334
  // =========================
package/src/core.js CHANGED
@@ -10969,14 +10969,35 @@ const createDomHelpers = (function createDomHelpers(opts) {
10969
10969
  const includeShadowDom = !(opts && opts.includeShadowDom === false);
10970
10970
  const excludeSelectors = Array.isArray(opts && opts.excludeSelectors) ? opts.excludeSelectors : [];
10971
10971
 
10972
+ // Rule-scoped excludes (engineOptions.rules[ruleId].excludeSelectors), set
10973
+ // by dom-runner.js immediately before invoking each rule's applicability/
10974
+ // run function via __setActiveRuleExcludeSelectors(). Safe as mutable
10975
+ // closure state because rule execution is synchronous and single-rule-
10976
+ // at-a-time: exactly one rule's excludes are ever "active" at once.
10977
+ var __activeRuleExcludeSelectors = [];
10978
+
10979
+ function __getEffectiveExcludeSelectors() {
10980
+ return __activeRuleExcludeSelectors.length
10981
+ ? excludeSelectors.concat(__activeRuleExcludeSelectors)
10982
+ : excludeSelectors;
10983
+ }
10984
+
10985
+ function __setActiveRuleExcludeSelectors(list) {
10986
+ __activeRuleExcludeSelectors = normalizeSelectorList(list);
10987
+ }
10988
+
10972
10989
  // Selector-related caches (selector uniqueness index, per-element built
10973
- // selector strings) depend on includeShadowDom/excludeSelectors, since
10974
- // those change which elements are considered when checking uniqueness.
10975
- // The underlying storage is shared across createDomHelpers() calls on
10976
- // the same window/document (see __domSharedCache below), so a run with
10977
- // different options must not read/write another run's cached selectors.
10978
- // This key partitions those caches per effective option set.
10979
- const __selectorOptsKey = (includeShadowDom ? 'sd1' : 'sd0') + '|' + excludeSelectors.slice().sort().join(',');
10990
+ // selector strings) depend on includeShadowDom/the effective exclude
10991
+ // list, since those change which elements are considered when checking
10992
+ // uniqueness. The underlying storage is shared across createDomHelpers()
10993
+ // calls on the same window/document (see __domSharedCache below), so a
10994
+ // run -- or a rule with its own rule-scoped excludes -- must not
10995
+ // read/write another run/rule's cached selectors. This key partitions
10996
+ // those caches per effective option set; recomputed per call (not a
10997
+ // constant) since the effective list changes as the active rule changes.
10998
+ function __getSelectorOptsKey() {
10999
+ return (includeShadowDom ? 'sd1' : 'sd0') + '|' + __getEffectiveExcludeSelectors().slice().sort().join(',');
11000
+ }
10980
11001
 
10981
11002
  // -------------------------------------------------------------------------
10982
11003
  // Per-run shared caches (DOM helpers)
@@ -11738,9 +11759,10 @@ const createDomHelpers = (function createDomHelpers(opts) {
11738
11759
 
11739
11760
 
11740
11761
  function isExcluded(el) {
11741
- if (!excludeSelectors.length || !el || !el.closest) return false;
11762
+ const eff = __getEffectiveExcludeSelectors();
11763
+ if (!eff.length || !el || !el.closest) return false;
11742
11764
  try {
11743
- return excludeSelectors.some((sel) => !!el.closest(sel));
11765
+ return eff.some((sel) => !!el.closest(sel));
11744
11766
  } catch {
11745
11767
  return false;
11746
11768
  }
@@ -11835,8 +11857,10 @@ const createDomHelpers = (function createDomHelpers(opts) {
11835
11857
  if (!scope || !scope.querySelectorAll) return [];
11836
11858
 
11837
11859
  // Cache shadow root discovery per root to avoid repeated querySelectorAll('*') walks.
11838
- // IMPORTANT: do not cache when excludeSelectors is non-empty (different helpers may differ).
11839
- if (!excludeSelectors.length && __shadowRootsByRoot) {
11860
+ // IMPORTANT: do not cache when the effective exclude list (global
11861
+ // ∪ active rule-scoped excludes) is non-empty -- different rules
11862
+ // may have different effective lists and must not share results.
11863
+ if (!__getEffectiveExcludeSelectors().length && __shadowRootsByRoot) {
11840
11864
  try {
11841
11865
  const cached = __shadowRootsByRoot.get(scope);
11842
11866
  if (cached) {
@@ -11911,7 +11935,7 @@ const createDomHelpers = (function createDomHelpers(opts) {
11911
11935
 
11912
11936
  function queryAllSmart(sel) {
11913
11937
  const list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
11914
- return excludeSelectors.length ? list.filter((el) => !isExcluded(el)) : list;
11938
+ return __getEffectiveExcludeSelectors().length ? list.filter((el) => !isExcluded(el)) : list;
11915
11939
  }
11916
11940
 
11917
11941
  // -------------------------------------------------------------------------
@@ -11945,10 +11969,11 @@ const createDomHelpers = (function createDomHelpers(opts) {
11945
11969
  function __getSelectorCacheForOpts() {
11946
11970
  if (!__selectorCache) return null;
11947
11971
  try {
11948
- let wm = __selectorCache.get(__selectorOptsKey);
11972
+ const key = __getSelectorOptsKey();
11973
+ let wm = __selectorCache.get(key);
11949
11974
  if (!(wm instanceof WeakMap)) {
11950
11975
  wm = new WeakMap();
11951
- __selectorCache.set(__selectorOptsKey, wm);
11976
+ __selectorCache.set(key, wm);
11952
11977
  }
11953
11978
  return wm;
11954
11979
  } catch {
@@ -14359,7 +14384,8 @@ const createDomHelpers = (function createDomHelpers(opts) {
14359
14384
  return createSelectorUniqIndex();
14360
14385
  }
14361
14386
 
14362
- const cached = perScope.get(__selectorOptsKey);
14387
+ const key = __getSelectorOptsKey();
14388
+ const cached = perScope.get(key);
14363
14389
  if (cached) {
14364
14390
  __perfInc('uniqIndex.hit');
14365
14391
  return cached;
@@ -14368,7 +14394,7 @@ const createDomHelpers = (function createDomHelpers(opts) {
14368
14394
  __perfInc('uniqIndex.miss');
14369
14395
  const idx = createSelectorUniqIndex();
14370
14396
  try {
14371
- perScope.set(__selectorOptsKey, idx);
14397
+ perScope.set(key, idx);
14372
14398
  } catch { /* ignore */
14373
14399
  }
14374
14400
  __perfInc('uniqIndex.build');
@@ -14878,6 +14904,12 @@ const createDomHelpers = (function createDomHelpers(opts) {
14878
14904
  isAccTreeEligible,
14879
14905
  isDomVisibleEligible,
14880
14906
 
14907
+ // Engine-internal: sets which rule's rule-scoped excludeSelectors
14908
+ // (engineOptions.rules[ruleId].excludeSelectors) are currently in
14909
+ // effect. Called by dom-runner.js before each rule invocation, not
14910
+ // intended for use by rule implementations.
14911
+ __setActiveRuleExcludeSelectors,
14912
+
14881
14913
  // Eligibility info wrapper
14882
14914
  getEligibilityInfo,
14883
14915
 
@@ -15291,6 +15323,15 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
15291
15323
  ? engineOptionsResolved.rules[defResolved.ruleId]
15292
15324
  : null;
15293
15325
 
15326
+ // Rule-scoped excludeSelectors (engineOptions.rules[ruleId].excludeSelectors)
15327
+ // apply on top of the global excludeSelectors for exactly this rule's
15328
+ // applicability check + run, then are cleared once this rule is done.
15329
+ // Safe because rule execution below is synchronous and one rule at a
15330
+ // time -- sharedHelpers is reused across all rules in this loop.
15331
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
15332
+ sharedHelpers.__setActiveRuleExcludeSelectors(ruleConfig && ruleConfig.excludeSelectors);
15333
+ }
15334
+
15294
15335
  const ctx = {
15295
15336
  document,
15296
15337
  window,
@@ -15361,6 +15402,14 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
15361
15402
  if (ruleTimings) ruleTimings[defResolved.ruleId] = (ruleTimings[defResolved.ruleId] || 0) + (nowMs() - t0);
15362
15403
  }
15363
15404
 
15405
+ // Composite rollups below carry no occurrences/nodes of their own, so
15406
+ // they never exercise rule-scoped excludes -- but clear the "active
15407
+ // rule" state on sharedHelpers regardless, so nothing after this point
15408
+ // (composite aggregation, perf stats) can observe a stale rule's excludes.
15409
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
15410
+ sharedHelpers.__setActiveRuleExcludeSelectors(null);
15411
+ }
15412
+
15364
15413
  // =========================
15365
15414
  // Composite rule aggregation (data-only rollups)
15366
15415
  // =========================
@@ -43009,14 +43058,35 @@ const createDomHelpers = (function createDomHelpers(opts) {
43009
43058
  const includeShadowDom = !(opts && opts.includeShadowDom === false);
43010
43059
  const excludeSelectors = Array.isArray(opts && opts.excludeSelectors) ? opts.excludeSelectors : [];
43011
43060
 
43061
+ // Rule-scoped excludes (engineOptions.rules[ruleId].excludeSelectors), set
43062
+ // by dom-runner.js immediately before invoking each rule's applicability/
43063
+ // run function via __setActiveRuleExcludeSelectors(). Safe as mutable
43064
+ // closure state because rule execution is synchronous and single-rule-
43065
+ // at-a-time: exactly one rule's excludes are ever "active" at once.
43066
+ var __activeRuleExcludeSelectors = [];
43067
+
43068
+ function __getEffectiveExcludeSelectors() {
43069
+ return __activeRuleExcludeSelectors.length
43070
+ ? excludeSelectors.concat(__activeRuleExcludeSelectors)
43071
+ : excludeSelectors;
43072
+ }
43073
+
43074
+ function __setActiveRuleExcludeSelectors(list) {
43075
+ __activeRuleExcludeSelectors = normalizeSelectorList(list);
43076
+ }
43077
+
43012
43078
  // Selector-related caches (selector uniqueness index, per-element built
43013
- // selector strings) depend on includeShadowDom/excludeSelectors, since
43014
- // those change which elements are considered when checking uniqueness.
43015
- // The underlying storage is shared across createDomHelpers() calls on
43016
- // the same window/document (see __domSharedCache below), so a run with
43017
- // different options must not read/write another run's cached selectors.
43018
- // This key partitions those caches per effective option set.
43019
- const __selectorOptsKey = (includeShadowDom ? 'sd1' : 'sd0') + '|' + excludeSelectors.slice().sort().join(',');
43079
+ // selector strings) depend on includeShadowDom/the effective exclude
43080
+ // list, since those change which elements are considered when checking
43081
+ // uniqueness. The underlying storage is shared across createDomHelpers()
43082
+ // calls on the same window/document (see __domSharedCache below), so a
43083
+ // run -- or a rule with its own rule-scoped excludes -- must not
43084
+ // read/write another run/rule's cached selectors. This key partitions
43085
+ // those caches per effective option set; recomputed per call (not a
43086
+ // constant) since the effective list changes as the active rule changes.
43087
+ function __getSelectorOptsKey() {
43088
+ return (includeShadowDom ? 'sd1' : 'sd0') + '|' + __getEffectiveExcludeSelectors().slice().sort().join(',');
43089
+ }
43020
43090
 
43021
43091
  // -------------------------------------------------------------------------
43022
43092
  // Per-run shared caches (DOM helpers)
@@ -43778,9 +43848,10 @@ const createDomHelpers = (function createDomHelpers(opts) {
43778
43848
 
43779
43849
 
43780
43850
  function isExcluded(el) {
43781
- if (!excludeSelectors.length || !el || !el.closest) return false;
43851
+ const eff = __getEffectiveExcludeSelectors();
43852
+ if (!eff.length || !el || !el.closest) return false;
43782
43853
  try {
43783
- return excludeSelectors.some((sel) => !!el.closest(sel));
43854
+ return eff.some((sel) => !!el.closest(sel));
43784
43855
  } catch {
43785
43856
  return false;
43786
43857
  }
@@ -43875,8 +43946,10 @@ const createDomHelpers = (function createDomHelpers(opts) {
43875
43946
  if (!scope || !scope.querySelectorAll) return [];
43876
43947
 
43877
43948
  // Cache shadow root discovery per root to avoid repeated querySelectorAll('*') walks.
43878
- // IMPORTANT: do not cache when excludeSelectors is non-empty (different helpers may differ).
43879
- if (!excludeSelectors.length && __shadowRootsByRoot) {
43949
+ // IMPORTANT: do not cache when the effective exclude list (global
43950
+ // ∪ active rule-scoped excludes) is non-empty -- different rules
43951
+ // may have different effective lists and must not share results.
43952
+ if (!__getEffectiveExcludeSelectors().length && __shadowRootsByRoot) {
43880
43953
  try {
43881
43954
  const cached = __shadowRootsByRoot.get(scope);
43882
43955
  if (cached) {
@@ -43951,7 +44024,7 @@ const createDomHelpers = (function createDomHelpers(opts) {
43951
44024
 
43952
44025
  function queryAllSmart(sel) {
43953
44026
  const list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
43954
- return excludeSelectors.length ? list.filter((el) => !isExcluded(el)) : list;
44027
+ return __getEffectiveExcludeSelectors().length ? list.filter((el) => !isExcluded(el)) : list;
43955
44028
  }
43956
44029
 
43957
44030
  // -------------------------------------------------------------------------
@@ -43985,10 +44058,11 @@ const createDomHelpers = (function createDomHelpers(opts) {
43985
44058
  function __getSelectorCacheForOpts() {
43986
44059
  if (!__selectorCache) return null;
43987
44060
  try {
43988
- let wm = __selectorCache.get(__selectorOptsKey);
44061
+ const key = __getSelectorOptsKey();
44062
+ let wm = __selectorCache.get(key);
43989
44063
  if (!(wm instanceof WeakMap)) {
43990
44064
  wm = new WeakMap();
43991
- __selectorCache.set(__selectorOptsKey, wm);
44065
+ __selectorCache.set(key, wm);
43992
44066
  }
43993
44067
  return wm;
43994
44068
  } catch {
@@ -46399,7 +46473,8 @@ const createDomHelpers = (function createDomHelpers(opts) {
46399
46473
  return createSelectorUniqIndex();
46400
46474
  }
46401
46475
 
46402
- const cached = perScope.get(__selectorOptsKey);
46476
+ const key = __getSelectorOptsKey();
46477
+ const cached = perScope.get(key);
46403
46478
  if (cached) {
46404
46479
  __perfInc('uniqIndex.hit');
46405
46480
  return cached;
@@ -46408,7 +46483,7 @@ const createDomHelpers = (function createDomHelpers(opts) {
46408
46483
  __perfInc('uniqIndex.miss');
46409
46484
  const idx = createSelectorUniqIndex();
46410
46485
  try {
46411
- perScope.set(__selectorOptsKey, idx);
46486
+ perScope.set(key, idx);
46412
46487
  } catch { /* ignore */
46413
46488
  }
46414
46489
  __perfInc('uniqIndex.build');
@@ -46918,6 +46993,12 @@ const createDomHelpers = (function createDomHelpers(opts) {
46918
46993
  isAccTreeEligible,
46919
46994
  isDomVisibleEligible,
46920
46995
 
46996
+ // Engine-internal: sets which rule's rule-scoped excludeSelectors
46997
+ // (engineOptions.rules[ruleId].excludeSelectors) are currently in
46998
+ // effect. Called by dom-runner.js before each rule invocation, not
46999
+ // intended for use by rule implementations.
47000
+ __setActiveRuleExcludeSelectors,
47001
+
46921
47002
  // Eligibility info wrapper
46922
47003
  getEligibilityInfo,
46923
47004
 
@@ -47331,6 +47412,15 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
47331
47412
  ? engineOptionsResolved.rules[defResolved.ruleId]
47332
47413
  : null;
47333
47414
 
47415
+ // Rule-scoped excludeSelectors (engineOptions.rules[ruleId].excludeSelectors)
47416
+ // apply on top of the global excludeSelectors for exactly this rule's
47417
+ // applicability check + run, then are cleared once this rule is done.
47418
+ // Safe because rule execution below is synchronous and one rule at a
47419
+ // time -- sharedHelpers is reused across all rules in this loop.
47420
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
47421
+ sharedHelpers.__setActiveRuleExcludeSelectors(ruleConfig && ruleConfig.excludeSelectors);
47422
+ }
47423
+
47334
47424
  const ctx = {
47335
47425
  document,
47336
47426
  window,
@@ -47401,6 +47491,14 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
47401
47491
  if (ruleTimings) ruleTimings[defResolved.ruleId] = (ruleTimings[defResolved.ruleId] || 0) + (nowMs() - t0);
47402
47492
  }
47403
47493
 
47494
+ // Composite rollups below carry no occurrences/nodes of their own, so
47495
+ // they never exercise rule-scoped excludes -- but clear the "active
47496
+ // rule" state on sharedHelpers regardless, so nothing after this point
47497
+ // (composite aggregation, perf stats) can observe a stale rule's excludes.
47498
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
47499
+ sharedHelpers.__setActiveRuleExcludeSelectors(null);
47500
+ }
47501
+
47404
47502
  // =========================
47405
47503
  // Composite rule aggregation (data-only rollups)
47406
47504
  // =========================
@@ -75004,14 +75102,35 @@ const createDomHelpers = (function createDomHelpers(opts) {
75004
75102
  const includeShadowDom = !(opts && opts.includeShadowDom === false);
75005
75103
  const excludeSelectors = Array.isArray(opts && opts.excludeSelectors) ? opts.excludeSelectors : [];
75006
75104
 
75105
+ // Rule-scoped excludes (engineOptions.rules[ruleId].excludeSelectors), set
75106
+ // by dom-runner.js immediately before invoking each rule's applicability/
75107
+ // run function via __setActiveRuleExcludeSelectors(). Safe as mutable
75108
+ // closure state because rule execution is synchronous and single-rule-
75109
+ // at-a-time: exactly one rule's excludes are ever "active" at once.
75110
+ var __activeRuleExcludeSelectors = [];
75111
+
75112
+ function __getEffectiveExcludeSelectors() {
75113
+ return __activeRuleExcludeSelectors.length
75114
+ ? excludeSelectors.concat(__activeRuleExcludeSelectors)
75115
+ : excludeSelectors;
75116
+ }
75117
+
75118
+ function __setActiveRuleExcludeSelectors(list) {
75119
+ __activeRuleExcludeSelectors = normalizeSelectorList(list);
75120
+ }
75121
+
75007
75122
  // Selector-related caches (selector uniqueness index, per-element built
75008
- // selector strings) depend on includeShadowDom/excludeSelectors, since
75009
- // those change which elements are considered when checking uniqueness.
75010
- // The underlying storage is shared across createDomHelpers() calls on
75011
- // the same window/document (see __domSharedCache below), so a run with
75012
- // different options must not read/write another run's cached selectors.
75013
- // This key partitions those caches per effective option set.
75014
- const __selectorOptsKey = (includeShadowDom ? 'sd1' : 'sd0') + '|' + excludeSelectors.slice().sort().join(',');
75123
+ // selector strings) depend on includeShadowDom/the effective exclude
75124
+ // list, since those change which elements are considered when checking
75125
+ // uniqueness. The underlying storage is shared across createDomHelpers()
75126
+ // calls on the same window/document (see __domSharedCache below), so a
75127
+ // run -- or a rule with its own rule-scoped excludes -- must not
75128
+ // read/write another run/rule's cached selectors. This key partitions
75129
+ // those caches per effective option set; recomputed per call (not a
75130
+ // constant) since the effective list changes as the active rule changes.
75131
+ function __getSelectorOptsKey() {
75132
+ return (includeShadowDom ? 'sd1' : 'sd0') + '|' + __getEffectiveExcludeSelectors().slice().sort().join(',');
75133
+ }
75015
75134
 
75016
75135
  // -------------------------------------------------------------------------
75017
75136
  // Per-run shared caches (DOM helpers)
@@ -75773,9 +75892,10 @@ const createDomHelpers = (function createDomHelpers(opts) {
75773
75892
 
75774
75893
 
75775
75894
  function isExcluded(el) {
75776
- if (!excludeSelectors.length || !el || !el.closest) return false;
75895
+ const eff = __getEffectiveExcludeSelectors();
75896
+ if (!eff.length || !el || !el.closest) return false;
75777
75897
  try {
75778
- return excludeSelectors.some((sel) => !!el.closest(sel));
75898
+ return eff.some((sel) => !!el.closest(sel));
75779
75899
  } catch {
75780
75900
  return false;
75781
75901
  }
@@ -75870,8 +75990,10 @@ const createDomHelpers = (function createDomHelpers(opts) {
75870
75990
  if (!scope || !scope.querySelectorAll) return [];
75871
75991
 
75872
75992
  // Cache shadow root discovery per root to avoid repeated querySelectorAll('*') walks.
75873
- // IMPORTANT: do not cache when excludeSelectors is non-empty (different helpers may differ).
75874
- if (!excludeSelectors.length && __shadowRootsByRoot) {
75993
+ // IMPORTANT: do not cache when the effective exclude list (global
75994
+ // ∪ active rule-scoped excludes) is non-empty -- different rules
75995
+ // may have different effective lists and must not share results.
75996
+ if (!__getEffectiveExcludeSelectors().length && __shadowRootsByRoot) {
75875
75997
  try {
75876
75998
  const cached = __shadowRootsByRoot.get(scope);
75877
75999
  if (cached) {
@@ -75946,7 +76068,7 @@ const createDomHelpers = (function createDomHelpers(opts) {
75946
76068
 
75947
76069
  function queryAllSmart(sel) {
75948
76070
  const list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
75949
- return excludeSelectors.length ? list.filter((el) => !isExcluded(el)) : list;
76071
+ return __getEffectiveExcludeSelectors().length ? list.filter((el) => !isExcluded(el)) : list;
75950
76072
  }
75951
76073
 
75952
76074
  // -------------------------------------------------------------------------
@@ -75980,10 +76102,11 @@ const createDomHelpers = (function createDomHelpers(opts) {
75980
76102
  function __getSelectorCacheForOpts() {
75981
76103
  if (!__selectorCache) return null;
75982
76104
  try {
75983
- let wm = __selectorCache.get(__selectorOptsKey);
76105
+ const key = __getSelectorOptsKey();
76106
+ let wm = __selectorCache.get(key);
75984
76107
  if (!(wm instanceof WeakMap)) {
75985
76108
  wm = new WeakMap();
75986
- __selectorCache.set(__selectorOptsKey, wm);
76109
+ __selectorCache.set(key, wm);
75987
76110
  }
75988
76111
  return wm;
75989
76112
  } catch {
@@ -78394,7 +78517,8 @@ const createDomHelpers = (function createDomHelpers(opts) {
78394
78517
  return createSelectorUniqIndex();
78395
78518
  }
78396
78519
 
78397
- const cached = perScope.get(__selectorOptsKey);
78520
+ const key = __getSelectorOptsKey();
78521
+ const cached = perScope.get(key);
78398
78522
  if (cached) {
78399
78523
  __perfInc('uniqIndex.hit');
78400
78524
  return cached;
@@ -78403,7 +78527,7 @@ const createDomHelpers = (function createDomHelpers(opts) {
78403
78527
  __perfInc('uniqIndex.miss');
78404
78528
  const idx = createSelectorUniqIndex();
78405
78529
  try {
78406
- perScope.set(__selectorOptsKey, idx);
78530
+ perScope.set(key, idx);
78407
78531
  } catch { /* ignore */
78408
78532
  }
78409
78533
  __perfInc('uniqIndex.build');
@@ -78913,6 +79037,12 @@ const createDomHelpers = (function createDomHelpers(opts) {
78913
79037
  isAccTreeEligible,
78914
79038
  isDomVisibleEligible,
78915
79039
 
79040
+ // Engine-internal: sets which rule's rule-scoped excludeSelectors
79041
+ // (engineOptions.rules[ruleId].excludeSelectors) are currently in
79042
+ // effect. Called by dom-runner.js before each rule invocation, not
79043
+ // intended for use by rule implementations.
79044
+ __setActiveRuleExcludeSelectors,
79045
+
78916
79046
  // Eligibility info wrapper
78917
79047
  getEligibilityInfo,
78918
79048
 
@@ -79326,6 +79456,15 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
79326
79456
  ? engineOptionsResolved.rules[defResolved.ruleId]
79327
79457
  : null;
79328
79458
 
79459
+ // Rule-scoped excludeSelectors (engineOptions.rules[ruleId].excludeSelectors)
79460
+ // apply on top of the global excludeSelectors for exactly this rule's
79461
+ // applicability check + run, then are cleared once this rule is done.
79462
+ // Safe because rule execution below is synchronous and one rule at a
79463
+ // time -- sharedHelpers is reused across all rules in this loop.
79464
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
79465
+ sharedHelpers.__setActiveRuleExcludeSelectors(ruleConfig && ruleConfig.excludeSelectors);
79466
+ }
79467
+
79329
79468
  const ctx = {
79330
79469
  document,
79331
79470
  window,
@@ -79396,6 +79535,14 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
79396
79535
  if (ruleTimings) ruleTimings[defResolved.ruleId] = (ruleTimings[defResolved.ruleId] || 0) + (nowMs() - t0);
79397
79536
  }
79398
79537
 
79538
+ // Composite rollups below carry no occurrences/nodes of their own, so
79539
+ // they never exercise rule-scoped excludes -- but clear the "active
79540
+ // rule" state on sharedHelpers regardless, so nothing after this point
79541
+ // (composite aggregation, perf stats) can observe a stale rule's excludes.
79542
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
79543
+ sharedHelpers.__setActiveRuleExcludeSelectors(null);
79544
+ }
79545
+
79399
79546
  // =========================
79400
79547
  // Composite rule aggregation (data-only rollups)
79401
79548
  // =========================