@surea11y/core 1.0.1 → 1.1.1

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,18 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.1.1] - 2026-07-29
8
+
9
+ ### Changed
10
+ - **`engineOptions.includeHiddenElements` (default `false`)**: helper-driven rules now skip elements hidden by `display:none` (on the element or any ancestor), `visibility:hidden`/`collapse`, the `[hidden]` attribute, closed `<details>`, and other structurally-non-rendered content by default, matching the visibility-aware behavior of other established engines. Filtering happens upstream in the shared `queryAllSmart` helper, before a rule's own pass/fail logic runs, so it's a candidate-list exclusion, not a post-hoc annotation. Set `engineOptions.includeHiddenElements: true` to restore the previous behavior and evaluate hidden/collapsed subtrees anyway (e.g. to catch a markup defect, like a broken ARIA ID reference, before a `<dialog>` ever opens). 10 rule files whose own logic intentionally doesn't call the underlying eligibility check directly (static-markup-validity rules such as `aria-valid-attr`, `aria-valid-attr-value`, `aria-allowed-attr`, `aria-allowed-role`, `aria-prohibited-attr`, `table-headers-attr-valid`, `table-th-has-data-cells`, `deprecated-elements-not-used`, `iframe-title-unique`, `aria-checked-state-mismatch-manual`) still inherit this filtering through `queryAllSmart`; their doc comments were updated to say so. See `docs/ENGINE_OPTIONS.md` and `docs/LIMITATIONS.md`.
11
+
12
+ ### Fixed
13
+ - `docs/LIMITATIONS.md`: the `<dialog>`/UA-stylesheet-hidden-content note was stale — it claimed static-markup-validity checks still evaluate hidden content, which this release's default change makes no longer true. Corrected to describe the current default and how to opt back in.
14
+
15
+ ## [1.1.0] - 2026-07-28
16
+
7
17
  ### Added
18
+ - `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
19
  - 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
20
 
10
21
  ### Fixed
@@ -25,7 +36,7 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
25
36
  - README: the JSON output example referenced a nonexistent rule id (`link-name-quality`); corrected to the real id, `link-name-quality-manual`.
26
37
  - README: Quick Start code samples labeled the runner's four positional arguments as `url, ruleFilter, options, policy`; corrected to the actual names (`pageUrl, contextSelector, engineOptions, runOnly`) used consistently elsewhere in the docs.
27
38
 
28
- ## [1.1.1] - 2026-07-24
39
+ ## [1.0.0] - 2026-07-26
29
40
 
30
41
  ### Added
31
42
  - 125 rules (77 automatic/`fail`-capable, 48 manual/advisory) — see `docs/RULE_CATALOG.md` for the full list.
@@ -58,7 +69,7 @@ See `docs/LIMITATIONS.md` — structural (keyboard-trap detection, reflow-at-zoo
58
69
 
59
70
  ---
60
71
 
61
- ## How to add an entry
72
+ # How to add an entry
62
73
 
63
74
  When you ship a change worth calling out to consumers (not every commit):
64
75
  1. Add a bullet under `[Unreleased]`, in the right subsection (`Added`, `Changed`, `Fixed`, `Deprecated`, `Removed`, `Security`) — create the subsection if it doesn't exist yet for this cycle.
@@ -72,6 +72,7 @@ runDomRulesInPage(url, null, {
72
72
  ```js
73
73
  const engineOptions = {
74
74
  locale: 'en', // default 'en'; falls back to 'en' per-string if a key is missing in the requested locale
75
+ includeHiddenElements: false, // default false — set true to evaluate hidden/collapsed subtrees too
75
76
  includeShadowDom: true, // default true — opt OUT with `false` to skip open shadow roots
76
77
  excludeSelectors: ['#cookie-banner', '.third-party-widget'], // array or comma-separated string
77
78
  timestamp: '2026-07-20T12:00:00Z', // optional — engine has no built-in clock, see OUTPUT_SCHEMA.md
@@ -95,7 +96,9 @@ const engineOptions = {
95
96
  },
96
97
 
97
98
  rules: {
98
- 'some-rule-id': { /* per-rule config, currently unused — see note */ }
99
+ 'some-rule-id': {
100
+ excludeSelectors: ['.some-noisy-widget'] // narrows candidates for THIS rule only — see note
101
+ }
99
102
  },
100
103
 
101
104
  probes: { /* optional host-supplied evidence, see note */ },
@@ -112,19 +115,46 @@ const engineOptions = {
112
115
  | Option | Meaning |
113
116
  |---|---|
114
117
  | `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. |
118
+ | `includeHiddenElements` | Default `false`: helper queries exclude elements hidden by structural/CSS mechanisms such as `display:none`, `[hidden]`, closed `<details>`, and hidden rendering-only host elements (with descendants excluded too). Set `true` to include those hidden/collapsed subtrees in evaluation (legacy/static-markup behavior). |
115
119
  | `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. |
120
+ | `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
121
  | `timestamp` | Passed straight through to the result's top-level `timestamp` field; the engine does not generate one itself (deterministic-by-design). |
118
122
  | `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
123
  | `contrast.rootCanvasFallback` | The assumed page background color when it's not computable at all — only matters in `auditorAssist` mode. |
120
124
  | `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
125
  | `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
126
  | `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. |
127
+ | `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
128
  | `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
129
  | `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
130
  | `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
131
 
132
+ ### Rule-scoped `excludeSelectors`
133
+
134
+ 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.
135
+
136
+ ```js
137
+ const engineOptions = {
138
+ excludeSelectors: ['#cookie-banner'], // applies to every rule, as always
139
+ rules: {
140
+ 'aria-required-children': {
141
+ excludeSelectors: ['mat-select', 'mat-stepper', 'mat-horizontal-stepper', 'mat-vertical-stepper']
142
+ },
143
+ 'aria-allowed-attr': {
144
+ excludeSelectors: ['mat-progress-spinner']
145
+ }
146
+ }
147
+ };
148
+ ```
149
+
150
+ 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.
151
+
152
+ 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)).
153
+
154
+ Accepts the same forms as the global option: an array (`['mat-select', 'mat-stepper']`) or a comma-separated string (`'mat-select, mat-stepper'`).
155
+
156
+ > 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.
157
+
128
158
  ## Recipes — composing options for real scenarios
129
159
 
130
160
  The reference above documents each option in isolation. These combine several at once, for scenarios you're likely to actually hit.
@@ -13,7 +13,7 @@ surea11y is a **static DOM scan**: it reads the DOM tree and computed styles at
13
13
  ## Environment-dependent — depends on how you run it
14
14
 
15
15
  - **jsdom (Node, no real browser) has no CSS layout engine.** Rules needing real geometry — most notably `target-size-minimum` (WCAG 2.5.8, needs real `getBoundingClientRect()`) — report `notApplicable` under plain jsdom rather than guess. Run under a real browser (Puppeteer/Playwright — see [`INTEGRATION.md`](./INTEGRATION.md) Pattern 2) to get real findings from these rules.
16
- - **`<dialog>` and other elements hidden by the default UA stylesheet** (no `open` attribute, `display: none` by spec) are skipped by most tools' visibility-aware checks, including other established engines — but surea11y's static-markup-validity checks (like ARIA ID-reference validity) still evaluate them, since a markup defect is still a defect even before the dialog opens. This is a deliberate surea11y choice that can occasionally make it *more* thorough than other engines on hidden content, not a bug — found and confirmed during internal cross-engine verification.
16
+ - **`<dialog>` and other elements hidden by the default UA stylesheet** (no `open` attribute, `display: none` by spec), along with any other subtree hidden via `display:none`, `visibility:hidden`, `[hidden]`, or closed `<details>`, are **excluded from rule evaluation by default** — matching the visibility-aware behavior of other established engines. This is a deliberate default (`engineOptions.includeHiddenElements: false`), not an oversight: hidden content isn't reachable by assistive technology or keyboard until it's shown, so flagging a markup defect inside it by default would often be noise. Set `engineOptions.includeHiddenElements: true` to evaluate hidden/collapsed subtrees anyway — e.g. to catch a markup defect (like a broken ARIA ID reference) before a dialog ever opens. See [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#engineoptions--the-rest) for the option and exactly which hiding mechanisms it covers.
17
17
  - **Static markup vs. live/post-hydration DOM state.** The rule logic itself is DOM-source-agnostic — it evaluates whatever DOM it's handed, whether that's jsdom-parsed static HTML (Pattern 1) or an already-loaded, already-hydrated real browser tab (Pattern 2, see [`INTEGRATION.md`](./INTEGRATION.md)). But the CLI (`npx @surea11y/core scan <url>`) specifically fetches static HTML only, with no JS execution — see [`CLI.md`](./CLI.md). For a JS-framework-hydrated widget whose server-rendered markup intentionally ships one state before client JS syncs it (e.g. `<input type="checkbox" aria-checked="true">` shipped before client JS sets the native `checked` property to match on hydration — an extremely common, entirely legitimate pattern), a CLI scan only sees the pre-hydration markup. Other engines running inside an actual loaded browser tab see the post-hydration state instead, so the two can disagree on exactly this class of element for reasons that have nothing to do with either engine's rule correctness. `aria-checked-state-mismatch` is deliberately `manual`/`cantTell`-capped for this exact reason rather than a hard `fail`. If you need live-DOM accuracy for hydration-sensitive checks, run the library directly against an already-loaded page via Pattern 2, not the static-fetch CLI.
18
18
 
19
19
  ## Deliberately not attempted — judgment calls, not automatable safely
@@ -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.1",
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",
@@ -70,7 +70,9 @@
70
70
  * menuitemcheckbox, menuitemradio. `aria-level` added to: tablist.
71
71
  * - `tree`'s `aria-readonly` removed: not in aria-query's resolved
72
72
  * props for `tree` (was an unverified carryover, not spec-backed).
73
- * - Not gated on isAccTreeEligible: this is a static markup property.
73
+ * - Not rule-gated on isAccTreeEligible: this remains a static-markup
74
+ * property, while engine-level hidden-subtree filtering still applies
75
+ * unless engineOptions.includeHiddenElements is true.
74
76
  */
75
77
 
76
78
  const id = 'aria-allowed-attr';
@@ -18,7 +18,9 @@
18
18
  * - Deliberately scoped to elements present in ALLOWED_ROLES_BY_ELEMENT;
19
19
  * elements without an asserted constraint are treated as "no constraint"
20
20
  * (not flagged) rather than guessed at — see that table's header comment.
21
- * - Not gated on isAccTreeEligible: this is a static markup property.
21
+ * - Not rule-gated on isAccTreeEligible: this remains a static-markup
22
+ * property, while engine-level hidden-subtree filtering still applies
23
+ * unless engineOptions.includeHiddenElements is true.
22
24
  */
23
25
 
24
26
  const id = 'aria-allowed-role';
@@ -55,7 +55,9 @@
55
55
  * `definition_role` page even demonstrates `aria-labelledby` usage on
56
56
  * it directly. A real, confirmed documentation bug on MDN's side, not
57
57
  * a gap here.
58
- * - Not gated on isAccTreeEligible: this is a static markup property.
58
+ * - Not rule-gated on isAccTreeEligible: this remains a static-markup
59
+ * property, while engine-level hidden-subtree filtering still applies
60
+ * unless engineOptions.includeHiddenElements is true.
59
61
  */
60
62
 
61
63
  const id = 'aria-prohibited-attr';
@@ -17,7 +17,9 @@
17
17
  * ID reference (list) that resolves to an existing element in the
18
18
  * document.
19
19
  * @implementation-notes
20
- * - Not gated on isAccTreeEligible: this is a static markup property.
20
+ * - Not rule-gated on isAccTreeEligible: this remains a static-markup
21
+ * property, while engine-level hidden-subtree filtering still applies
22
+ * unless engineOptions.includeHiddenElements is true.
21
23
  * - ID-reference resolution (added 2026-07-20, see aria-helpers.js's
22
24
  * idExists) only flags idref-list attributes (aria-labelledby,
23
25
  * aria-describedby, aria-controls, aria-owns, etc.) when NONE of the
@@ -18,7 +18,9 @@
18
18
  * - Distinct from aria-valid-attr-value (which validates the VALUE
19
19
  * of a recognized attribute) — this rule only validates the attribute
20
20
  * NAME.
21
- * - Not gated on isAccTreeEligible: this is a static markup property.
21
+ * - Not rule-gated on isAccTreeEligible: this remains a static-markup
22
+ * property, while engine-level hidden-subtree filtering still applies
23
+ * unless engineOptions.includeHiddenElements is true.
22
24
  */
23
25
 
24
26
  const id = 'aria-valid-attr';
@@ -17,10 +17,12 @@
17
17
  * rule has no partial-pass case, matching a widely-used reference engine's `blink`/`marquee`
18
18
  * rules (which report only when the element is found).
19
19
  * @implementation-notes
20
- * - Not gated on isAccTreeEligible: presence in markup is itself the
20
+ * - Not rule-gated on isAccTreeEligible: presence in markup is itself the
21
21
  * violation, independent of visibility (moving/blinking content inside a
22
22
  * hidden ancestor could still become visible later without a code
23
23
  * change, so hiding it today does not remove the underlying defect).
24
+ * Engine-level hidden-subtree filtering still applies unless
25
+ * engineOptions.includeHiddenElements is true.
24
26
  */
25
27
 
26
28
  const id = 'deprecated-elements-not-used';
@@ -19,8 +19,9 @@
19
19
  * - Compares the title ATTRIBUTE specifically, not the full computed
20
20
  * accessible name (aria-label could legitimately differ in wording even
21
21
  * when title happens to collide) — matches a widely-used reference engine's frame-title-unique.
22
- * - Not gated on isAccTreeEligible: a duplicate title is a static markup
23
- * property independent of the frame's current visibility.
22
+ * - Not rule-gated on isAccTreeEligible: duplicate titles are a static
23
+ * markup property. Engine-level hidden-subtree filtering still applies
24
+ * unless engineOptions.includeHiddenElements is true.
24
25
  */
25
26
 
26
27
  const id = 'iframe-title-unique';
@@ -16,7 +16,9 @@
16
16
  * - One occurrence per offending cell (not per bad token), listing every
17
17
  * invalid reference — matches a widely-used reference engine's
18
18
  * td-headers-attr reporting granularity.
19
- * - Not gated on isAccTreeEligible: this is a static markup property.
19
+ * - Not rule-gated on isAccTreeEligible: this remains a static-markup
20
+ * property, while engine-level hidden-subtree filtering still applies
21
+ * unless engineOptions.includeHiddenElements is true.
20
22
  */
21
23
 
22
24
  const id = 'table-headers-attr-valid';
@@ -28,7 +28,9 @@
28
28
  * if some particular <th> in it doesn't actually describe any cell
29
29
  * (false negative, not a false positive — acceptable under this
30
30
  * engine's philosophy).
31
- * - Not gated on isAccTreeEligible: this is a static markup property.
31
+ * - Not rule-gated on isAccTreeEligible: this remains a static-markup
32
+ * property, while engine-level hidden-subtree filtering still applies
33
+ * unless engineOptions.includeHiddenElements is true.
32
34
  */
33
35
 
34
36
  const id = 'table-th-has-data-cells';
@@ -42,8 +42,10 @@
42
42
  * - Any `aria-checked` value other than "true"/"false"/"mixed" (checkbox)
43
43
  * or "true"/"false" (radio) is treated as equivalent to "false" — also
44
44
  * matches that reference engine's own normalization exactly.
45
- * - Not gated on isAccTreeEligible: whether the accessible state matches
46
- * is a markup-correctness property independent of current visibility.
45
+ * - Not rule-gated on isAccTreeEligible: whether the accessible state
46
+ * matches is still a markup-correctness property, while engine-level
47
+ * hidden-subtree filtering applies unless engineOptions.includeHiddenElements
48
+ * is true.
47
49
  */
48
50
 
49
51
  const id = 'aria-checked-state-mismatch';
@@ -115,16 +115,41 @@ function createDomHelpers(opts) {
115
115
  })();
116
116
  // Default on: opt OUT with `includeShadowDom: false`, not opt in.
117
117
  const includeShadowDom = !(opts && opts.includeShadowDom === false);
118
+ // Default off: by default, helper queries skip structurally/CSS-hidden
119
+ // subtrees (display:none, [hidden], closed <details>, etc.). Callers can
120
+ // opt out with includeHiddenElements:true.
121
+ const includeHiddenElements = !!(opts && opts.includeHiddenElements === true);
118
122
  const excludeSelectors = Array.isArray(opts && opts.excludeSelectors) ? opts.excludeSelectors : [];
119
123
 
124
+ // Rule-scoped excludes (engineOptions.rules[ruleId].excludeSelectors), set
125
+ // by dom-runner.js immediately before invoking each rule's applicability/
126
+ // run function via __setActiveRuleExcludeSelectors(). Safe as mutable
127
+ // closure state because rule execution is synchronous and single-rule-
128
+ // at-a-time: exactly one rule's excludes are ever "active" at once.
129
+ var __activeRuleExcludeSelectors = [];
130
+
131
+ function __getEffectiveExcludeSelectors() {
132
+ return __activeRuleExcludeSelectors.length
133
+ ? excludeSelectors.concat(__activeRuleExcludeSelectors)
134
+ : excludeSelectors;
135
+ }
136
+
137
+ function __setActiveRuleExcludeSelectors(list) {
138
+ __activeRuleExcludeSelectors = normalizeSelectorList(list);
139
+ }
140
+
120
141
  // 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(',');
142
+ // selector strings) depend on includeShadowDom/the effective exclude
143
+ // list, since those change which elements are considered when checking
144
+ // uniqueness. The underlying storage is shared across createDomHelpers()
145
+ // calls on the same window/document (see __domSharedCache below), so a
146
+ // run -- or a rule with its own rule-scoped excludes -- must not
147
+ // read/write another run/rule's cached selectors. This key partitions
148
+ // those caches per effective option set; recomputed per call (not a
149
+ // constant) since the effective list changes as the active rule changes.
150
+ function __getSelectorOptsKey() {
151
+ return (includeShadowDom ? 'sd1' : 'sd0') + '|' + __getEffectiveExcludeSelectors().slice().sort().join(',');
152
+ }
128
153
 
129
154
  // -------------------------------------------------------------------------
130
155
  // Per-run shared caches (DOM helpers)
@@ -886,9 +911,10 @@ function createDomHelpers(opts) {
886
911
 
887
912
 
888
913
  function isExcluded(el) {
889
- if (!excludeSelectors.length || !el || !el.closest) return false;
914
+ const eff = __getEffectiveExcludeSelectors();
915
+ if (!eff.length || !el || !el.closest) return false;
890
916
  try {
891
- return excludeSelectors.some((sel) => !!el.closest(sel));
917
+ return eff.some((sel) => !!el.closest(sel));
892
918
  } catch {
893
919
  return false;
894
920
  }
@@ -983,8 +1009,10 @@ function createDomHelpers(opts) {
983
1009
  if (!scope || !scope.querySelectorAll) return [];
984
1010
 
985
1011
  // 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) {
1012
+ // IMPORTANT: do not cache when the effective exclude list (global
1013
+ // ∪ active rule-scoped excludes) is non-empty -- different rules
1014
+ // may have different effective lists and must not share results.
1015
+ if (!__getEffectiveExcludeSelectors().length && __shadowRootsByRoot) {
988
1016
  try {
989
1017
  const cached = __shadowRootsByRoot.get(scope);
990
1018
  if (cached) {
@@ -1057,9 +1085,38 @@ function createDomHelpers(opts) {
1057
1085
  return results;
1058
1086
  }
1059
1087
 
1088
+ const HARD_HIDDEN_REASONS = new Set([
1089
+ 'displayNone',
1090
+ 'hiddenAttr',
1091
+ 'detailsClosed',
1092
+ 'templateContent',
1093
+ 'nonRenderedElement',
1094
+ 'inputHidden',
1095
+ 'visibilityHidden'
1096
+ ]);
1097
+
1060
1098
  function queryAllSmart(sel) {
1061
- const list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
1062
- return excludeSelectors.length ? list.filter((el) => !isExcluded(el)) : list;
1099
+ let list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
1100
+
1101
+ // Global hidden-content policy: skip nodes that are fully excluded from
1102
+ // rendered visibility by default (unless includeHiddenElements:true).
1103
+ if (!includeHiddenElements) {
1104
+ list = list.filter((el) => {
1105
+ try {
1106
+ const vis = isAccTreeEligible(el);
1107
+ if (!vis || vis.eligible !== false) return true;
1108
+ const reasons = Array.isArray(vis.reasons) ? vis.reasons : [];
1109
+ for (const r of reasons) {
1110
+ if (HARD_HIDDEN_REASONS.has(r)) return false;
1111
+ }
1112
+ return true;
1113
+ } catch {
1114
+ return true;
1115
+ }
1116
+ });
1117
+ }
1118
+
1119
+ return __getEffectiveExcludeSelectors().length ? list.filter((el) => !isExcluded(el)) : list;
1063
1120
  }
1064
1121
 
1065
1122
  // -------------------------------------------------------------------------
@@ -1093,10 +1150,11 @@ function createDomHelpers(opts) {
1093
1150
  function __getSelectorCacheForOpts() {
1094
1151
  if (!__selectorCache) return null;
1095
1152
  try {
1096
- let wm = __selectorCache.get(__selectorOptsKey);
1153
+ const key = __getSelectorOptsKey();
1154
+ let wm = __selectorCache.get(key);
1097
1155
  if (!(wm instanceof WeakMap)) {
1098
1156
  wm = new WeakMap();
1099
- __selectorCache.set(__selectorOptsKey, wm);
1157
+ __selectorCache.set(key, wm);
1100
1158
  }
1101
1159
  return wm;
1102
1160
  } catch {
@@ -3507,7 +3565,8 @@ function createDomHelpers(opts) {
3507
3565
  return createSelectorUniqIndex();
3508
3566
  }
3509
3567
 
3510
- const cached = perScope.get(__selectorOptsKey);
3568
+ const key = __getSelectorOptsKey();
3569
+ const cached = perScope.get(key);
3511
3570
  if (cached) {
3512
3571
  __perfInc('uniqIndex.hit');
3513
3572
  return cached;
@@ -3516,7 +3575,7 @@ function createDomHelpers(opts) {
3516
3575
  __perfInc('uniqIndex.miss');
3517
3576
  const idx = createSelectorUniqIndex();
3518
3577
  try {
3519
- perScope.set(__selectorOptsKey, idx);
3578
+ perScope.set(key, idx);
3520
3579
  } catch { /* ignore */
3521
3580
  }
3522
3581
  __perfInc('uniqIndex.build');
@@ -4026,6 +4085,12 @@ function createDomHelpers(opts) {
4026
4085
  isAccTreeEligible,
4027
4086
  isDomVisibleEligible,
4028
4087
 
4088
+ // Engine-internal: sets which rule's rule-scoped excludeSelectors
4089
+ // (engineOptions.rules[ruleId].excludeSelectors) are currently in
4090
+ // effect. Called by dom-runner.js before each rule invocation, not
4091
+ // intended for use by rule implementations.
4092
+ __setActiveRuleExcludeSelectors,
4093
+
4029
4094
  // Eligibility info wrapper
4030
4095
  getEligibilityInfo,
4031
4096
 
@@ -48,6 +48,9 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
48
48
 
49
49
  // Default on: opt OUT with `includeShadowDom: false`, not opt in.
50
50
  const includeShadowDom = !(engineOptionsResolved && engineOptionsResolved.includeShadowDom === false);
51
+ // Default off: hidden/collapsed content is excluded from rule evaluation
52
+ // unless the caller explicitly opts in.
53
+ const includeHiddenElements = !!(engineOptionsResolved && engineOptionsResolved.includeHiddenElements === true);
51
54
  const excludeSelectors = normalizeSelectorList(engineOptionsResolved && engineOptionsResolved.excludeSelectors);
52
55
 
53
56
  const url = pageUrl || (document.location && document.location.href) || null;
@@ -63,6 +66,7 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
63
66
  window,
64
67
  root: roots,
65
68
  includeShadowDom,
69
+ includeHiddenElements,
66
70
  excludeSelectors,
67
71
  // Optional perf counters (bench/debug only). Deterministic and per-run.
68
72
  perfStats: !!(engineOptionsResolved && engineOptionsResolved.perfStats)
@@ -242,6 +246,15 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
242
246
  ? engineOptionsResolved.rules[defResolved.ruleId]
243
247
  : null;
244
248
 
249
+ // Rule-scoped excludeSelectors (engineOptions.rules[ruleId].excludeSelectors)
250
+ // apply on top of the global excludeSelectors for exactly this rule's
251
+ // applicability check + run, then are cleared once this rule is done.
252
+ // Safe because rule execution below is synchronous and one rule at a
253
+ // time -- sharedHelpers is reused across all rules in this loop.
254
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
255
+ sharedHelpers.__setActiveRuleExcludeSelectors(ruleConfig && ruleConfig.excludeSelectors);
256
+ }
257
+
245
258
  const ctx = {
246
259
  document,
247
260
  window,
@@ -312,6 +325,14 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
312
325
  if (ruleTimings) ruleTimings[defResolved.ruleId] = (ruleTimings[defResolved.ruleId] || 0) + (nowMs() - t0);
313
326
  }
314
327
 
328
+ // Composite rollups below carry no occurrences/nodes of their own, so
329
+ // they never exercise rule-scoped excludes -- but clear the "active
330
+ // rule" state on sharedHelpers regardless, so nothing after this point
331
+ // (composite aggregation, perf stats) can observe a stale rule's excludes.
332
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
333
+ sharedHelpers.__setActiveRuleExcludeSelectors(null);
334
+ }
335
+
315
336
  // =========================
316
337
  // Composite rule aggregation (data-only rollups)
317
338
  // =========================
package/src/core.js CHANGED
@@ -10967,16 +10967,41 @@ const createDomHelpers = (function createDomHelpers(opts) {
10967
10967
  })();
10968
10968
  // Default on: opt OUT with `includeShadowDom: false`, not opt in.
10969
10969
  const includeShadowDom = !(opts && opts.includeShadowDom === false);
10970
+ // Default off: by default, helper queries skip structurally/CSS-hidden
10971
+ // subtrees (display:none, [hidden], closed <details>, etc.). Callers can
10972
+ // opt out with includeHiddenElements:true.
10973
+ const includeHiddenElements = !!(opts && opts.includeHiddenElements === true);
10970
10974
  const excludeSelectors = Array.isArray(opts && opts.excludeSelectors) ? opts.excludeSelectors : [];
10971
10975
 
10976
+ // Rule-scoped excludes (engineOptions.rules[ruleId].excludeSelectors), set
10977
+ // by dom-runner.js immediately before invoking each rule's applicability/
10978
+ // run function via __setActiveRuleExcludeSelectors(). Safe as mutable
10979
+ // closure state because rule execution is synchronous and single-rule-
10980
+ // at-a-time: exactly one rule's excludes are ever "active" at once.
10981
+ var __activeRuleExcludeSelectors = [];
10982
+
10983
+ function __getEffectiveExcludeSelectors() {
10984
+ return __activeRuleExcludeSelectors.length
10985
+ ? excludeSelectors.concat(__activeRuleExcludeSelectors)
10986
+ : excludeSelectors;
10987
+ }
10988
+
10989
+ function __setActiveRuleExcludeSelectors(list) {
10990
+ __activeRuleExcludeSelectors = normalizeSelectorList(list);
10991
+ }
10992
+
10972
10993
  // 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(',');
10994
+ // selector strings) depend on includeShadowDom/the effective exclude
10995
+ // list, since those change which elements are considered when checking
10996
+ // uniqueness. The underlying storage is shared across createDomHelpers()
10997
+ // calls on the same window/document (see __domSharedCache below), so a
10998
+ // run -- or a rule with its own rule-scoped excludes -- must not
10999
+ // read/write another run/rule's cached selectors. This key partitions
11000
+ // those caches per effective option set; recomputed per call (not a
11001
+ // constant) since the effective list changes as the active rule changes.
11002
+ function __getSelectorOptsKey() {
11003
+ return (includeShadowDom ? 'sd1' : 'sd0') + '|' + __getEffectiveExcludeSelectors().slice().sort().join(',');
11004
+ }
10980
11005
 
10981
11006
  // -------------------------------------------------------------------------
10982
11007
  // Per-run shared caches (DOM helpers)
@@ -11738,9 +11763,10 @@ const createDomHelpers = (function createDomHelpers(opts) {
11738
11763
 
11739
11764
 
11740
11765
  function isExcluded(el) {
11741
- if (!excludeSelectors.length || !el || !el.closest) return false;
11766
+ const eff = __getEffectiveExcludeSelectors();
11767
+ if (!eff.length || !el || !el.closest) return false;
11742
11768
  try {
11743
- return excludeSelectors.some((sel) => !!el.closest(sel));
11769
+ return eff.some((sel) => !!el.closest(sel));
11744
11770
  } catch {
11745
11771
  return false;
11746
11772
  }
@@ -11835,8 +11861,10 @@ const createDomHelpers = (function createDomHelpers(opts) {
11835
11861
  if (!scope || !scope.querySelectorAll) return [];
11836
11862
 
11837
11863
  // 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) {
11864
+ // IMPORTANT: do not cache when the effective exclude list (global
11865
+ // ∪ active rule-scoped excludes) is non-empty -- different rules
11866
+ // may have different effective lists and must not share results.
11867
+ if (!__getEffectiveExcludeSelectors().length && __shadowRootsByRoot) {
11840
11868
  try {
11841
11869
  const cached = __shadowRootsByRoot.get(scope);
11842
11870
  if (cached) {
@@ -11909,9 +11937,38 @@ const createDomHelpers = (function createDomHelpers(opts) {
11909
11937
  return results;
11910
11938
  }
11911
11939
 
11940
+ const HARD_HIDDEN_REASONS = new Set([
11941
+ 'displayNone',
11942
+ 'hiddenAttr',
11943
+ 'detailsClosed',
11944
+ 'templateContent',
11945
+ 'nonRenderedElement',
11946
+ 'inputHidden',
11947
+ 'visibilityHidden'
11948
+ ]);
11949
+
11912
11950
  function queryAllSmart(sel) {
11913
- const list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
11914
- return excludeSelectors.length ? list.filter((el) => !isExcluded(el)) : list;
11951
+ let list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
11952
+
11953
+ // Global hidden-content policy: skip nodes that are fully excluded from
11954
+ // rendered visibility by default (unless includeHiddenElements:true).
11955
+ if (!includeHiddenElements) {
11956
+ list = list.filter((el) => {
11957
+ try {
11958
+ const vis = isAccTreeEligible(el);
11959
+ if (!vis || vis.eligible !== false) return true;
11960
+ const reasons = Array.isArray(vis.reasons) ? vis.reasons : [];
11961
+ for (const r of reasons) {
11962
+ if (HARD_HIDDEN_REASONS.has(r)) return false;
11963
+ }
11964
+ return true;
11965
+ } catch {
11966
+ return true;
11967
+ }
11968
+ });
11969
+ }
11970
+
11971
+ return __getEffectiveExcludeSelectors().length ? list.filter((el) => !isExcluded(el)) : list;
11915
11972
  }
11916
11973
 
11917
11974
  // -------------------------------------------------------------------------
@@ -11945,10 +12002,11 @@ const createDomHelpers = (function createDomHelpers(opts) {
11945
12002
  function __getSelectorCacheForOpts() {
11946
12003
  if (!__selectorCache) return null;
11947
12004
  try {
11948
- let wm = __selectorCache.get(__selectorOptsKey);
12005
+ const key = __getSelectorOptsKey();
12006
+ let wm = __selectorCache.get(key);
11949
12007
  if (!(wm instanceof WeakMap)) {
11950
12008
  wm = new WeakMap();
11951
- __selectorCache.set(__selectorOptsKey, wm);
12009
+ __selectorCache.set(key, wm);
11952
12010
  }
11953
12011
  return wm;
11954
12012
  } catch {
@@ -14359,7 +14417,8 @@ const createDomHelpers = (function createDomHelpers(opts) {
14359
14417
  return createSelectorUniqIndex();
14360
14418
  }
14361
14419
 
14362
- const cached = perScope.get(__selectorOptsKey);
14420
+ const key = __getSelectorOptsKey();
14421
+ const cached = perScope.get(key);
14363
14422
  if (cached) {
14364
14423
  __perfInc('uniqIndex.hit');
14365
14424
  return cached;
@@ -14368,7 +14427,7 @@ const createDomHelpers = (function createDomHelpers(opts) {
14368
14427
  __perfInc('uniqIndex.miss');
14369
14428
  const idx = createSelectorUniqIndex();
14370
14429
  try {
14371
- perScope.set(__selectorOptsKey, idx);
14430
+ perScope.set(key, idx);
14372
14431
  } catch { /* ignore */
14373
14432
  }
14374
14433
  __perfInc('uniqIndex.build');
@@ -14878,6 +14937,12 @@ const createDomHelpers = (function createDomHelpers(opts) {
14878
14937
  isAccTreeEligible,
14879
14938
  isDomVisibleEligible,
14880
14939
 
14940
+ // Engine-internal: sets which rule's rule-scoped excludeSelectors
14941
+ // (engineOptions.rules[ruleId].excludeSelectors) are currently in
14942
+ // effect. Called by dom-runner.js before each rule invocation, not
14943
+ // intended for use by rule implementations.
14944
+ __setActiveRuleExcludeSelectors,
14945
+
14881
14946
  // Eligibility info wrapper
14882
14947
  getEligibilityInfo,
14883
14948
 
@@ -15097,6 +15162,9 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
15097
15162
 
15098
15163
  // Default on: opt OUT with `includeShadowDom: false`, not opt in.
15099
15164
  const includeShadowDom = !(engineOptionsResolved && engineOptionsResolved.includeShadowDom === false);
15165
+ // Default off: hidden/collapsed content is excluded from rule evaluation
15166
+ // unless the caller explicitly opts in.
15167
+ const includeHiddenElements = !!(engineOptionsResolved && engineOptionsResolved.includeHiddenElements === true);
15100
15168
  const excludeSelectors = normalizeSelectorList(engineOptionsResolved && engineOptionsResolved.excludeSelectors);
15101
15169
 
15102
15170
  const url = pageUrl || (document.location && document.location.href) || null;
@@ -15112,6 +15180,7 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
15112
15180
  window,
15113
15181
  root: roots,
15114
15182
  includeShadowDom,
15183
+ includeHiddenElements,
15115
15184
  excludeSelectors,
15116
15185
  // Optional perf counters (bench/debug only). Deterministic and per-run.
15117
15186
  perfStats: !!(engineOptionsResolved && engineOptionsResolved.perfStats)
@@ -15291,6 +15360,15 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
15291
15360
  ? engineOptionsResolved.rules[defResolved.ruleId]
15292
15361
  : null;
15293
15362
 
15363
+ // Rule-scoped excludeSelectors (engineOptions.rules[ruleId].excludeSelectors)
15364
+ // apply on top of the global excludeSelectors for exactly this rule's
15365
+ // applicability check + run, then are cleared once this rule is done.
15366
+ // Safe because rule execution below is synchronous and one rule at a
15367
+ // time -- sharedHelpers is reused across all rules in this loop.
15368
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
15369
+ sharedHelpers.__setActiveRuleExcludeSelectors(ruleConfig && ruleConfig.excludeSelectors);
15370
+ }
15371
+
15294
15372
  const ctx = {
15295
15373
  document,
15296
15374
  window,
@@ -15361,6 +15439,14 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
15361
15439
  if (ruleTimings) ruleTimings[defResolved.ruleId] = (ruleTimings[defResolved.ruleId] || 0) + (nowMs() - t0);
15362
15440
  }
15363
15441
 
15442
+ // Composite rollups below carry no occurrences/nodes of their own, so
15443
+ // they never exercise rule-scoped excludes -- but clear the "active
15444
+ // rule" state on sharedHelpers regardless, so nothing after this point
15445
+ // (composite aggregation, perf stats) can observe a stale rule's excludes.
15446
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
15447
+ sharedHelpers.__setActiveRuleExcludeSelectors(null);
15448
+ }
15449
+
15364
15450
  // =========================
15365
15451
  // Composite rule aggregation (data-only rollups)
15366
15452
  // =========================
@@ -43007,16 +43093,41 @@ const createDomHelpers = (function createDomHelpers(opts) {
43007
43093
  })();
43008
43094
  // Default on: opt OUT with `includeShadowDom: false`, not opt in.
43009
43095
  const includeShadowDom = !(opts && opts.includeShadowDom === false);
43096
+ // Default off: by default, helper queries skip structurally/CSS-hidden
43097
+ // subtrees (display:none, [hidden], closed <details>, etc.). Callers can
43098
+ // opt out with includeHiddenElements:true.
43099
+ const includeHiddenElements = !!(opts && opts.includeHiddenElements === true);
43010
43100
  const excludeSelectors = Array.isArray(opts && opts.excludeSelectors) ? opts.excludeSelectors : [];
43011
43101
 
43102
+ // Rule-scoped excludes (engineOptions.rules[ruleId].excludeSelectors), set
43103
+ // by dom-runner.js immediately before invoking each rule's applicability/
43104
+ // run function via __setActiveRuleExcludeSelectors(). Safe as mutable
43105
+ // closure state because rule execution is synchronous and single-rule-
43106
+ // at-a-time: exactly one rule's excludes are ever "active" at once.
43107
+ var __activeRuleExcludeSelectors = [];
43108
+
43109
+ function __getEffectiveExcludeSelectors() {
43110
+ return __activeRuleExcludeSelectors.length
43111
+ ? excludeSelectors.concat(__activeRuleExcludeSelectors)
43112
+ : excludeSelectors;
43113
+ }
43114
+
43115
+ function __setActiveRuleExcludeSelectors(list) {
43116
+ __activeRuleExcludeSelectors = normalizeSelectorList(list);
43117
+ }
43118
+
43012
43119
  // 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(',');
43120
+ // selector strings) depend on includeShadowDom/the effective exclude
43121
+ // list, since those change which elements are considered when checking
43122
+ // uniqueness. The underlying storage is shared across createDomHelpers()
43123
+ // calls on the same window/document (see __domSharedCache below), so a
43124
+ // run -- or a rule with its own rule-scoped excludes -- must not
43125
+ // read/write another run/rule's cached selectors. This key partitions
43126
+ // those caches per effective option set; recomputed per call (not a
43127
+ // constant) since the effective list changes as the active rule changes.
43128
+ function __getSelectorOptsKey() {
43129
+ return (includeShadowDom ? 'sd1' : 'sd0') + '|' + __getEffectiveExcludeSelectors().slice().sort().join(',');
43130
+ }
43020
43131
 
43021
43132
  // -------------------------------------------------------------------------
43022
43133
  // Per-run shared caches (DOM helpers)
@@ -43778,9 +43889,10 @@ const createDomHelpers = (function createDomHelpers(opts) {
43778
43889
 
43779
43890
 
43780
43891
  function isExcluded(el) {
43781
- if (!excludeSelectors.length || !el || !el.closest) return false;
43892
+ const eff = __getEffectiveExcludeSelectors();
43893
+ if (!eff.length || !el || !el.closest) return false;
43782
43894
  try {
43783
- return excludeSelectors.some((sel) => !!el.closest(sel));
43895
+ return eff.some((sel) => !!el.closest(sel));
43784
43896
  } catch {
43785
43897
  return false;
43786
43898
  }
@@ -43875,8 +43987,10 @@ const createDomHelpers = (function createDomHelpers(opts) {
43875
43987
  if (!scope || !scope.querySelectorAll) return [];
43876
43988
 
43877
43989
  // 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) {
43990
+ // IMPORTANT: do not cache when the effective exclude list (global
43991
+ // ∪ active rule-scoped excludes) is non-empty -- different rules
43992
+ // may have different effective lists and must not share results.
43993
+ if (!__getEffectiveExcludeSelectors().length && __shadowRootsByRoot) {
43880
43994
  try {
43881
43995
  const cached = __shadowRootsByRoot.get(scope);
43882
43996
  if (cached) {
@@ -43949,9 +44063,38 @@ const createDomHelpers = (function createDomHelpers(opts) {
43949
44063
  return results;
43950
44064
  }
43951
44065
 
44066
+ const HARD_HIDDEN_REASONS = new Set([
44067
+ 'displayNone',
44068
+ 'hiddenAttr',
44069
+ 'detailsClosed',
44070
+ 'templateContent',
44071
+ 'nonRenderedElement',
44072
+ 'inputHidden',
44073
+ 'visibilityHidden'
44074
+ ]);
44075
+
43952
44076
  function queryAllSmart(sel) {
43953
- const list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
43954
- return excludeSelectors.length ? list.filter((el) => !isExcluded(el)) : list;
44077
+ let list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
44078
+
44079
+ // Global hidden-content policy: skip nodes that are fully excluded from
44080
+ // rendered visibility by default (unless includeHiddenElements:true).
44081
+ if (!includeHiddenElements) {
44082
+ list = list.filter((el) => {
44083
+ try {
44084
+ const vis = isAccTreeEligible(el);
44085
+ if (!vis || vis.eligible !== false) return true;
44086
+ const reasons = Array.isArray(vis.reasons) ? vis.reasons : [];
44087
+ for (const r of reasons) {
44088
+ if (HARD_HIDDEN_REASONS.has(r)) return false;
44089
+ }
44090
+ return true;
44091
+ } catch {
44092
+ return true;
44093
+ }
44094
+ });
44095
+ }
44096
+
44097
+ return __getEffectiveExcludeSelectors().length ? list.filter((el) => !isExcluded(el)) : list;
43955
44098
  }
43956
44099
 
43957
44100
  // -------------------------------------------------------------------------
@@ -43985,10 +44128,11 @@ const createDomHelpers = (function createDomHelpers(opts) {
43985
44128
  function __getSelectorCacheForOpts() {
43986
44129
  if (!__selectorCache) return null;
43987
44130
  try {
43988
- let wm = __selectorCache.get(__selectorOptsKey);
44131
+ const key = __getSelectorOptsKey();
44132
+ let wm = __selectorCache.get(key);
43989
44133
  if (!(wm instanceof WeakMap)) {
43990
44134
  wm = new WeakMap();
43991
- __selectorCache.set(__selectorOptsKey, wm);
44135
+ __selectorCache.set(key, wm);
43992
44136
  }
43993
44137
  return wm;
43994
44138
  } catch {
@@ -46399,7 +46543,8 @@ const createDomHelpers = (function createDomHelpers(opts) {
46399
46543
  return createSelectorUniqIndex();
46400
46544
  }
46401
46545
 
46402
- const cached = perScope.get(__selectorOptsKey);
46546
+ const key = __getSelectorOptsKey();
46547
+ const cached = perScope.get(key);
46403
46548
  if (cached) {
46404
46549
  __perfInc('uniqIndex.hit');
46405
46550
  return cached;
@@ -46408,7 +46553,7 @@ const createDomHelpers = (function createDomHelpers(opts) {
46408
46553
  __perfInc('uniqIndex.miss');
46409
46554
  const idx = createSelectorUniqIndex();
46410
46555
  try {
46411
- perScope.set(__selectorOptsKey, idx);
46556
+ perScope.set(key, idx);
46412
46557
  } catch { /* ignore */
46413
46558
  }
46414
46559
  __perfInc('uniqIndex.build');
@@ -46918,6 +47063,12 @@ const createDomHelpers = (function createDomHelpers(opts) {
46918
47063
  isAccTreeEligible,
46919
47064
  isDomVisibleEligible,
46920
47065
 
47066
+ // Engine-internal: sets which rule's rule-scoped excludeSelectors
47067
+ // (engineOptions.rules[ruleId].excludeSelectors) are currently in
47068
+ // effect. Called by dom-runner.js before each rule invocation, not
47069
+ // intended for use by rule implementations.
47070
+ __setActiveRuleExcludeSelectors,
47071
+
46921
47072
  // Eligibility info wrapper
46922
47073
  getEligibilityInfo,
46923
47074
 
@@ -47137,6 +47288,9 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
47137
47288
 
47138
47289
  // Default on: opt OUT with `includeShadowDom: false`, not opt in.
47139
47290
  const includeShadowDom = !(engineOptionsResolved && engineOptionsResolved.includeShadowDom === false);
47291
+ // Default off: hidden/collapsed content is excluded from rule evaluation
47292
+ // unless the caller explicitly opts in.
47293
+ const includeHiddenElements = !!(engineOptionsResolved && engineOptionsResolved.includeHiddenElements === true);
47140
47294
  const excludeSelectors = normalizeSelectorList(engineOptionsResolved && engineOptionsResolved.excludeSelectors);
47141
47295
 
47142
47296
  const url = pageUrl || (document.location && document.location.href) || null;
@@ -47152,6 +47306,7 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
47152
47306
  window,
47153
47307
  root: roots,
47154
47308
  includeShadowDom,
47309
+ includeHiddenElements,
47155
47310
  excludeSelectors,
47156
47311
  // Optional perf counters (bench/debug only). Deterministic and per-run.
47157
47312
  perfStats: !!(engineOptionsResolved && engineOptionsResolved.perfStats)
@@ -47331,6 +47486,15 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
47331
47486
  ? engineOptionsResolved.rules[defResolved.ruleId]
47332
47487
  : null;
47333
47488
 
47489
+ // Rule-scoped excludeSelectors (engineOptions.rules[ruleId].excludeSelectors)
47490
+ // apply on top of the global excludeSelectors for exactly this rule's
47491
+ // applicability check + run, then are cleared once this rule is done.
47492
+ // Safe because rule execution below is synchronous and one rule at a
47493
+ // time -- sharedHelpers is reused across all rules in this loop.
47494
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
47495
+ sharedHelpers.__setActiveRuleExcludeSelectors(ruleConfig && ruleConfig.excludeSelectors);
47496
+ }
47497
+
47334
47498
  const ctx = {
47335
47499
  document,
47336
47500
  window,
@@ -47401,6 +47565,14 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
47401
47565
  if (ruleTimings) ruleTimings[defResolved.ruleId] = (ruleTimings[defResolved.ruleId] || 0) + (nowMs() - t0);
47402
47566
  }
47403
47567
 
47568
+ // Composite rollups below carry no occurrences/nodes of their own, so
47569
+ // they never exercise rule-scoped excludes -- but clear the "active
47570
+ // rule" state on sharedHelpers regardless, so nothing after this point
47571
+ // (composite aggregation, perf stats) can observe a stale rule's excludes.
47572
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
47573
+ sharedHelpers.__setActiveRuleExcludeSelectors(null);
47574
+ }
47575
+
47404
47576
  // =========================
47405
47577
  // Composite rule aggregation (data-only rollups)
47406
47578
  // =========================
@@ -75002,16 +75174,41 @@ const createDomHelpers = (function createDomHelpers(opts) {
75002
75174
  })();
75003
75175
  // Default on: opt OUT with `includeShadowDom: false`, not opt in.
75004
75176
  const includeShadowDom = !(opts && opts.includeShadowDom === false);
75177
+ // Default off: by default, helper queries skip structurally/CSS-hidden
75178
+ // subtrees (display:none, [hidden], closed <details>, etc.). Callers can
75179
+ // opt out with includeHiddenElements:true.
75180
+ const includeHiddenElements = !!(opts && opts.includeHiddenElements === true);
75005
75181
  const excludeSelectors = Array.isArray(opts && opts.excludeSelectors) ? opts.excludeSelectors : [];
75006
75182
 
75183
+ // Rule-scoped excludes (engineOptions.rules[ruleId].excludeSelectors), set
75184
+ // by dom-runner.js immediately before invoking each rule's applicability/
75185
+ // run function via __setActiveRuleExcludeSelectors(). Safe as mutable
75186
+ // closure state because rule execution is synchronous and single-rule-
75187
+ // at-a-time: exactly one rule's excludes are ever "active" at once.
75188
+ var __activeRuleExcludeSelectors = [];
75189
+
75190
+ function __getEffectiveExcludeSelectors() {
75191
+ return __activeRuleExcludeSelectors.length
75192
+ ? excludeSelectors.concat(__activeRuleExcludeSelectors)
75193
+ : excludeSelectors;
75194
+ }
75195
+
75196
+ function __setActiveRuleExcludeSelectors(list) {
75197
+ __activeRuleExcludeSelectors = normalizeSelectorList(list);
75198
+ }
75199
+
75007
75200
  // 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(',');
75201
+ // selector strings) depend on includeShadowDom/the effective exclude
75202
+ // list, since those change which elements are considered when checking
75203
+ // uniqueness. The underlying storage is shared across createDomHelpers()
75204
+ // calls on the same window/document (see __domSharedCache below), so a
75205
+ // run -- or a rule with its own rule-scoped excludes -- must not
75206
+ // read/write another run/rule's cached selectors. This key partitions
75207
+ // those caches per effective option set; recomputed per call (not a
75208
+ // constant) since the effective list changes as the active rule changes.
75209
+ function __getSelectorOptsKey() {
75210
+ return (includeShadowDom ? 'sd1' : 'sd0') + '|' + __getEffectiveExcludeSelectors().slice().sort().join(',');
75211
+ }
75015
75212
 
75016
75213
  // -------------------------------------------------------------------------
75017
75214
  // Per-run shared caches (DOM helpers)
@@ -75773,9 +75970,10 @@ const createDomHelpers = (function createDomHelpers(opts) {
75773
75970
 
75774
75971
 
75775
75972
  function isExcluded(el) {
75776
- if (!excludeSelectors.length || !el || !el.closest) return false;
75973
+ const eff = __getEffectiveExcludeSelectors();
75974
+ if (!eff.length || !el || !el.closest) return false;
75777
75975
  try {
75778
- return excludeSelectors.some((sel) => !!el.closest(sel));
75976
+ return eff.some((sel) => !!el.closest(sel));
75779
75977
  } catch {
75780
75978
  return false;
75781
75979
  }
@@ -75870,8 +76068,10 @@ const createDomHelpers = (function createDomHelpers(opts) {
75870
76068
  if (!scope || !scope.querySelectorAll) return [];
75871
76069
 
75872
76070
  // 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) {
76071
+ // IMPORTANT: do not cache when the effective exclude list (global
76072
+ // ∪ active rule-scoped excludes) is non-empty -- different rules
76073
+ // may have different effective lists and must not share results.
76074
+ if (!__getEffectiveExcludeSelectors().length && __shadowRootsByRoot) {
75875
76075
  try {
75876
76076
  const cached = __shadowRootsByRoot.get(scope);
75877
76077
  if (cached) {
@@ -75944,9 +76144,38 @@ const createDomHelpers = (function createDomHelpers(opts) {
75944
76144
  return results;
75945
76145
  }
75946
76146
 
76147
+ const HARD_HIDDEN_REASONS = new Set([
76148
+ 'displayNone',
76149
+ 'hiddenAttr',
76150
+ 'detailsClosed',
76151
+ 'templateContent',
76152
+ 'nonRenderedElement',
76153
+ 'inputHidden',
76154
+ 'visibilityHidden'
76155
+ ]);
76156
+
75947
76157
  function queryAllSmart(sel) {
75948
- const list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
75949
- return excludeSelectors.length ? list.filter((el) => !isExcluded(el)) : list;
76158
+ let list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
76159
+
76160
+ // Global hidden-content policy: skip nodes that are fully excluded from
76161
+ // rendered visibility by default (unless includeHiddenElements:true).
76162
+ if (!includeHiddenElements) {
76163
+ list = list.filter((el) => {
76164
+ try {
76165
+ const vis = isAccTreeEligible(el);
76166
+ if (!vis || vis.eligible !== false) return true;
76167
+ const reasons = Array.isArray(vis.reasons) ? vis.reasons : [];
76168
+ for (const r of reasons) {
76169
+ if (HARD_HIDDEN_REASONS.has(r)) return false;
76170
+ }
76171
+ return true;
76172
+ } catch {
76173
+ return true;
76174
+ }
76175
+ });
76176
+ }
76177
+
76178
+ return __getEffectiveExcludeSelectors().length ? list.filter((el) => !isExcluded(el)) : list;
75950
76179
  }
75951
76180
 
75952
76181
  // -------------------------------------------------------------------------
@@ -75980,10 +76209,11 @@ const createDomHelpers = (function createDomHelpers(opts) {
75980
76209
  function __getSelectorCacheForOpts() {
75981
76210
  if (!__selectorCache) return null;
75982
76211
  try {
75983
- let wm = __selectorCache.get(__selectorOptsKey);
76212
+ const key = __getSelectorOptsKey();
76213
+ let wm = __selectorCache.get(key);
75984
76214
  if (!(wm instanceof WeakMap)) {
75985
76215
  wm = new WeakMap();
75986
- __selectorCache.set(__selectorOptsKey, wm);
76216
+ __selectorCache.set(key, wm);
75987
76217
  }
75988
76218
  return wm;
75989
76219
  } catch {
@@ -78394,7 +78624,8 @@ const createDomHelpers = (function createDomHelpers(opts) {
78394
78624
  return createSelectorUniqIndex();
78395
78625
  }
78396
78626
 
78397
- const cached = perScope.get(__selectorOptsKey);
78627
+ const key = __getSelectorOptsKey();
78628
+ const cached = perScope.get(key);
78398
78629
  if (cached) {
78399
78630
  __perfInc('uniqIndex.hit');
78400
78631
  return cached;
@@ -78403,7 +78634,7 @@ const createDomHelpers = (function createDomHelpers(opts) {
78403
78634
  __perfInc('uniqIndex.miss');
78404
78635
  const idx = createSelectorUniqIndex();
78405
78636
  try {
78406
- perScope.set(__selectorOptsKey, idx);
78637
+ perScope.set(key, idx);
78407
78638
  } catch { /* ignore */
78408
78639
  }
78409
78640
  __perfInc('uniqIndex.build');
@@ -78913,6 +79144,12 @@ const createDomHelpers = (function createDomHelpers(opts) {
78913
79144
  isAccTreeEligible,
78914
79145
  isDomVisibleEligible,
78915
79146
 
79147
+ // Engine-internal: sets which rule's rule-scoped excludeSelectors
79148
+ // (engineOptions.rules[ruleId].excludeSelectors) are currently in
79149
+ // effect. Called by dom-runner.js before each rule invocation, not
79150
+ // intended for use by rule implementations.
79151
+ __setActiveRuleExcludeSelectors,
79152
+
78916
79153
  // Eligibility info wrapper
78917
79154
  getEligibilityInfo,
78918
79155
 
@@ -79132,6 +79369,9 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
79132
79369
 
79133
79370
  // Default on: opt OUT with `includeShadowDom: false`, not opt in.
79134
79371
  const includeShadowDom = !(engineOptionsResolved && engineOptionsResolved.includeShadowDom === false);
79372
+ // Default off: hidden/collapsed content is excluded from rule evaluation
79373
+ // unless the caller explicitly opts in.
79374
+ const includeHiddenElements = !!(engineOptionsResolved && engineOptionsResolved.includeHiddenElements === true);
79135
79375
  const excludeSelectors = normalizeSelectorList(engineOptionsResolved && engineOptionsResolved.excludeSelectors);
79136
79376
 
79137
79377
  const url = pageUrl || (document.location && document.location.href) || null;
@@ -79147,6 +79387,7 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
79147
79387
  window,
79148
79388
  root: roots,
79149
79389
  includeShadowDom,
79390
+ includeHiddenElements,
79150
79391
  excludeSelectors,
79151
79392
  // Optional perf counters (bench/debug only). Deterministic and per-run.
79152
79393
  perfStats: !!(engineOptionsResolved && engineOptionsResolved.perfStats)
@@ -79326,6 +79567,15 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
79326
79567
  ? engineOptionsResolved.rules[defResolved.ruleId]
79327
79568
  : null;
79328
79569
 
79570
+ // Rule-scoped excludeSelectors (engineOptions.rules[ruleId].excludeSelectors)
79571
+ // apply on top of the global excludeSelectors for exactly this rule's
79572
+ // applicability check + run, then are cleared once this rule is done.
79573
+ // Safe because rule execution below is synchronous and one rule at a
79574
+ // time -- sharedHelpers is reused across all rules in this loop.
79575
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
79576
+ sharedHelpers.__setActiveRuleExcludeSelectors(ruleConfig && ruleConfig.excludeSelectors);
79577
+ }
79578
+
79329
79579
  const ctx = {
79330
79580
  document,
79331
79581
  window,
@@ -79396,6 +79646,14 @@ const runCore = (function runCore(pageUrl, contextSelector, engineOptions, runOn
79396
79646
  if (ruleTimings) ruleTimings[defResolved.ruleId] = (ruleTimings[defResolved.ruleId] || 0) + (nowMs() - t0);
79397
79647
  }
79398
79648
 
79649
+ // Composite rollups below carry no occurrences/nodes of their own, so
79650
+ // they never exercise rule-scoped excludes -- but clear the "active
79651
+ // rule" state on sharedHelpers regardless, so nothing after this point
79652
+ // (composite aggregation, perf stats) can observe a stale rule's excludes.
79653
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
79654
+ sharedHelpers.__setActiveRuleExcludeSelectors(null);
79655
+ }
79656
+
79399
79657
  // =========================
79400
79658
  // Composite rule aggregation (data-only rollups)
79401
79659
  // =========================