@surea11y/core 1.0.0 → 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 +20 -0
- package/README.md +382 -81
- package/docs/ENGINE_OPTIONS.md +97 -4
- package/docs/I18N.md +3 -3
- package/docs/OUTPUT_SCHEMA.md +5 -5
- package/docs/RULE_TAXONOMY.md +9 -8
- package/docs/TROUBLESHOOTING.md +3 -3
- package/docs/WCAG_CONFORMANCE.md +1 -1
- package/package.json +3 -2
- package/src/checks/automatic/aria-allowed-attr.js +47 -24
- package/src/core/dom-helpers.js +48 -16
- package/src/core/dom-runner.js +17 -0
- package/src/core.js +1173 -87
- package/src/i18n/fr.js +314 -1
package/docs/ENGINE_OPTIONS.md
CHANGED
|
@@ -82,6 +82,7 @@ const engineOptions = {
|
|
|
82
82
|
mode: 'strictConformance', // 'strictConformance' (default) | 'auditorAssist'
|
|
83
83
|
rootCanvasFallback: '#ffffff' // background assumed when the true root background isn't computable
|
|
84
84
|
},
|
|
85
|
+
visibilityMode: 'styleOnly', // 'styleOnly' (default) | 'styleAndGeometry' — see below; scoped to the contrast rules only
|
|
85
86
|
|
|
86
87
|
policyContract: 'a11y', // 'a11y' (default) | 'generic' | inline contract object — see POLICY.md
|
|
87
88
|
policy: { // optional overrides on top of policyContract
|
|
@@ -94,7 +95,9 @@ const engineOptions = {
|
|
|
94
95
|
},
|
|
95
96
|
|
|
96
97
|
rules: {
|
|
97
|
-
'some-rule-id': {
|
|
98
|
+
'some-rule-id': {
|
|
99
|
+
excludeSelectors: ['.some-noisy-widget'] // narrows candidates for THIS rule only — see note
|
|
100
|
+
}
|
|
98
101
|
},
|
|
99
102
|
|
|
100
103
|
probes: { /* optional host-supplied evidence, see note */ },
|
|
@@ -112,17 +115,107 @@ const engineOptions = {
|
|
|
112
115
|
|---|---|
|
|
113
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. |
|
|
114
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). |
|
|
115
|
-
| `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. |
|
|
116
119
|
| `timestamp` | Passed straight through to the result's top-level `timestamp` field; the engine does not generate one itself (deterministic-by-design). |
|
|
117
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. |
|
|
118
121
|
| `contrast.rootCanvasFallback` | The assumed page background color when it's not computable at all — only matters in `auditorAssist` mode. |
|
|
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). |
|
|
119
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`. |
|
|
120
|
-
| `output.includeSelector` / `.includeHtml` | Only affects the small number of rules (currently 4 of
|
|
121
|
-
| `rules[ruleId]` | Passed through to that rule as `ctx.config
|
|
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. |
|
|
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`. |
|
|
122
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. |
|
|
123
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. |
|
|
124
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`. |
|
|
125
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
|
+
|
|
156
|
+
## Recipes — composing options for real scenarios
|
|
157
|
+
|
|
158
|
+
The reference above documents each option in isolation. These combine several at once, for scenarios you're likely to actually hit.
|
|
159
|
+
|
|
160
|
+
**CI gate: WCAG 2.2 AA only, ignore a third-party widget you don't control**
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
runDomRulesInPage(url, null, {
|
|
164
|
+
excludeSelectors: ['#cookie-banner', '.intercom-launcher'],
|
|
165
|
+
tags: { include: 'wcag2a,wcag2aa,wcag21a,wcag21aa,wcag22a,wcag22aa' }
|
|
166
|
+
}, null);
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Human auditor doing a deep contrast pass in a real browser** — trade some false-positive protection for more findings, and check real layout (not just computed style) since a real page is being driven. Shown with Puppeteer's `page.evaluate` (accepts multiple args); if you're on Playwright, wrap the four positional args into a single object first — see [`INTEGRATION.md`](./INTEGRATION.md):
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
const result = await page.evaluate(runa11yCoreInPage, url, null, {
|
|
173
|
+
contrast: { mode: 'auditorAssist' },
|
|
174
|
+
visibilityMode: 'styleAndGeometry'
|
|
175
|
+
}, null);
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Scoped re-scan of one region after a UI change, skipping shadow DOM** — useful in a component-level test where you only care about the widget you just changed:
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
runDomRulesInPage(url, '#checkout-form', {
|
|
182
|
+
includeShadowDom: false
|
|
183
|
+
}, { includeRuleIds: ['form-control-programmatic-label-present', 'button-name-present'] });
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**Reproducible output for snapshot testing** — pin a `timestamp` so two runs of the same HTML produce byte-identical JSON, and request the debug timing breakdown:
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
runDomRulesInPage(url, null, {
|
|
190
|
+
timestamp: '2026-01-01T00:00:00Z',
|
|
191
|
+
perfStats: true,
|
|
192
|
+
profileRules: true
|
|
193
|
+
}, null);
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**A custom, org-specific rule alongside the built-ins**, only for this one call:
|
|
197
|
+
|
|
198
|
+
```js
|
|
199
|
+
runDomRulesInPage(url, null, {
|
|
200
|
+
customRules: [{
|
|
201
|
+
id: 'org-no-inline-onclick',
|
|
202
|
+
meta: { title: 'No inline onclick handlers', defaultSeverity: 'moderate' },
|
|
203
|
+
runInPage(ctx) {
|
|
204
|
+
const els = ctx.helpers.queryAll('[onclick]');
|
|
205
|
+
const occurrences = els.map((el) => ({
|
|
206
|
+
selector: ctx.helpers.buildSelector(el),
|
|
207
|
+
html: el.outerHTML,
|
|
208
|
+
summary: 'Inline onclick handler found.',
|
|
209
|
+
hint: 'Move event handling into an external script.'
|
|
210
|
+
}));
|
|
211
|
+
return { ruleId: ctx.rule.ruleId, outcome: occurrences.length ? 'fail' : 'pass', severity: 'moderate', occurrences };
|
|
212
|
+
}
|
|
213
|
+
}]
|
|
214
|
+
}, null);
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
See the option-by-option table above for anything not shown here, and the `customRules` section immediately below for the full descriptor contract.
|
|
218
|
+
|
|
126
219
|
## `customRules` — runtime-registered rules (equivalent to the rule/check registration pattern used by other engines)
|
|
127
220
|
|
|
128
221
|
Every shipped rule is baked into `src/core.js` at build time. `engineOptions.customRules` is the runtime escape hatch: an array of rule descriptors registered for that one call only — nothing is added to the static catalog (`getRulesCatalog()`/`getChecksCatalog()`), and nothing persists between calls. This is deliberate, not a limitation to work around: surea11y already takes fresh `engineOptions` per call with no mutable global config (unlike some other engines, which need a `configure()`/`reset()` step against a shared runtime), and custom rules follow that same per-call model.
|
package/docs/I18N.md
CHANGED
|
@@ -6,10 +6,10 @@ Every rule's title/description, and every occurrence's summary/hint, is localize
|
|
|
6
6
|
|
|
7
7
|
| Locale | File | Keys | Coverage vs. English |
|
|
8
8
|
|---|---|---|---|
|
|
9
|
-
| `en` (English) | `src/i18n/en.js` |
|
|
10
|
-
| `fr` (French) | `src/i18n/fr.js` |
|
|
9
|
+
| `en` (English) | `src/i18n/en.js` | 600 | 100% (the canonical/fallback set) |
|
|
10
|
+
| `fr` (French) | `src/i18n/fr.js` | 600 | 100% |
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Both locales are fully translated as of this writing. That won't stay automatically true — every time a new rule (or a new i18n key) is added to `en.js`, `fr.js` needs the same key added, or it silently falls back to English for that string (see the fallback behavior below). There's no automated check for this yet; diff `Object.keys(require('./src/i18n/en.js'))` against `fr.js` after adding a rule to catch drift before it ships.
|
|
13
13
|
|
|
14
14
|
## Selecting a locale
|
|
15
15
|
|
package/docs/OUTPUT_SCHEMA.md
CHANGED
|
@@ -103,7 +103,7 @@ Notes:
|
|
|
103
103
|
|
|
104
104
|
## An occurrence (`occurrences[i]`)
|
|
105
105
|
|
|
106
|
-
Only present when `outcome` is `fail` or `cantTell` (a `pass`/`notApplicable` result has `occurrences: []` — this engine does not enumerate the elements it passed, only the ones it flagged
|
|
106
|
+
Only present when `outcome` is `fail` or `cantTell` (a `pass`/`notApplicable` result has `occurrences: []` — this engine does not enumerate the elements it passed, only the ones it flagged).
|
|
107
107
|
|
|
108
108
|
```ts
|
|
109
109
|
{
|
|
@@ -114,7 +114,7 @@ Only present when `outcome` is `fail` or `cantTell` (a `pass`/`notApplicable` re
|
|
|
114
114
|
hint: string,
|
|
115
115
|
i18n: { summaryKey: string, hintKey: string, params: object } | null,
|
|
116
116
|
data: {
|
|
117
|
-
visibilityFilter?: { targetSet: string, accEligible: boolean | null, reasons: string[] },
|
|
117
|
+
visibilityFilter?: { eligible: boolean, targetSet: string, accEligible: boolean | null, reasons: string[] },
|
|
118
118
|
details?: object // rule-specific, non-normative — see below
|
|
119
119
|
}
|
|
120
120
|
}
|
|
@@ -128,7 +128,7 @@ Only present when `outcome` is `fail` or `cantTell` (a `pass`/`notApplicable` re
|
|
|
128
128
|
| `summary` | Human-readable, already localized ("This button has no accessible name."). |
|
|
129
129
|
| `hint` | Human-readable remediation guidance, already localized. |
|
|
130
130
|
| `i18n` | The raw translation keys behind `summary`/`hint`, if you want to re-render them in a different locale yourself without re-running the scan. `null` if the occurrence didn't use key-based i18n. |
|
|
131
|
-
| `data.visibilityFilter` | Present on most occurrences: why the engine considered this element eligible
|
|
131
|
+
| `data.visibilityFilter` | Present on most occurrences: why the engine considered this element eligible (or not) under whichever eligibility model the rule used. `eligible` is that result; `targetSet` says which model produced it (`'dom'`: raw DOM/CSS visibility — most rules; `'acc'`: accessibility-tree eligibility). `accEligible` mirrors `eligible` only when `targetSet` is `'acc'`, otherwise `null` — don't read it as a second, independent signal. `reasons` is a list of machine-readable exclusion codes when `eligible: false`. |
|
|
132
132
|
| `data.details` | Rule-specific structured data (e.g. `reasonCode`, computed metrics, resolved references) — **non-normative**: useful for building richer UI or debugging, but never changes what `outcome`/`severity` mean. Shape varies per rule; treat as best-effort extra context, not a stable contract. |
|
|
133
133
|
|
|
134
134
|
## A composite result (`rulesResults[i]`)
|
|
@@ -202,7 +202,7 @@ const result = runDomRulesInPage(
|
|
|
202
202
|
{
|
|
203
203
|
"selector": "html > body > button",
|
|
204
204
|
"html": "<button></button>",
|
|
205
|
-
"structuralPath": [1,
|
|
205
|
+
"structuralPath": [1, 1],
|
|
206
206
|
"summary": "This button has no accessible name.",
|
|
207
207
|
"hint": "Provide visible button text or a programmatic accessible-name mechanism (for example aria-label) so assistive technologies can identify the button.",
|
|
208
208
|
"data": {
|
|
@@ -222,7 +222,7 @@ const result = runDomRulesInPage(
|
|
|
222
222
|
{
|
|
223
223
|
"selector": "html > body > img",
|
|
224
224
|
"html": "<img src=\"logo.png\">",
|
|
225
|
-
"structuralPath": [1,
|
|
225
|
+
"structuralPath": [1, 0],
|
|
226
226
|
"summary": "Missing alt attribute on <img>.",
|
|
227
227
|
"hint": "Add an alt attribute (use alt=\"\" only for decorative images)."
|
|
228
228
|
}
|
package/docs/RULE_TAXONOMY.md
CHANGED
|
@@ -12,7 +12,7 @@ Encoded by: `meta.type`
|
|
|
12
12
|
|
|
13
13
|
- `automatic`
|
|
14
14
|
- Rule makes a **normative decision**
|
|
15
|
-
- Allowed outcomes: `pass`, `fail`, `notApplicable`
|
|
15
|
+
- Allowed outcomes: `pass`, `fail`, `notApplicable`, and `cantTell` as a defensive fallback only (e.g. an internal-failure safety net, or a computability gate a rule can't resolve — see `contrast-minimum.js`/`contrast-enhanced.js`/`contrast-computable.js`/`target-size-minimum.js`), never as its primary intended path
|
|
16
16
|
- `manual`
|
|
17
17
|
- Rule signals **human review required**
|
|
18
18
|
- Allowed outcomes: `cantTell`, `notApplicable`
|
|
@@ -24,7 +24,7 @@ Manual rules MUST NOT make normative failure decisions.
|
|
|
24
24
|
### 1.2 Intent
|
|
25
25
|
Encoded by: **rule id suffix**
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Illustrative intents (from the image-alternatives family used as the running example in §2, not an exhaustive list — the ruleset's 125 rules use dozens of distinct suffixes; `docs/RULE_CATALOG.md` is the generated, always-current list):
|
|
28
28
|
|
|
29
29
|
- `present`
|
|
30
30
|
- Verifies that a required **mechanism exists**
|
|
@@ -43,7 +43,7 @@ Intent determines whether a rule can be automatic.
|
|
|
43
43
|
### 1.3 Target Family
|
|
44
44
|
Encoded by: **rule id prefix**
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
Illustrative families, from the image-alternatives cluster (WCAG 1.1.1) used as the running example in §2 — not an exhaustive list. The ruleset's 125 rules span dozens of families (`aria-*`, `contrast-*`, `dialog-*`, `iframe-*`, `label-*`, `link-*`, `list-*`, `landmark-*`, and more); see `docs/RULE_CATALOG.md` for the generated, always-current list:
|
|
47
47
|
|
|
48
48
|
- `img`
|
|
49
49
|
- `area`
|
|
@@ -67,12 +67,13 @@ Rules MUST NOT mix families.
|
|
|
67
67
|
### 1.4 Target Set (Tree Scope)
|
|
68
68
|
Encoded by: `data.visibilityFilter.targetSet`
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
- `acc` (accessibility tree)
|
|
70
|
+
Values used by this ruleset:
|
|
71
|
+
- `acc` (accessibility tree) — most rules
|
|
72
|
+
- `dom` (raw DOM/CSS visibility, no accessibility-tree computation) — e.g. `label-in-name.js`
|
|
72
73
|
|
|
73
|
-
Rules
|
|
74
|
-
- `helpers.isAccTreeEligible`
|
|
75
|
-
- `helpers.getEligibilityInfo(..., { targetSet: "acc" })`
|
|
74
|
+
Rules log eligibility against whichever tree scope they target using:
|
|
75
|
+
- `helpers.isAccTreeEligible` / `helpers.isDomVisibleEligible`
|
|
76
|
+
- `helpers.getEligibilityInfo(..., { targetSet: "acc" | "dom" })`
|
|
76
77
|
|
|
77
78
|
This is a semantic constraint, not logging noise.
|
|
78
79
|
|
package/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -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
|
|
|
@@ -39,9 +39,9 @@ No — see [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md). A `pass` means every
|
|
|
39
39
|
|
|
40
40
|
Treat it as "needs a human to look" — it's neither pass nor fail by design. Most teams log `cantTell` findings without failing CI on them, since failing a build on something the engine explicitly couldn't determine tends to train people to ignore the gate. See [`POLICY.md`](./POLICY.md) if you want to reshape this behavior (e.g. via a custom policy contract), and [`INTEGRATION.md`](./INTEGRATION.md#ci-gating-a-build-on-the-result) for a concrete CI-gating example.
|
|
41
41
|
|
|
42
|
-
## "
|
|
42
|
+
## "What happens if a locale is only partially translated?"
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
Missing keys fall back to English per-string (never a blank or broken result), so a partial locale degrades gracefully rather than failing outright — see [`I18N.md`](./I18N.md) for the mechanism and current coverage. Both shipped locales (`en`, `fr`) are at full parity as of this writing, but that's not guaranteed to stay true automatically: adding a new rule adds a new key to `en.js`, and unless the same key is added to `fr.js` (or any other locale file you maintain), that string falls back to English until it is.
|
|
45
45
|
|
|
46
46
|
## "`runDomRulesInPage` vs `runa11yCoreInPage` — which one do I want?"
|
|
47
47
|
|
package/docs/WCAG_CONFORMANCE.md
CHANGED
|
@@ -4,7 +4,7 @@ How individual rule results relate to a WCAG Success Criterion (SC), and what su
|
|
|
4
4
|
|
|
5
5
|
## The three layers
|
|
6
6
|
|
|
7
|
-
1. **Atomic rules** (`checksResults[]`) — one normative decision each, e.g. "does this `<img>` have an `alt` attribute." See [`RULE_CATALOG.md`](./RULE_CATALOG.md) for all
|
|
7
|
+
1. **Atomic rules** (`checksResults[]`) — one normative decision each, e.g. "does this `<img>` have an `alt` attribute." See [`RULE_CATALOG.md`](./RULE_CATALOG.md) for all 125.
|
|
8
8
|
2. **Facets** — a WCAG SC is usually bigger than any one rule can decide deterministically. Internally, each SC is broken into named "facets" (e.g. 1.1.1 Non-text Content has facets like `img-alt-attr-present`, `text-alternative-quality`, `decorative-null`) tracked in `src/coverage/wcag-facets.js`, each marked `full` (a rule decides it with high confidence), `partial` (a rule decides *part* of it — see each rule's own scope notes), or `manual` (no safe automated heuristic exists at all). Run `npm run coverage` to regenerate `coverage/coverage-report.md`, the per-SC facet breakdown.
|
|
9
9
|
3. **Composite (WCAG-SC rollup) rules** (`rulesResults[]`) — a generated aggregate of every atomic rule mapped to one SC, giving you one pass/fail/cantTell/notApplicable verdict per SC instead of having to roll up dozens of atomic results yourself. See [`RULE_CATALOG.md`](./RULE_CATALOG.md#composite-wcag-sc-rollup-rules-31) for the full list (e.g. `wcag-1.1.1-non-text-content` rolls up 22 atomic rules).
|
|
10
10
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@surea11y/core",
|
|
3
|
-
"version": "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": {
|
|
7
|
-
"surea11y": "
|
|
7
|
+
"surea11y": "bin/core.js"
|
|
8
8
|
},
|
|
9
9
|
"author": "Jorge Rumoroso",
|
|
10
10
|
"license": "MIT",
|
|
@@ -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",
|
|
@@ -22,12 +22,7 @@
|
|
|
22
22
|
* - SUPPORTED_ATTRS_BY_ROLE widened 2026-07-21 (7 new roles), cross-checked
|
|
23
23
|
* against a widely-used reference engine's own per-role `allowedAttrs`
|
|
24
24
|
* table AND verified each addition is an unambiguous, well-established ARIA
|
|
25
|
-
* fact rather than a blind import
|
|
26
|
-
* far more than were reconciled here; this pass deliberately took only the
|
|
27
|
-
* additions with clear, specific supported attributes (not just the
|
|
28
|
-
* near-universal, thin `aria-expanded` that engine allows on most roles), and
|
|
29
|
-
* left the rest for a dedicated future full-reconciliation pass rather
|
|
30
|
-
* than rushing all 68 through at lower confidence:
|
|
25
|
+
* fact rather than a blind import:
|
|
31
26
|
* - `searchbox`: identical to the already-covered `textbox` (ARIA
|
|
32
27
|
* explicitly defines searchbox as textbox's subclass, same supported
|
|
33
28
|
* set).
|
|
@@ -47,6 +42,34 @@
|
|
|
47
42
|
* - `menu`/`menubar`/`toolbar`: activedescendant/orientation — standard
|
|
48
43
|
* composite-widget properties, same family as the already-covered
|
|
49
44
|
* `tablist`.
|
|
45
|
+
* - SUPPORTED_ATTRS_BY_ROLE full-reconciliation pass 2026-07-28: the
|
|
46
|
+
* 2026-07-21 pass deliberately deferred ~61 roles where the reference
|
|
47
|
+
* engine (axe-core) allows `aria-expanded`, treating it as too thin/
|
|
48
|
+
* near-universal to import blindly. Checked against `aria-query`
|
|
49
|
+
* (tracks the published WAI-ARIA 1.2 Recommendation, 6 June 2023 — the
|
|
50
|
+
* latest actually-published version, as opposed to the in-progress 1.3
|
|
51
|
+
* Editor's Draft) instead of axe-core directly, because axe-core's own
|
|
52
|
+
* source comments (`// Spec difference: Aria-expanded removed in 1.2`)
|
|
53
|
+
* show that most of its `aria-expanded` allowances are deliberate
|
|
54
|
+
* ARIA-1.1 legacy/AT-compat carryovers axe-core keeps on purpose, not
|
|
55
|
+
* current-spec facts — importing them wholesale would have re-added
|
|
56
|
+
* exactly the kind of unverified allowance the 07-21 pass was avoiding.
|
|
57
|
+
* `aria-query`'s per-role tables (with superclass inheritance resolved,
|
|
58
|
+
* e.g. `aria-activedescendant` via the abstract `composite` role) gave a
|
|
59
|
+
* spec-grounded diff instead. Net changes from that diff:
|
|
60
|
+
* - `aria-expanded` added to: checkbox, columnheader, gridcell, listbox,
|
|
61
|
+
* menuitemcheckbox, menuitemradio, row, rowheader, switch, tab —
|
|
62
|
+
* confirmed present in current spec for these roles specifically
|
|
63
|
+
* (unlike e.g. `listitem`, `dialog`, `alertdialog`, `heading`, which
|
|
64
|
+
* the spec does NOT list it for — those stay excluded).
|
|
65
|
+
* - `aria-activedescendant` added to: combobox, grid, listbox,
|
|
66
|
+
* radiogroup, row, spinbutton, tablist, treegrid (inherited from the
|
|
67
|
+
* abstract `composite` role for any composite/managed-focus widget).
|
|
68
|
+
* - `aria-readonly`/`aria-required` added to: switch, menuitemcheckbox,
|
|
69
|
+
* menuitemradio. `aria-posinset`/`aria-setsize` added to: tab, radio,
|
|
70
|
+
* menuitemcheckbox, menuitemradio. `aria-level` added to: tablist.
|
|
71
|
+
* - `tree`'s `aria-readonly` removed: not in aria-query's resolved
|
|
72
|
+
* props for `tree` (was an unverified carryover, not spec-backed).
|
|
50
73
|
* - Not gated on isAccTreeEligible: this is a static markup property.
|
|
51
74
|
*/
|
|
52
75
|
|
|
@@ -101,39 +124,39 @@ function runInPage(ctx) {
|
|
|
101
124
|
// role/attribute pairings from the WAI-ARIA role definitions are listed.
|
|
102
125
|
const SUPPORTED_ATTRS_BY_ROLE = {
|
|
103
126
|
alertdialog: ['aria-modal'],
|
|
104
|
-
checkbox: ['aria-checked', 'aria-readonly', 'aria-required'],
|
|
105
|
-
columnheader: ['aria-sort', 'aria-colindex', 'aria-colspan', 'aria-readonly', 'aria-required', 'aria-rowindex', 'aria-rowspan', 'aria-selected'],
|
|
106
|
-
combobox: ['aria-expanded', 'aria-autocomplete', 'aria-readonly', 'aria-required'],
|
|
127
|
+
checkbox: ['aria-checked', 'aria-readonly', 'aria-required', 'aria-expanded'],
|
|
128
|
+
columnheader: ['aria-sort', 'aria-colindex', 'aria-colspan', 'aria-readonly', 'aria-required', 'aria-rowindex', 'aria-rowspan', 'aria-selected', 'aria-expanded'],
|
|
129
|
+
combobox: ['aria-expanded', 'aria-autocomplete', 'aria-readonly', 'aria-required', 'aria-activedescendant'],
|
|
107
130
|
dialog: ['aria-modal'],
|
|
108
|
-
grid: ['aria-multiselectable', 'aria-readonly', 'aria-colcount', 'aria-rowcount'],
|
|
109
|
-
gridcell: ['aria-selected', 'aria-readonly', 'aria-required', 'aria-colindex', 'aria-colspan', 'aria-rowindex', 'aria-rowspan'],
|
|
131
|
+
grid: ['aria-multiselectable', 'aria-readonly', 'aria-colcount', 'aria-rowcount', 'aria-activedescendant'],
|
|
132
|
+
gridcell: ['aria-selected', 'aria-readonly', 'aria-required', 'aria-colindex', 'aria-colspan', 'aria-rowindex', 'aria-rowspan', 'aria-expanded'],
|
|
110
133
|
heading: ['aria-level'],
|
|
111
|
-
listbox: ['aria-multiselectable', 'aria-readonly', 'aria-required', 'aria-orientation'],
|
|
134
|
+
listbox: ['aria-multiselectable', 'aria-readonly', 'aria-required', 'aria-orientation', 'aria-expanded', 'aria-activedescendant'],
|
|
112
135
|
listitem: ['aria-level', 'aria-posinset', 'aria-setsize'],
|
|
113
136
|
menu: ['aria-activedescendant', 'aria-orientation'],
|
|
114
137
|
menubar: ['aria-activedescendant', 'aria-orientation'],
|
|
115
|
-
menuitemcheckbox: ['aria-checked'],
|
|
116
|
-
menuitemradio: ['aria-checked'],
|
|
138
|
+
menuitemcheckbox: ['aria-checked', 'aria-expanded', 'aria-readonly', 'aria-required', 'aria-posinset', 'aria-setsize'],
|
|
139
|
+
menuitemradio: ['aria-checked', 'aria-expanded', 'aria-readonly', 'aria-required', 'aria-posinset', 'aria-setsize'],
|
|
117
140
|
meter: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext'],
|
|
118
141
|
option: ['aria-selected', 'aria-checked', 'aria-posinset', 'aria-setsize'],
|
|
119
142
|
progressbar: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext'],
|
|
120
|
-
radio: ['aria-checked'],
|
|
121
|
-
radiogroup: ['aria-readonly', 'aria-required', 'aria-orientation'],
|
|
122
|
-
row: ['aria-selected', 'aria-level', 'aria-posinset', 'aria-setsize', 'aria-colindex', 'aria-rowindex'],
|
|
123
|
-
rowheader: ['aria-sort', 'aria-colindex', 'aria-colspan', 'aria-readonly', 'aria-required', 'aria-rowindex', 'aria-rowspan', 'aria-selected'],
|
|
143
|
+
radio: ['aria-checked', 'aria-posinset', 'aria-setsize'],
|
|
144
|
+
radiogroup: ['aria-readonly', 'aria-required', 'aria-orientation', 'aria-activedescendant'],
|
|
145
|
+
row: ['aria-selected', 'aria-level', 'aria-posinset', 'aria-setsize', 'aria-colindex', 'aria-rowindex', 'aria-expanded', 'aria-activedescendant'],
|
|
146
|
+
rowheader: ['aria-sort', 'aria-colindex', 'aria-colspan', 'aria-readonly', 'aria-required', 'aria-rowindex', 'aria-rowspan', 'aria-selected', 'aria-expanded'],
|
|
124
147
|
scrollbar: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-orientation', 'aria-controls'],
|
|
125
148
|
searchbox: ['aria-activedescendant', 'aria-autocomplete', 'aria-multiline', 'aria-placeholder', 'aria-readonly', 'aria-required'],
|
|
126
149
|
separator: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-orientation'],
|
|
127
150
|
slider: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-orientation', 'aria-readonly'],
|
|
128
|
-
spinbutton: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-readonly', 'aria-required'],
|
|
129
|
-
switch: ['aria-checked'],
|
|
130
|
-
tab: ['aria-selected'],
|
|
151
|
+
spinbutton: ['aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext', 'aria-readonly', 'aria-required', 'aria-activedescendant'],
|
|
152
|
+
switch: ['aria-checked', 'aria-expanded', 'aria-readonly', 'aria-required'],
|
|
153
|
+
tab: ['aria-selected', 'aria-expanded', 'aria-posinset', 'aria-setsize'],
|
|
131
154
|
table: ['aria-colcount', 'aria-rowcount'],
|
|
132
|
-
tablist: ['aria-multiselectable', 'aria-orientation'],
|
|
155
|
+
tablist: ['aria-multiselectable', 'aria-orientation', 'aria-level', 'aria-activedescendant'],
|
|
133
156
|
textbox: ['aria-activedescendant', 'aria-autocomplete', 'aria-multiline', 'aria-placeholder', 'aria-readonly', 'aria-required'],
|
|
134
157
|
toolbar: ['aria-activedescendant', 'aria-orientation'],
|
|
135
|
-
tree: ['aria-multiselectable', 'aria-
|
|
136
|
-
treegrid: ['aria-multiselectable', 'aria-readonly', 'aria-required', 'aria-orientation', 'aria-colcount', 'aria-rowcount'],
|
|
158
|
+
tree: ['aria-multiselectable', 'aria-required', 'aria-orientation', 'aria-activedescendant'],
|
|
159
|
+
treegrid: ['aria-multiselectable', 'aria-readonly', 'aria-required', 'aria-orientation', 'aria-colcount', 'aria-rowcount', 'aria-activedescendant'],
|
|
137
160
|
treeitem: ['aria-checked', 'aria-selected', 'aria-expanded', 'aria-level', 'aria-posinset', 'aria-setsize']
|
|
138
161
|
};
|
|
139
162
|
|
package/src/core/dom-helpers.js
CHANGED
|
@@ -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/
|
|
122
|
-
// those change which elements are considered when checking
|
|
123
|
-
// The underlying storage is shared across createDomHelpers()
|
|
124
|
-
// the same window/document (see __domSharedCache below), so a
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
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
|
-
|
|
910
|
+
const eff = __getEffectiveExcludeSelectors();
|
|
911
|
+
if (!eff.length || !el || !el.closest) return false;
|
|
890
912
|
try {
|
|
891
|
-
return
|
|
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
|
|
987
|
-
|
|
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
|
|
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
|
-
|
|
1120
|
+
const key = __getSelectorOptsKey();
|
|
1121
|
+
let wm = __selectorCache.get(key);
|
|
1097
1122
|
if (!(wm instanceof WeakMap)) {
|
|
1098
1123
|
wm = new WeakMap();
|
|
1099
|
-
__selectorCache.set(
|
|
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
|
|
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(
|
|
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
|
|
package/src/core/dom-runner.js
CHANGED
|
@@ -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
|
// =========================
|