@surea11y/core 1.1.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +48 -4
  2. package/LICENSE +373 -21
  3. package/README.md +70 -1
  4. package/bin/core.js +240 -11
  5. package/docs/API_STABILITY.md +61 -0
  6. package/docs/BASELINE.md +66 -0
  7. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  8. package/docs/CI_INTEGRATIONS.md +103 -0
  9. package/docs/CLI.md +80 -1
  10. package/docs/ENGINE_OPTIONS.md +4 -0
  11. package/docs/INTEGRATION.md +19 -1
  12. package/docs/OUTPUT_SCHEMA.md +2 -2
  13. package/docs/REPORT.md +33 -0
  14. package/docs/RULE_AUTHORING.md +31 -0
  15. package/docs/RULE_CATALOG.md +1 -1
  16. package/docs/SARIF.md +59 -0
  17. package/package.json +15 -4
  18. package/src/baseline.js +0 -0
  19. package/src/catalogs/composites.wcag.js +414 -450
  20. package/src/checks/automatic/area-alt-present.js +59 -25
  21. package/src/checks/automatic/aria-allowed-attr.js +193 -33
  22. package/src/checks/automatic/aria-allowed-role.js +21 -7
  23. package/src/checks/automatic/aria-braille-equivalent.js +32 -10
  24. package/src/checks/automatic/aria-conditional-attr.js +24 -7
  25. package/src/checks/automatic/aria-deprecated-role.js +22 -8
  26. package/src/checks/automatic/aria-hidden-body.js +50 -19
  27. package/src/checks/automatic/aria-hidden-focus.js +408 -53
  28. package/src/checks/automatic/aria-prohibited-attr.js +296 -22
  29. package/src/checks/automatic/aria-prohibited-children.js +125 -29
  30. package/src/checks/automatic/aria-required-attr.js +23 -8
  31. package/src/checks/automatic/aria-required-children.js +37 -14
  32. package/src/checks/automatic/aria-required-parent.js +48 -14
  33. package/src/checks/automatic/aria-role-name-present.js +47 -21
  34. package/src/checks/automatic/aria-roles-valid.js +22 -12
  35. package/src/checks/automatic/aria-valid-attr-value.js +28 -7
  36. package/src/checks/automatic/aria-valid-attr.js +17 -5
  37. package/src/checks/automatic/autocomplete-valid.js +74 -16
  38. package/src/checks/automatic/avoid-inline-spacing.js +20 -6
  39. package/src/checks/automatic/binary-control-name-present.js +60 -50
  40. package/src/checks/automatic/button-name-present.js +48 -18
  41. package/src/checks/automatic/bypass-blocks-present.js +50 -25
  42. package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
  43. package/src/checks/automatic/combobox-name-present.js +38 -45
  44. package/src/checks/automatic/contrast-computable.js +361 -341
  45. package/src/checks/automatic/contrast-enhanced.js +487 -466
  46. package/src/checks/automatic/contrast-minimum.js +486 -465
  47. package/src/checks/automatic/css-orientation-lock.js +39 -9
  48. package/src/checks/automatic/definition-list-children-valid.js +40 -19
  49. package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
  50. package/src/checks/automatic/dialog-name-present.js +37 -75
  51. package/src/checks/automatic/dlitem-parent-valid.js +23 -8
  52. package/src/checks/automatic/duplicate-id-aria.js +24 -6
  53. package/src/checks/automatic/embed-text-alternative-present.js +86 -35
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
  55. package/src/checks/automatic/form-control-single-label.js +47 -10
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +43 -19
  57. package/src/checks/automatic/iframe-focusable-content.js +26 -11
  58. package/src/checks/automatic/iframe-name-present.js +31 -9
  59. package/src/checks/automatic/iframe-title-unique.js +29 -8
  60. package/src/checks/automatic/img-alt-present.js +47 -43
  61. package/src/checks/automatic/input-image-alt-present.js +143 -112
  62. package/src/checks/automatic/label-in-name.js +50 -22
  63. package/src/checks/automatic/language-page-present.js +117 -109
  64. package/src/checks/automatic/link-in-text-block.js +59 -19
  65. package/src/checks/automatic/link-name-present.js +45 -14
  66. package/src/checks/automatic/list-children-valid.js +26 -9
  67. package/src/checks/automatic/listbox-name-present.js +39 -19
  68. package/src/checks/automatic/listitem-parent-valid.js +18 -6
  69. package/src/checks/automatic/menuitem-name-present.js +39 -61
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +37 -8
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +28 -6
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +32 -7
  73. package/src/checks/automatic/meter-name-present.js +36 -33
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
  75. package/src/checks/automatic/object-text-alternative-present.js +91 -39
  76. package/src/checks/automatic/option-name-present.js +38 -21
  77. package/src/checks/automatic/page-title-present.js +26 -7
  78. package/src/checks/automatic/progressbar-name-present.js +41 -34
  79. package/src/checks/automatic/role-img-alt-present.js +209 -157
  80. package/src/checks/automatic/searchbox-name-present.js +39 -19
  81. package/src/checks/automatic/server-side-image-map-absent.js +23 -8
  82. package/src/checks/automatic/slider-name-present.js +40 -47
  83. package/src/checks/automatic/spinbutton-name-present.js +39 -19
  84. package/src/checks/automatic/summary-name-present.js +37 -17
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
  86. package/src/checks/automatic/svg-text-alternative-present.js +246 -226
  87. package/src/checks/automatic/tab-name-present.js +37 -60
  88. package/src/checks/automatic/table-headers-attr-valid.js +24 -8
  89. package/src/checks/automatic/table-th-has-data-cells.js +22 -8
  90. package/src/checks/automatic/target-size-minimum.js +118 -48
  91. package/src/checks/automatic/td-has-header.js +29 -11
  92. package/src/checks/automatic/textbox-name-present.js +39 -19
  93. package/src/checks/automatic/tooltip-name-present.js +37 -18
  94. package/src/checks/automatic/treeitem-name-present.js +38 -21
  95. package/src/checks/automatic/valid-lang.js +20 -6
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
  97. package/src/checks/manual/accesskeys-manual.js +14 -5
  98. package/src/checks/manual/area-alt-decorative-manual.js +192 -193
  99. package/src/checks/manual/area-alt-quality-manual.js +182 -141
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
  101. package/src/checks/manual/aria-text-manual.js +14 -6
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
  103. package/src/checks/manual/css-hidden-focus.js +196 -165
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
  105. package/src/checks/manual/empty-heading-manual.js +24 -12
  106. package/src/checks/manual/empty-table-header-manual.js +21 -14
  107. package/src/checks/manual/focus-order-semantics-manual.js +45 -10
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
  109. package/src/checks/manual/heading-order-manual.js +22 -12
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +17 -7
  112. package/src/checks/manual/img-alt-decorative-manual.js +131 -96
  113. package/src/checks/manual/img-alt-quality-manual.js +176 -127
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
  115. package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
  116. package/src/checks/manual/label-title-only-manual.js +14 -5
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -29
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +82 -29
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +64 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +54 -23
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +54 -23
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
  123. package/src/checks/manual/landmark-one-main-manual.js +35 -21
  124. package/src/checks/manual/landmark-unique-manual.js +73 -45
  125. package/src/checks/manual/link-name-quality-manual.js +43 -12
  126. package/src/checks/manual/media-transcript-present-manual.js +35 -22
  127. package/src/checks/manual/meta-viewport-large-manual.js +25 -6
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
  129. package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
  131. package/src/checks/manual/p-as-heading-manual.js +22 -7
  132. package/src/checks/manual/page-has-heading-one-manual.js +40 -23
  133. package/src/checks/manual/page-title-patterns-manual.js +87 -51
  134. package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
  135. package/src/checks/manual/region-manual.js +275 -62
  136. package/src/checks/manual/scope-attr-valid-manual.js +11 -9
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
  138. package/src/checks/manual/skip-link-manual.js +35 -12
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
  140. package/src/checks/manual/tabindex-manual.js +11 -9
  141. package/src/checks/manual/table-duplicate-name-manual.js +17 -7
  142. package/src/checks/manual/table-fake-caption-manual.js +24 -7
  143. package/src/checks/manual/video-caption-manual.js +15 -4
  144. package/src/checks/manual-review.js +56 -12
  145. package/src/core/aria-helpers.js +1128 -823
  146. package/src/core/contrast-helpers.js +1217 -1062
  147. package/src/core/dom-helpers.js +4176 -3886
  148. package/src/core/dom-runner.js +720 -596
  149. package/src/core/frame-messaging.js +189 -138
  150. package/src/core/frame-scan.js +94 -82
  151. package/src/core/rollup-composites.js +94 -102
  152. package/src/core/rule-meta.js +71 -35
  153. package/src/core.js +37939 -29121
  154. package/src/i18n/en.js +1194 -887
  155. package/src/i18n/fr.js +1136 -793
  156. package/src/policy/contracts.js +13 -13
  157. package/src/policy/resolvePolicy.js +48 -44
  158. package/src/report.js +502 -0
  159. package/src/sarif.js +175 -0
  160. package/surea11y.browser.js +36042 -0
@@ -12,652 +12,776 @@
12
12
  * normalizeRuleMeta (src/core/rule-meta.js -- used for engineOptions.customRules).
13
13
  */
14
14
 
15
- function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, RULE_IMPLS, ENGINE_TAG, SCHEMA_VERSION, COMPOSITE_RULES) {
16
- // Normalize contrast options without mutating caller-provided engineOptions.
17
- function __normalizeContrastOptions(engineOptions2) {
18
- const eo = engineOptions2 && typeof engineOptions2 === 'object' ? engineOptions2 : {};
19
- const c = (eo.contrast && typeof eo.contrast === 'object') ? eo.contrast : {};
20
- const mode = (c.mode === 'auditorAssist') ? 'auditorAssist' : 'strictConformance';
21
- const rootCanvasFallback =
22
- (typeof c.rootCanvasFallback === 'string' && c.rootCanvasFallback.trim())
23
- ? c.rootCanvasFallback.trim()
24
- : '#ffffff';
25
- return { mode, rootCanvasFallback };
26
- }
27
-
28
- const engineOptionsResolved =
29
- (engineOptions && typeof engineOptions === 'object')
30
- ? { ...engineOptions, contrast: __normalizeContrastOptions(engineOptions) }
31
- : { contrast: __normalizeContrastOptions(null) };
32
-
33
- const policy = resolvePolicy(POLICY_CONTRACTS, engineOptionsResolved);
34
-
35
- // contextSelector accepts a single selector string (which may itself be a
36
- // comma-separated selector list -- ordinary CSS union semantics) OR an
37
- // array of selector strings for scanning multiple, possibly disjoint
38
- // regions in one run (a widely-used reference engine's multi-.include()
39
- // capability has no equivalent here otherwise). Both forms resolve via querySelectorAll,
40
- // not querySelector -- previously a single string only ever scanned its
41
- // FIRST match, silently ignoring the rest if it happened to match more
42
- // than one element. That was a real gap, not a deliberate "one region
43
- // only" design: switched to querySelectorAll for both forms so
44
- // "matches this selector" actually means all matches, consistently.
45
- // Shared with frame-scan.js (same resolution used to discover which
46
- // child <iframe>/<frame> elements fall within the same scan scope).
47
- const { ctxSelector, roots } = resolveContextRoots(document, contextSelector);
48
-
49
- // Default on: opt OUT with `includeShadowDom: false`, not opt in.
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);
54
- const excludeSelectors = normalizeSelectorList(engineOptionsResolved && engineOptionsResolved.excludeSelectors);
55
-
56
- const url = pageUrl || (document.location && document.location.href) || null;
57
- const title = document.title || null;
58
- // Deterministic timestamp: only use host-provided value (no time-based logic).
59
- const timestamp =
60
- (engineOptionsResolved && typeof engineOptionsResolved.timestamp === 'string' && engineOptionsResolved.timestamp.trim())
61
- ? engineOptionsResolved.timestamp.trim()
62
- : null;
63
-
64
- // createDomHelpers()/createContrastHelpers() persist their element-keyed
65
- // caches (outerHtmlCache, selectorCache, etc.) on window.__a11ycoreSharedCache
66
- // so multiple helper instances created *within this run* can share them
67
- // deterministically. But a window/document is frequently reused across
68
- // SEPARATE runs -- e.g. Jest's jsdom environment creates one window per
69
- // test file, and mutating document.body between it() blocks is standard.
70
- // Those caches are keyed by element reference, not content, so a run that
71
- // reuses an already-cached element (document.body never changes identity)
72
- // would otherwise read stale data cached by an earlier, unrelated run on
73
- // the same window. Clearing at the start of every run keeps sharing scoped
74
- // to "this run" as intended, without leaking across runs.
15
+ /* global resolvePolicy, POLICY_CONTRACTS, resolveRuleDefI18n, ruleMatchesRunOnly,
16
+ normalizeRuleResult, normalizeLocale, createDomHelpers, normalizeSelectorList,
17
+ resolveContextRoots, normalizeRuleMeta */
18
+
19
+ function runCore(
20
+ pageUrl,
21
+ contextSelector,
22
+ engineOptions,
23
+ runOnly,
24
+ CHECK_DEFS,
25
+ RULE_IMPLS,
26
+ ENGINE_TAG,
27
+ SCHEMA_VERSION,
28
+ COMPOSITE_RULES
29
+ ) {
30
+ // Normalize contrast options without mutating caller-provided engineOptions.
31
+ function __normalizeContrastOptions(engineOptions2) {
32
+ const eo = engineOptions2 && typeof engineOptions2 === 'object' ? engineOptions2 : {};
33
+ const c = eo.contrast && typeof eo.contrast === 'object' ? eo.contrast : {};
34
+ const mode = c.mode === 'auditorAssist' ? 'auditorAssist' : 'strictConformance';
35
+ const rootCanvasFallback =
36
+ typeof c.rootCanvasFallback === 'string' && c.rootCanvasFallback.trim()
37
+ ? c.rootCanvasFallback.trim()
38
+ : '#ffffff';
39
+ return { mode, rootCanvasFallback };
40
+ }
41
+
42
+ const engineOptionsResolved =
43
+ engineOptions && typeof engineOptions === 'object'
44
+ ? { ...engineOptions, contrast: __normalizeContrastOptions(engineOptions) }
45
+ : { contrast: __normalizeContrastOptions(null) };
46
+
47
+ const policy = resolvePolicy(POLICY_CONTRACTS, engineOptionsResolved);
48
+
49
+ // contextSelector accepts a single selector string (which may itself be a
50
+ // comma-separated selector list -- ordinary CSS union semantics) OR an
51
+ // array of selector strings for scanning multiple, possibly disjoint
52
+ // regions in one run (a widely-used reference engine's multi-.include()
53
+ // capability has no equivalent here otherwise). Both forms resolve via querySelectorAll,
54
+ // not querySelector -- previously a single string only ever scanned its
55
+ // FIRST match, silently ignoring the rest if it happened to match more
56
+ // than one element. That was a real gap, not a deliberate "one region
57
+ // only" design: switched to querySelectorAll for both forms so
58
+ // "matches this selector" actually means all matches, consistently.
59
+ // Shared with frame-scan.js (same resolution used to discover which
60
+ // child <iframe>/<frame> elements fall within the same scan scope).
61
+ const { ctxSelector, roots } = resolveContextRoots(document, contextSelector);
62
+
63
+ // Default on: opt OUT with `includeShadowDom: false`, not opt in.
64
+ const includeShadowDom = !(
65
+ engineOptionsResolved && engineOptionsResolved.includeShadowDom === false
66
+ );
67
+ // Default off: hidden/collapsed content is excluded from rule evaluation
68
+ // unless the caller explicitly opts in.
69
+ const includeHiddenElements = !!(
70
+ engineOptionsResolved && engineOptionsResolved.includeHiddenElements === true
71
+ );
72
+ const excludeSelectors = normalizeSelectorList(
73
+ engineOptionsResolved && engineOptionsResolved.excludeSelectors
74
+ );
75
+ // Default off: explicit opt-in for "this scan target was never meant to
76
+ // represent a real page" -- see helpers.isWholeDocumentScope().
77
+ const fragment = !!(engineOptionsResolved && engineOptionsResolved.fragment === true);
78
+
79
+ const url = pageUrl || (document.location && document.location.href) || null;
80
+ const title = document.title || null;
81
+ // Deterministic timestamp: only use host-provided value (no time-based logic).
82
+ const timestamp =
83
+ engineOptionsResolved &&
84
+ typeof engineOptionsResolved.timestamp === 'string' &&
85
+ engineOptionsResolved.timestamp.trim()
86
+ ? engineOptionsResolved.timestamp.trim()
87
+ : null;
88
+
89
+ // createDomHelpers()/createContrastHelpers() persist their element-keyed
90
+ // caches (outerHtmlCache, selectorCache, etc.) on window.__a11ycoreSharedCache
91
+ // so multiple helper instances created *within this run* can share them
92
+ // deterministically. But a window/document is frequently reused across
93
+ // SEPARATE runs -- e.g. Jest's jsdom environment creates one window per
94
+ // test file, and mutating document.body between it() blocks is standard.
95
+ // Those caches are keyed by element reference, not content, so a run that
96
+ // reuses an already-cached element (document.body never changes identity)
97
+ // would otherwise read stale data cached by an earlier, unrelated run on
98
+ // the same window. Clearing at the start of every run keeps sharing scoped
99
+ // to "this run" as intended, without leaking across runs.
100
+ try {
101
+ if (window && window.__a11ycoreSharedCache) window.__a11ycoreSharedCache = {};
102
+ } catch {}
103
+
104
+ const sharedHelpers = createDomHelpers({
105
+ document,
106
+ window,
107
+ root: roots,
108
+ includeShadowDom,
109
+ includeHiddenElements,
110
+ excludeSelectors,
111
+ fragment,
112
+ // Optional perf counters (bench/debug only). Deterministic and per-run.
113
+ perfStats: !!(engineOptionsResolved && engineOptionsResolved.perfStats)
114
+ });
115
+
116
+ const profileRules = !!(engineOptionsResolved && engineOptionsResolved.profileRules);
117
+ const ruleTimings = profileRules ? Object.create(null) : null;
118
+
119
+ function nowMs() {
120
+ // performance.now() if available, else Date.now()
75
121
  try {
76
- if (window && window.__a11ycoreSharedCache) window.__a11ycoreSharedCache = {};
77
- } catch {}
78
-
79
- const sharedHelpers = createDomHelpers({
80
- document,
81
- window,
82
- root: roots,
83
- includeShadowDom,
84
- includeHiddenElements,
85
- excludeSelectors,
86
- // Optional perf counters (bench/debug only). Deterministic and per-run.
87
- perfStats: !!(engineOptionsResolved && engineOptionsResolved.perfStats)
88
- });
89
-
90
- const profileRules = !!(engineOptionsResolved && engineOptionsResolved.profileRules);
91
- const ruleTimings = profileRules ? Object.create(null) : null;
92
-
93
- function nowMs() {
94
- // performance.now() if available, else Date.now()
95
- try {
96
- if (typeof performance !== 'undefined' && performance && typeof performance.now === 'function') {
97
- return performance.now();
98
- }
99
- } catch (e) {}
100
- return Date.now();
122
+ if (
123
+ typeof performance !== 'undefined' &&
124
+ performance &&
125
+ typeof performance.now === 'function'
126
+ ) {
127
+ return performance.now();
128
+ }
129
+ } catch (e) {}
130
+ return Date.now();
131
+ }
132
+
133
+ // =========================
134
+ // Probes (optional evidence fed by the host app)
135
+ // Keep deterministic + serializable + no-throws.
136
+ // =========================
137
+ function sanitizeProbeValue(v, depth) {
138
+ // depth-bounded, JSON-safe sanitizer
139
+ if (depth <= 0) return null;
140
+ if (v == null) return null;
141
+
142
+ const t = typeof v;
143
+ if (t === 'string') return v.length > 2000 ? v.slice(0, 2000) : v;
144
+ if (t === 'number') return Number.isFinite(v) ? v : null;
145
+ if (t === 'boolean') return v;
146
+ if (t === 'function') return null;
147
+
148
+ if (Array.isArray(v)) {
149
+ // cap arrays to avoid huge payloads
150
+ const out = [];
151
+ const n = Math.min(v.length, 200);
152
+ for (let i = 0; i < n; i++) out.push(sanitizeProbeValue(v[i], depth - 1));
153
+ return out;
101
154
  }
102
155
 
103
- // =========================
104
- // Probes (optional evidence fed by the host app)
105
- // Keep deterministic + serializable + no-throws.
106
- // =========================
107
- function sanitizeProbeValue(v, depth) {
108
- // depth-bounded, JSON-safe sanitizer
109
- if (depth <= 0) return null;
110
- if (v == null) return null;
111
-
112
- const t = typeof v;
113
- if (t === 'string') return v.length > 2000 ? v.slice(0, 2000) : v;
114
- if (t === 'number') return Number.isFinite(v) ? v : null;
115
- if (t === 'boolean') return v;
116
- if (t === 'function') return null;
117
-
118
- if (Array.isArray(v)) {
119
- // cap arrays to avoid huge payloads
120
- const out = [];
121
- const n = Math.min(v.length, 200);
122
- for (let i = 0; i < n; i++) out.push(sanitizeProbeValue(v[i], depth - 1));
123
- return out;
124
- }
156
+ if (t === 'object') {
157
+ const out = {};
158
+ const keys = Object.keys(v).sort();
159
+ // cap object keys
160
+ const n = Math.min(keys.length, 50);
161
+ for (let i = 0; i < n; i++) {
162
+ const k = keys[i];
163
+ // only allow string keys
164
+ if (typeof k !== 'string') continue;
165
+ out[k] = sanitizeProbeValue(v[k], depth - 1);
166
+ }
167
+ return out;
168
+ }
125
169
 
126
- if (t === 'object') {
127
- const out = {};
128
- const keys = Object.keys(v).sort();
129
- // cap object keys
130
- const n = Math.min(keys.length, 50);
131
- for (let i = 0; i < n; i++) {
132
- const k = keys[i];
133
- // only allow string keys
134
- if (typeof k !== 'string') continue;
135
- out[k] = sanitizeProbeValue(v[k], depth - 1);
136
- }
137
- return out;
170
+ return null;
171
+ }
172
+
173
+ let probes;
174
+ try {
175
+ const rawProbes =
176
+ engineOptionsResolved && typeof engineOptionsResolved.probes === 'object'
177
+ ? engineOptionsResolved.probes
178
+ : null;
179
+ probes = rawProbes ? sanitizeProbeValue(rawProbes, 6) : null;
180
+ if (!probes || typeof probes !== 'object' || Array.isArray(probes)) probes = null;
181
+ } catch (e) {
182
+ probes = null;
183
+ }
184
+
185
+ // =========================
186
+ // Runtime custom rules (engineOptions.customRules)
187
+ // =========================
188
+ // Same module shape as an internal rule file: { id, meta, runInPage, applicability?, data? }.
189
+ // runInPage/applicability may be a real function (fine for same-realm/Node/jsdom callers)
190
+ // or a function-source string (required for cross-realm callers, e.g. a Playwright
191
+ // page.evaluate(runa11yCoreInPage, { engineOptions }) call -- engineOptions crosses a
192
+ // structured-clone/JSON boundary there, so a live Function reference can't survive it,
193
+ // but a string can). Reconstructed via `new Function`, matching exactly how build-core.js
194
+ // already embeds each built-in rule's own runInPage source into the in-page runner.
195
+ // Scan-scoped only (not added to the static CHECK_DEFS/getRulesCatalog() catalog) --
196
+ // matches surea11y's existing "fresh engineOptions per call, no mutable global config"
197
+ // design (see ROADMAP.md), rather than a widely-used reference engine's stateful global configure().
198
+ let effectiveCheckDefs = CHECK_DEFS;
199
+ let effectiveRuleImpls = RULE_IMPLS;
200
+ let overriddenBuiltinIds = [];
201
+ const rawCustomRules = Array.isArray(engineOptionsResolved.customRules)
202
+ ? engineOptionsResolved.customRules
203
+ : [];
204
+ if (rawCustomRules.length) {
205
+ function reviveRuleFn(value) {
206
+ if (typeof value === 'function') return value;
207
+ if (typeof value === 'string' && value.trim()) {
208
+ try {
209
+ const fn = new Function('return (' + value + ')')();
210
+ if (typeof fn === 'function') return fn;
211
+ } catch (e) {
212
+ return null;
138
213
  }
139
-
140
- return null;
214
+ }
215
+ return null;
141
216
  }
142
217
 
143
- let probes = null;
144
- try {
145
- const rawProbes = engineOptionsResolved && typeof engineOptionsResolved.probes === 'object' ? engineOptionsResolved.probes : null;
146
- probes = rawProbes ? sanitizeProbeValue(rawProbes, 6) : null;
147
- if (!probes || typeof probes !== 'object' || Array.isArray(probes)) probes = null;
148
- } catch (e) {
149
- probes = null;
218
+ const extraDefsById = new Map();
219
+ const extraImpls = {};
220
+ for (const c of rawCustomRules) {
221
+ if (!c || typeof c !== 'object') continue;
222
+ const ruleId = typeof c.id === 'string' ? c.id.trim() : '';
223
+ if (!ruleId) continue;
224
+ const runFn = reviveRuleFn(c.runInPage);
225
+ if (typeof runFn !== 'function') continue; // invalid custom rule: skipped, not a crash
226
+
227
+ const applicabilityFn = reviveRuleFn(c.applicability);
228
+ const normalizedMeta = normalizeRuleMeta(ruleId, ruleId, c.meta, ENGINE_TAG);
229
+
230
+ // Overriding a built-in rule id is supported (see docs/ENGINE_OPTIONS.md),
231
+ // but a same-named custom rule is just as likely to be an accidental
232
+ // collision (a generic name like "region" or "tabindex" picked without
233
+ // realizing it's already a built-in id) as a deliberate override -- so
234
+ // surface it either way rather than silently swapping the rule out.
235
+ if (CHECK_DEFS.some((d) => d && d.ruleId === ruleId)) {
236
+ overriddenBuiltinIds.push(ruleId);
237
+ }
238
+
239
+ extraDefsById.set(ruleId, {
240
+ ruleId,
241
+ title: normalizedMeta.title,
242
+ description: normalizedMeta.description,
243
+ i18n: normalizedMeta.i18n,
244
+ helpUrl: normalizedMeta.helpUrl,
245
+ tags: normalizedMeta.tags,
246
+ wcagSc: normalizedMeta.wcagSc,
247
+ normativeMappings: normalizedMeta.normativeMappings,
248
+ defaultSeverity: normalizedMeta.defaultSeverity,
249
+ defaultConfidence: normalizedMeta.defaultConfidence,
250
+ type: normalizedMeta.type,
251
+ coverage: normalizedMeta.coverage,
252
+ data: c.data === undefined ? null : c.data,
253
+ ruleInterfaceVersion: normalizedMeta.ruleInterfaceVersion,
254
+ ruleVersion: normalizedMeta.ruleVersion,
255
+ normative: normalizedMeta.normative,
256
+ atomic: normalizedMeta.atomic,
257
+ deprecated: normalizedMeta.deprecated,
258
+ deprecation: normalizedMeta.deprecation,
259
+ category: normalizedMeta.category,
260
+ standard: normalizedMeta.standard,
261
+ applicability: normalizedMeta.applicability,
262
+ expectation: normalizedMeta.expectation,
263
+ references: normalizedMeta.references,
264
+ requirements: normalizedMeta.requirements,
265
+ mappings: normalizedMeta.mappings
266
+ });
267
+ extraImpls[ruleId] = { run: runFn, applicability: applicabilityFn || null };
150
268
  }
151
269
 
152
- // =========================
153
- // Runtime custom rules (engineOptions.customRules)
154
- // =========================
155
- // Same module shape as an internal rule file: { id, meta, runInPage, applicability?, data? }.
156
- // runInPage/applicability may be a real function (fine for same-realm/Node/jsdom callers)
157
- // or a function-source string (required for cross-realm callers, e.g. a Playwright
158
- // page.evaluate(runa11yCoreInPage, { engineOptions }) call -- engineOptions crosses a
159
- // structured-clone/JSON boundary there, so a live Function reference can't survive it,
160
- // but a string can). Reconstructed via `new Function`, matching exactly how build-core.js
161
- // already embeds each built-in rule's own runInPage source into the in-page runner.
162
- // Scan-scoped only (not added to the static CHECK_DEFS/getRulesCatalog() catalog) --
163
- // matches surea11y's existing "fresh engineOptions per call, no mutable global config"
164
- // design (see ROADMAP.md), rather than a widely-used reference engine's stateful global configure().
165
- let effectiveCheckDefs = CHECK_DEFS;
166
- let effectiveRuleImpls = RULE_IMPLS;
167
- let overriddenBuiltinIds = [];
168
- const rawCustomRules = Array.isArray(engineOptionsResolved.customRules) ? engineOptionsResolved.customRules : [];
169
- if (rawCustomRules.length) {
170
- function reviveRuleFn(value) {
171
- if (typeof value === 'function') return value;
172
- if (typeof value === 'string' && value.trim()) {
173
- try {
174
- const fn = new Function('return (' + value + ')')();
175
- if (typeof fn === 'function') return fn;
176
- } catch (e) {
177
- return null;
178
- }
179
- }
180
- return null;
181
- }
182
-
183
- const extraDefsById = new Map();
184
- const extraImpls = {};
185
- for (const c of rawCustomRules) {
186
- if (!c || typeof c !== 'object') continue;
187
- const ruleId = typeof c.id === 'string' ? c.id.trim() : '';
188
- if (!ruleId) continue;
189
- const runFn = reviveRuleFn(c.runInPage);
190
- if (typeof runFn !== 'function') continue; // invalid custom rule: skipped, not a crash
191
-
192
- const applicabilityFn = reviveRuleFn(c.applicability);
193
- const normalizedMeta = normalizeRuleMeta(ruleId, ruleId, c.meta, ENGINE_TAG);
194
-
195
- // Overriding a built-in rule id is supported (see docs/ENGINE_OPTIONS.md),
196
- // but a same-named custom rule is just as likely to be an accidental
197
- // collision (a generic name like "region" or "tabindex" picked without
198
- // realizing it's already a built-in id) as a deliberate override -- so
199
- // surface it either way rather than silently swapping the rule out.
200
- if (CHECK_DEFS.some((d) => d && d.ruleId === ruleId)) {
201
- overriddenBuiltinIds.push(ruleId);
202
- }
203
-
204
- extraDefsById.set(ruleId, {
205
- ruleId,
206
- title: normalizedMeta.title,
207
- description: normalizedMeta.description,
208
- i18n: normalizedMeta.i18n,
209
- helpUrl: normalizedMeta.helpUrl,
210
- tags: normalizedMeta.tags,
211
- wcagSc: normalizedMeta.wcagSc,
212
- normativeMappings: normalizedMeta.normativeMappings,
213
- defaultSeverity: normalizedMeta.defaultSeverity,
214
- defaultConfidence: normalizedMeta.defaultConfidence,
215
- type: normalizedMeta.type,
216
- coverage: normalizedMeta.coverage,
217
- data: (c.data === undefined ? null : c.data),
218
- ruleInterfaceVersion: normalizedMeta.ruleInterfaceVersion,
219
- ruleVersion: normalizedMeta.ruleVersion,
220
- normative: normalizedMeta.normative,
221
- atomic: normalizedMeta.atomic,
222
- category: normalizedMeta.category,
223
- standard: normalizedMeta.standard,
224
- applicability: normalizedMeta.applicability,
225
- expectation: normalizedMeta.expectation,
226
- references: normalizedMeta.references,
227
- requirements: normalizedMeta.requirements,
228
- mappings: normalizedMeta.mappings
229
- });
230
- extraImpls[ruleId] = { run: runFn, applicability: applicabilityFn || null };
231
- }
232
-
233
- if (extraDefsById.size) {
234
- effectiveCheckDefs = CHECK_DEFS
235
- .filter((d) => !extraDefsById.has(d.ruleId))
236
- .concat(Array.from(extraDefsById.values()));
237
- effectiveRuleImpls = { ...RULE_IMPLS, ...extraImpls };
238
- }
239
-
240
- if (overriddenBuiltinIds.length) {
241
- try {
242
- console.warn('[surea11y] customRules overriding built-in rule id(s) for this scan: ' + overriddenBuiltinIds.join(', '));
243
- } catch (e) {}
244
- }
270
+ if (extraDefsById.size) {
271
+ effectiveCheckDefs = CHECK_DEFS.filter((d) => !extraDefsById.has(d.ruleId)).concat(
272
+ Array.from(extraDefsById.values())
273
+ );
274
+ effectiveRuleImpls = { ...RULE_IMPLS, ...extraImpls };
245
275
  }
246
276
 
247
- const checksResults = [];
248
-
249
- for (const def of effectiveCheckDefs) {
250
- const t0 = ruleTimings ? nowMs() : 0;
251
- const defResolved = resolveRuleDefI18n(def, engineOptionsResolved);
252
- if (!ruleMatchesRunOnly(defResolved, runOnly, ENGINE_TAG)) continue;
253
-
254
- const implEntry = effectiveRuleImpls[defResolved.ruleId];
255
- const impl = implEntry && typeof implEntry.run === 'function' ? implEntry.run : null;
256
- const applicabilityFn = implEntry && typeof implEntry.applicability === 'function' ? implEntry.applicability : null;
257
- if (typeof impl !== 'function') continue;
258
-
259
- const ruleConfig =
260
- engineOptionsResolved && engineOptionsResolved.rules && engineOptionsResolved.rules[defResolved.ruleId]
261
- ? engineOptionsResolved.rules[defResolved.ruleId]
262
- : null;
263
-
264
- // Rule-scoped excludeSelectors (engineOptions.rules[ruleId].excludeSelectors)
265
- // apply on top of the global excludeSelectors for exactly this rule's
266
- // applicability check + run, then are cleared once this rule is done.
267
- // Safe because rule execution below is synchronous and one rule at a
268
- // time -- sharedHelpers is reused across all rules in this loop.
269
- if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
270
- sharedHelpers.__setActiveRuleExcludeSelectors(ruleConfig && ruleConfig.excludeSelectors);
271
- }
272
-
273
- const ctx = {
274
- document,
275
- window,
276
- root: roots,
277
- rule: defResolved,
278
- config: ruleConfig,
279
- helpers: sharedHelpers,
280
- engineTag: ENGINE_TAG,
281
- contextSelector: ctxSelector,
282
- engineOptions: (engineOptionsResolved && typeof engineOptionsResolved === 'object') ? engineOptionsResolved : {},
283
-
284
- // Optional evidence channel provided by host app
285
- inputs: {
286
- probes
287
- }
288
- };
289
-
290
- if (typeof applicabilityFn === 'function') {
291
- let applicable = true;
292
- try {
293
- const res = applicabilityFn(ctx);
294
- if (typeof res === 'boolean') applicable = res;
295
- else if (res && typeof res === 'object' && typeof res.applicable === 'boolean') applicable = res.applicable;
296
- } catch (err) {
297
- const raw = {
298
- outcome: 'cantTell',
299
- occurrences: [],
300
- error: String(err && err.message ? err.message : err),
301
- engineOptions: { ...(ctx.engineOptions || {}), locale: normalizeLocale(engineOptionsResolved && engineOptionsResolved.locale) }
302
- };
303
- checksResults.push(normalizeRuleResult(defResolved, raw, SCHEMA_VERSION, policy, sharedHelpers));
304
- if (ruleTimings) ruleTimings[defResolved.ruleId] = (ruleTimings[defResolved.ruleId] || 0) + (nowMs() - t0);
305
- continue;
306
- }
307
-
308
- if (!applicable) {
309
- const raw = {
310
- outcome: 'notApplicable',
311
- occurrences: [],
312
- engineOptions: { ...(ctx.engineOptions || {}), locale: normalizeLocale(engineOptionsResolved && engineOptionsResolved.locale) }
313
- };
314
- checksResults.push(normalizeRuleResult(defResolved, raw, SCHEMA_VERSION, policy, sharedHelpers));
315
- if (ruleTimings) ruleTimings[defResolved.ruleId] = (ruleTimings[defResolved.ruleId] || 0) + (nowMs() - t0);
316
- continue;
317
- }
318
- }
319
-
320
- let result;
321
- try {
322
- result = impl(ctx);
323
- } catch (err) {
324
- result = {
325
- outcome: 'cantTell',
326
- occurrences: [],
327
- error: String(err && err.message ? err.message : err),
328
- engineOptions: { ...(ctx.engineOptions || {}), locale: normalizeLocale(engineOptionsResolved && engineOptionsResolved.locale) }
329
- };
330
- }
331
-
332
- if (!result || typeof result !== 'object') {
333
- if (ruleTimings) ruleTimings[defResolved.ruleId] = (ruleTimings[defResolved.ruleId] || 0) + (nowMs() - t0);
334
- continue;
335
- }
336
- if (!result.engineOptions) {
337
- result.engineOptions = { ...(ctx.engineOptions || {}), locale: normalizeLocale(engineOptionsResolved && engineOptionsResolved.locale) };
338
- }
339
- checksResults.push(normalizeRuleResult(defResolved, result, SCHEMA_VERSION, policy, sharedHelpers));
340
- if (ruleTimings) ruleTimings[defResolved.ruleId] = (ruleTimings[defResolved.ruleId] || 0) + (nowMs() - t0);
277
+ if (overriddenBuiltinIds.length) {
278
+ try {
279
+ console.warn(
280
+ '[surea11y] customRules overriding built-in rule id(s) for this scan: ' +
281
+ overriddenBuiltinIds.join(', ')
282
+ );
283
+ } catch (e) {}
341
284
  }
342
-
343
- // Composite rollups below carry no occurrences/nodes of their own, so
344
- // they never exercise rule-scoped excludes -- but clear the "active
345
- // rule" state on sharedHelpers regardless, so nothing after this point
346
- // (composite aggregation, perf stats) can observe a stale rule's excludes.
285
+ }
286
+
287
+ const checksResults = [];
288
+
289
+ for (const def of effectiveCheckDefs) {
290
+ const t0 = ruleTimings ? nowMs() : 0;
291
+ const defResolved = resolveRuleDefI18n(def, engineOptionsResolved);
292
+ if (!ruleMatchesRunOnly(defResolved, runOnly, ENGINE_TAG)) continue;
293
+
294
+ const implEntry = effectiveRuleImpls[defResolved.ruleId];
295
+ const impl = implEntry && typeof implEntry.run === 'function' ? implEntry.run : null;
296
+ const applicabilityFn =
297
+ implEntry && typeof implEntry.applicability === 'function' ? implEntry.applicability : null;
298
+ if (typeof impl !== 'function') continue;
299
+
300
+ const ruleConfig =
301
+ engineOptionsResolved &&
302
+ engineOptionsResolved.rules &&
303
+ engineOptionsResolved.rules[defResolved.ruleId]
304
+ ? engineOptionsResolved.rules[defResolved.ruleId]
305
+ : null;
306
+
307
+ // Rule-scoped excludeSelectors (engineOptions.rules[ruleId].excludeSelectors)
308
+ // apply on top of the global excludeSelectors for exactly this rule's
309
+ // applicability check + run, then are cleared once this rule is done.
310
+ // Safe because rule execution below is synchronous and one rule at a
311
+ // time -- sharedHelpers is reused across all rules in this loop.
347
312
  if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
348
- sharedHelpers.__setActiveRuleExcludeSelectors(null);
313
+ sharedHelpers.__setActiveRuleExcludeSelectors(ruleConfig && ruleConfig.excludeSelectors);
349
314
  }
350
315
 
351
- // =========================
352
- // Composite rule aggregation (data-only rollups)
353
- // =========================
354
- const rulesResults = [];
355
- try {
356
- const composites = Array.isArray(COMPOSITE_RULES) ? COMPOSITE_RULES : [];
357
-
358
- // Determine target conformance level from runOnly.tags (already normalized by caller)
359
- const LEVEL_RANK = { A: 1, AA: 2, AAA: 3 };
360
-
361
- function inferTargetLevelFromRunOnly(runOnly2) {
362
- const tags = runOnly2 && Array.isArray(runOnly2.tags) ? runOnly2.tags : [];
363
- // tags are already lowercase
364
- if (tags.includes('wcag2aaa') || tags.includes('wcag22aaa') || tags.includes('wcag21aaa')) return 'AAA';
365
- if (tags.includes('wcag2aa') || tags.includes('wcag22aa') || tags.includes('wcag21aa')) return 'AA';
366
- if (tags.includes('wcag2a') || tags.includes('wcag22a') || tags.includes('wcag21a')) return 'A';
367
- return null; // if not specified, don't filter composites (back-compat)
368
- }
316
+ const ctx = {
317
+ document,
318
+ window,
319
+ root: roots,
320
+ rule: defResolved,
321
+ config: ruleConfig,
322
+ helpers: sharedHelpers,
323
+ engineTag: ENGINE_TAG,
324
+ contextSelector: ctxSelector,
325
+ engineOptions:
326
+ engineOptionsResolved && typeof engineOptionsResolved === 'object'
327
+ ? engineOptionsResolved
328
+ : {},
329
+
330
+ // Optional evidence channel provided by host app
331
+ inputs: {
332
+ probes
333
+ }
334
+ };
369
335
 
370
- function normalizeLevel(s) {
371
- const v = typeof s === 'string' ? s.trim().toUpperCase() : '';
372
- return (v === 'A' || v === 'AA' || v === 'AAA') ? v : null;
373
- }
336
+ if (typeof applicabilityFn === 'function') {
337
+ let applicable = true;
338
+ try {
339
+ const res = applicabilityFn(ctx);
340
+ if (typeof res === 'boolean') applicable = res;
341
+ else if (res && typeof res === 'object' && typeof res.applicable === 'boolean')
342
+ applicable = res.applicable;
343
+ } catch (err) {
344
+ const raw = {
345
+ outcome: 'cantTell',
346
+ occurrences: [],
347
+ error: String(err && err.message ? err.message : err),
348
+ engineOptions: {
349
+ ...(ctx.engineOptions || {}),
350
+ locale: normalizeLocale(engineOptionsResolved && engineOptionsResolved.locale)
351
+ }
352
+ };
353
+ checksResults.push(
354
+ normalizeRuleResult(defResolved, raw, SCHEMA_VERSION, policy, sharedHelpers)
355
+ );
356
+ if (ruleTimings)
357
+ ruleTimings[defResolved.ruleId] = (ruleTimings[defResolved.ruleId] || 0) + (nowMs() - t0);
358
+ continue;
359
+ }
360
+
361
+ if (!applicable) {
362
+ const raw = {
363
+ outcome: 'notApplicable',
364
+ occurrences: [],
365
+ engineOptions: {
366
+ ...(ctx.engineOptions || {}),
367
+ locale: normalizeLocale(engineOptionsResolved && engineOptionsResolved.locale)
368
+ }
369
+ };
370
+ checksResults.push(
371
+ normalizeRuleResult(defResolved, raw, SCHEMA_VERSION, policy, sharedHelpers)
372
+ );
373
+ if (ruleTimings)
374
+ ruleTimings[defResolved.ruleId] = (ruleTimings[defResolved.ruleId] || 0) + (nowMs() - t0);
375
+ continue;
376
+ }
377
+ }
374
378
 
375
- function isAllowedByTargetLevel(compositeLevel, targetLevel) {
376
- if (!targetLevel) return true;
377
- const c = LEVEL_RANK[compositeLevel];
378
- const t = LEVEL_RANK[targetLevel];
379
- if (!c || !t) return false; // unknown level => safest: exclude
380
- return c <= t;
379
+ let result;
380
+ try {
381
+ result = impl(ctx);
382
+ } catch (err) {
383
+ result = {
384
+ outcome: 'cantTell',
385
+ occurrences: [],
386
+ error: String(err && err.message ? err.message : err),
387
+ engineOptions: {
388
+ ...(ctx.engineOptions || {}),
389
+ locale: normalizeLocale(engineOptionsResolved && engineOptionsResolved.locale)
381
390
  }
391
+ };
392
+ }
382
393
 
383
- const targetLevel = inferTargetLevelFromRunOnly(runOnly);
394
+ if (!result || typeof result !== 'object') {
395
+ if (ruleTimings)
396
+ ruleTimings[defResolved.ruleId] = (ruleTimings[defResolved.ruleId] || 0) + (nowMs() - t0);
397
+ continue;
398
+ }
399
+ if (!result.engineOptions) {
400
+ result.engineOptions = {
401
+ ...(ctx.engineOptions || {}),
402
+ locale: normalizeLocale(engineOptionsResolved && engineOptionsResolved.locale)
403
+ };
404
+ }
405
+ checksResults.push(
406
+ normalizeRuleResult(defResolved, result, SCHEMA_VERSION, policy, sharedHelpers)
407
+ );
408
+ if (ruleTimings)
409
+ ruleTimings[defResolved.ruleId] = (ruleTimings[defResolved.ruleId] || 0) + (nowMs() - t0);
410
+ }
411
+
412
+ // Composite rollups below carry no occurrences/nodes of their own, so
413
+ // they never exercise rule-scoped excludes -- but clear the "active
414
+ // rule" state on sharedHelpers regardless, so nothing after this point
415
+ // (composite aggregation, perf stats) can observe a stale rule's excludes.
416
+ if (typeof sharedHelpers.__setActiveRuleExcludeSelectors === 'function') {
417
+ sharedHelpers.__setActiveRuleExcludeSelectors(null);
418
+ }
419
+
420
+ // =========================
421
+ // Composite rule aggregation (data-only rollups)
422
+ // =========================
423
+ const rulesResults = [];
424
+ try {
425
+ const composites = Array.isArray(COMPOSITE_RULES) ? COMPOSITE_RULES : [];
426
+
427
+ // Determine target conformance level from runOnly.tags (already normalized by caller)
428
+ const LEVEL_RANK = { A: 1, AA: 2, AAA: 3 };
429
+
430
+ function inferTargetLevelFromRunOnly(runOnly2) {
431
+ const tags = runOnly2 && Array.isArray(runOnly2.tags) ? runOnly2.tags : [];
432
+ // tags are already lowercase
433
+ if (tags.includes('wcag2aaa') || tags.includes('wcag22aaa') || tags.includes('wcag21aaa'))
434
+ return 'AAA';
435
+ if (tags.includes('wcag2aa') || tags.includes('wcag22aa') || tags.includes('wcag21aa'))
436
+ return 'AA';
437
+ if (tags.includes('wcag2a') || tags.includes('wcag22a') || tags.includes('wcag21a'))
438
+ return 'A';
439
+ return null; // if not specified, don't filter composites (back-compat)
440
+ }
384
441
 
385
- // Severity rollup (deterministic)
386
- const SEVERITY_RANK = { minor: 1, moderate: 2, serious: 3, critical: 4 };
442
+ function normalizeLevel(s) {
443
+ const v = typeof s === 'string' ? s.trim().toUpperCase() : '';
444
+ return v === 'A' || v === 'AA' || v === 'AAA' ? v : null;
445
+ }
387
446
 
388
- function normalizeSeverity(s) {
389
- const v = typeof s === 'string' ? s.trim().toLowerCase() : '';
390
- return SEVERITY_RANK[v] ? v : null;
391
- }
447
+ function isAllowedByTargetLevel(compositeLevel, targetLevel) {
448
+ if (!targetLevel) return true;
449
+ const c = LEVEL_RANK[compositeLevel];
450
+ const t = LEVEL_RANK[targetLevel];
451
+ if (!c || !t) return false; // unknown level => safest: exclude
452
+ return c <= t;
453
+ }
392
454
 
393
- function maxSeverity(a, b) {
394
- if (!a) return b || null;
395
- if (!b) return a || null;
396
- return (SEVERITY_RANK[b] > SEVERITY_RANK[a]) ? b : a;
397
- }
455
+ const targetLevel = inferTargetLevelFromRunOnly(runOnly);
398
456
 
399
- // Index atomic results by ruleId (deterministic)
400
- const byRuleId = Object.create(null);
401
- for (const rr of checksResults) {
402
- if (rr && typeof rr === 'object' && typeof rr.ruleId === 'string' && rr.ruleId) {
403
- byRuleId[rr.ruleId] = rr;
404
- }
405
- }
457
+ // Severity rollup (deterministic)
458
+ const SEVERITY_RANK = { minor: 1, moderate: 2, serious: 3, critical: 4 };
406
459
 
407
- function isNonEmptyString(s) {
408
- return typeof s === 'string' && !!s.trim();
409
- }
460
+ function normalizeSeverity(s) {
461
+ const v = typeof s === 'string' ? s.trim().toLowerCase() : '';
462
+ return SEVERITY_RANK[v] ? v : null;
463
+ }
410
464
 
411
- function buildCompositeDef(entry) {
412
- if (!entry || typeof entry !== 'object') return null;
413
-
414
- const ruleId = isNonEmptyString(entry.id) ? entry.id.trim() : String(entry.id || '').trim();
415
- if (!ruleId) return null;
416
-
417
- const metaIn = (entry.meta && typeof entry.meta === 'object' && !Array.isArray(entry.meta)) ? entry.meta : {};
418
-
419
- const titleKey = (typeof metaIn.titleKey === 'string' && metaIn.titleKey.trim()) ? metaIn.titleKey.trim() : '';
420
- const descriptionKey = (typeof metaIn.descriptionKey === 'string' && metaIn.descriptionKey.trim()) ? metaIn.descriptionKey.trim() : '';
421
-
422
- const wcagSc = Array.isArray(metaIn.wcagSc) ? metaIn.wcagSc.map(String).map(s => s.trim()).filter(Boolean) : [];
423
-
424
- const tags = [];
425
- tags.push(String(ENGINE_TAG || 'a11ycore').toLowerCase());
426
- tags.push('composite');
427
-
428
- // Fixed WCAG-version-introduction lists (2.1 and 2.2 additions only -- every other
429
- // SC, including all pre-2.1 ones, is WCAG 2.0 baseline). Keep in sync with
430
- // src/coverage/wcag-version-map.js (the canonical copy the rule-authoring
431
- // consistency test checks against) -- this one has to stay a self-contained
432
- // literal since runCore is inlined via .toString() with no module access at runtime.
433
- const WCAG21_NEW_SCS = ['1.3.4', '1.3.5', '1.3.6', '1.4.10', '1.4.11', '1.4.12', '1.4.13', '2.1.4', '2.2.6', '2.3.3', '2.5.1', '2.5.2', '2.5.3', '2.5.4', '2.5.5', '2.5.6', '4.1.3'];
434
- const WCAG22_NEW_SCS = ['2.4.11', '2.4.12', '2.4.13', '2.5.7', '2.5.8', '3.2.6', '3.3.7', '3.3.8', '3.3.9'];
435
- const isWcag22Sc = wcagSc.some((sc) => WCAG22_NEW_SCS.includes(sc));
436
- const isWcag21Sc = !isWcag22Sc && wcagSc.some((sc) => WCAG21_NEW_SCS.includes(sc));
437
- const versionTagPrefix = isWcag22Sc ? 'wcag22' : (isWcag21Sc ? 'wcag21' : 'wcag2');
438
-
439
- const lvl = (typeof metaIn.level === 'string' ? metaIn.level.trim().toUpperCase() : '');
440
- if (lvl === 'A') {
441
- tags.push(versionTagPrefix + 'a');
442
- } else if (lvl === 'AA') {
443
- tags.push(versionTagPrefix + 'a', versionTagPrefix + 'aa');
444
- } else if (lvl === 'AAA') {
445
- tags.push(versionTagPrefix + 'a', versionTagPrefix + 'aa', versionTagPrefix + 'aaa');
446
- }
465
+ function maxSeverity(a, b) {
466
+ if (!a) return b || null;
467
+ if (!b) return a || null;
468
+ return SEVERITY_RANK[b] > SEVERITY_RANK[a] ? b : a;
469
+ }
447
470
 
448
- // Build normativeMappings so downstream consumers (like adapters) can derive WCAG SC/level
449
- const normativeMappingsFromMeta = wcagSc.map((sc) => {
450
- const m = { standard: 'WCAG', requirement: sc };
451
- if (lvl === 'A' || lvl === 'AA' || lvl === 'AAA') m.level = lvl;
452
- return m;
453
- });
454
-
455
- const checksIds =
456
- Array.isArray(entry.checksIds)
457
- ? entry.checksIds.map(String).map(s => s.trim()).filter(Boolean)
458
- : [];
459
-
460
- return {
461
- ruleId,
462
- title: metaIn.title || ruleId,
463
- description: metaIn.description || '',
464
-
465
- i18n: (titleKey || descriptionKey) ? { titleKey: titleKey || '', descriptionKey: descriptionKey || '' } : null,
466
-
467
- helpUrl: '',
468
- tags,
469
-
470
- normativeMappings: normativeMappingsFromMeta,
471
-
472
- defaultSeverity: 'serious',
473
- defaultConfidence: 'medium',
474
- type: 'automatic',
475
- coverage: null,
476
-
477
- ruleInterfaceVersion: '1.0.0',
478
- ruleVersion: '0.0.0',
479
- normative: true,
480
- atomic: false,
481
- category: null,
482
- standard: null,
483
- applicability: '',
484
- expectation: '',
485
- references: [],
486
- requirements: null,
487
- mappings: null,
488
-
489
- // optional catalog meta passthrough
490
- data: {
491
- details: {
492
- kind: 'compositeRule',
493
- wcagSc,
494
- level: (typeof metaIn.level === 'string' && metaIn.level.trim()) ? metaIn.level.trim() : null
495
- }
496
- },
497
-
498
- __checksIds: checksIds
499
- };
500
- }
471
+ // Index atomic results by ruleId (deterministic)
472
+ const byRuleId = Object.create(null);
473
+ for (const rr of checksResults) {
474
+ if (rr && typeof rr === 'object' && typeof rr.ruleId === 'string' && rr.ruleId) {
475
+ byRuleId[rr.ruleId] = rr;
476
+ }
477
+ }
501
478
 
502
- for (let i = 0; i < composites.length; i++) {
503
- const entry = composites[i];
504
- const cDef0 = buildCompositeDef(entry);
505
- if (!cDef0) continue;
479
+ function isNonEmptyString(s) {
480
+ return typeof s === 'string' && !!s.trim();
481
+ }
506
482
 
507
- // Conformance-level gate: if scan is AA, suppress AAA composites even if tags match
508
- const compositeLevel =
509
- cDef0 &&
510
- cDef0.data &&
511
- cDef0.data.details &&
512
- normalizeLevel(cDef0.data.details.level);
483
+ function buildCompositeDef(entry) {
484
+ if (!entry || typeof entry !== 'object') return null;
485
+
486
+ const ruleId = isNonEmptyString(entry.id) ? entry.id.trim() : String(entry.id || '').trim();
487
+ if (!ruleId) return null;
488
+
489
+ const metaIn =
490
+ entry.meta && typeof entry.meta === 'object' && !Array.isArray(entry.meta)
491
+ ? entry.meta
492
+ : {};
493
+
494
+ const titleKey =
495
+ typeof metaIn.titleKey === 'string' && metaIn.titleKey.trim() ? metaIn.titleKey.trim() : '';
496
+ const descriptionKey =
497
+ typeof metaIn.descriptionKey === 'string' && metaIn.descriptionKey.trim()
498
+ ? metaIn.descriptionKey.trim()
499
+ : '';
500
+
501
+ const wcagSc = Array.isArray(metaIn.wcagSc)
502
+ ? metaIn.wcagSc
503
+ .map(String)
504
+ .map((s) => s.trim())
505
+ .filter(Boolean)
506
+ : [];
507
+
508
+ const tags = [];
509
+ tags.push(String(ENGINE_TAG || 'a11ycore').toLowerCase());
510
+ tags.push('composite');
511
+
512
+ // Fixed WCAG-version-introduction lists (2.1 and 2.2 additions only -- every other
513
+ // SC, including all pre-2.1 ones, is WCAG 2.0 baseline). Keep in sync with
514
+ // src/coverage/wcag-version-map.js (the canonical copy the rule-authoring
515
+ // consistency test checks against) -- this one has to stay a self-contained
516
+ // literal since runCore is inlined via .toString() with no module access at runtime.
517
+ const WCAG21_NEW_SCS = [
518
+ '1.3.4',
519
+ '1.3.5',
520
+ '1.3.6',
521
+ '1.4.10',
522
+ '1.4.11',
523
+ '1.4.12',
524
+ '1.4.13',
525
+ '2.1.4',
526
+ '2.2.6',
527
+ '2.3.3',
528
+ '2.5.1',
529
+ '2.5.2',
530
+ '2.5.3',
531
+ '2.5.4',
532
+ '2.5.5',
533
+ '2.5.6',
534
+ '4.1.3'
535
+ ];
536
+ const WCAG22_NEW_SCS = [
537
+ '2.4.11',
538
+ '2.4.12',
539
+ '2.4.13',
540
+ '2.5.7',
541
+ '2.5.8',
542
+ '3.2.6',
543
+ '3.3.7',
544
+ '3.3.8',
545
+ '3.3.9'
546
+ ];
547
+ const isWcag22Sc = wcagSc.some((sc) => WCAG22_NEW_SCS.includes(sc));
548
+ const isWcag21Sc = !isWcag22Sc && wcagSc.some((sc) => WCAG21_NEW_SCS.includes(sc));
549
+ const versionTagPrefix = isWcag22Sc ? 'wcag22' : isWcag21Sc ? 'wcag21' : 'wcag2';
550
+
551
+ const lvl = typeof metaIn.level === 'string' ? metaIn.level.trim().toUpperCase() : '';
552
+ if (lvl === 'A') {
553
+ tags.push(versionTagPrefix + 'a');
554
+ } else if (lvl === 'AA') {
555
+ tags.push(versionTagPrefix + 'a', versionTagPrefix + 'aa');
556
+ } else if (lvl === 'AAA') {
557
+ tags.push(versionTagPrefix + 'a', versionTagPrefix + 'aa', versionTagPrefix + 'aaa');
558
+ }
559
+
560
+ // Build normativeMappings so downstream consumers (like adapters) can derive WCAG SC/level
561
+ const normativeMappingsFromMeta = wcagSc.map((sc) => {
562
+ const m = { standard: 'WCAG', requirement: sc };
563
+ if (lvl === 'A' || lvl === 'AA' || lvl === 'AAA') m.level = lvl;
564
+ return m;
565
+ });
566
+
567
+ const checksIds = Array.isArray(entry.checksIds)
568
+ ? entry.checksIds
569
+ .map(String)
570
+ .map((s) => s.trim())
571
+ .filter(Boolean)
572
+ : [];
573
+
574
+ return {
575
+ ruleId,
576
+ title: metaIn.title || ruleId,
577
+ description: metaIn.description || '',
578
+
579
+ i18n:
580
+ titleKey || descriptionKey
581
+ ? { titleKey: titleKey || '', descriptionKey: descriptionKey || '' }
582
+ : null,
583
+
584
+ helpUrl: '',
585
+ tags,
586
+
587
+ normativeMappings: normativeMappingsFromMeta,
588
+
589
+ defaultSeverity: 'serious',
590
+ defaultConfidence: 'medium',
591
+ type: 'automatic',
592
+ coverage: null,
593
+
594
+ ruleInterfaceVersion: '1.0.0',
595
+ ruleVersion: '0.0.0',
596
+ normative: true,
597
+ atomic: false,
598
+ deprecated: false,
599
+ deprecation: null,
600
+ category: null,
601
+ standard: null,
602
+ applicability: '',
603
+ expectation: '',
604
+ references: [],
605
+ requirements: null,
606
+ mappings: null,
607
+
608
+ // optional catalog meta passthrough
609
+ data: {
610
+ details: {
611
+ kind: 'compositeRule',
612
+ wcagSc,
613
+ level:
614
+ typeof metaIn.level === 'string' && metaIn.level.trim() ? metaIn.level.trim() : null
615
+ }
616
+ },
617
+
618
+ __checksIds: checksIds
619
+ };
620
+ }
513
621
 
514
- if (!isAllowedByTargetLevel(compositeLevel, targetLevel)) continue;
622
+ for (let i = 0; i < composites.length; i++) {
623
+ const entry = composites[i];
624
+ const cDef0 = buildCompositeDef(entry);
625
+ if (!cDef0) continue;
515
626
 
516
- // Localize title/description (uses def.i18n.* keys)
517
- const cDefResolved = resolveRuleDefI18n(cDef0, engineOptionsResolved);
627
+ // Conformance-level gate: if scan is AA, suppress AAA composites even if tags match
628
+ const compositeLevel =
629
+ cDef0 && cDef0.data && cDef0.data.details && normalizeLevel(cDef0.data.details.level);
518
630
 
519
- // Apply same selection logic to composites
520
- if (!ruleMatchesRunOnly(cDefResolved, runOnly, ENGINE_TAG)) continue;
631
+ if (!isAllowedByTargetLevel(compositeLevel, targetLevel)) continue;
521
632
 
522
- const checksIds = Array.isArray(cDef0.__checksIds) ? cDef0.__checksIds : [];
633
+ // Localize title/description (uses def.i18n.* keys)
634
+ const cDefResolved = resolveRuleDefI18n(cDef0, engineOptionsResolved);
523
635
 
524
- // rollup metrics (stable order)
525
- let failCount = 0;
526
- let cantTellCount = 0;
527
- let notApplicableCount = 0;
528
- let passCount = 0;
529
- let missingCount = 0;
636
+ // Apply same selection logic to composites
637
+ if (!ruleMatchesRunOnly(cDefResolved, runOnly, ENGINE_TAG)) continue;
530
638
 
531
- const contributors = [];
639
+ const checksIds = Array.isArray(cDef0.__checksIds) ? cDef0.__checksIds : [];
532
640
 
533
- let rolledFailSeverity = null; // max severity among FAIL contributors
534
- let rolledCantTellSeverity = null; // max severity among CANTTELL contributors (optional)
641
+ // rollup metrics (stable order)
642
+ let failCount = 0;
643
+ let cantTellCount = 0;
644
+ let notApplicableCount = 0;
645
+ let passCount = 0;
646
+ let missingCount = 0;
535
647
 
536
- for (let j = 0; j < checksIds.length; j++) {
537
- const tid = checksIds[j];
538
- const child = tid ? byRuleId[tid] : null;
648
+ const contributors = [];
539
649
 
540
- if (!child) {
541
- missingCount += 1;
542
- contributors.push({ testId: tid, outcome: 'missing' });
543
- continue;
544
- }
650
+ let rolledFailSeverity = null; // max severity among FAIL contributors
651
+ let rolledCantTellSeverity = null; // max severity among CANTTELL contributors (optional)
545
652
 
546
- const out = child.outcome;
547
- const childSev = normalizeSeverity(child && child.severity);
653
+ for (let j = 0; j < checksIds.length; j++) {
654
+ const tid = checksIds[j];
655
+ const child = tid ? byRuleId[tid] : null;
548
656
 
549
- contributors.push({ testId: tid, outcome: out, severity: childSev || null });
657
+ if (!child) {
658
+ missingCount += 1;
659
+ contributors.push({ testId: tid, outcome: 'missing' });
660
+ continue;
661
+ }
550
662
 
551
- if (out === 'fail' && childSev) {
552
- rolledFailSeverity = maxSeverity(rolledFailSeverity, childSev);
553
- } else if (out === 'cantTell' && childSev) {
554
- rolledCantTellSeverity = maxSeverity(rolledCantTellSeverity, childSev);
555
- }
663
+ const out = child.outcome;
664
+ const childSev = normalizeSeverity(child && child.severity);
556
665
 
557
- if (out === 'fail') failCount += 1;
558
- else if (out === 'cantTell') cantTellCount += 1;
559
- else if (out === 'notApplicable') notApplicableCount += 1;
560
- else if (out === 'pass') passCount += 1;
561
- }
666
+ contributors.push({ testId: tid, outcome: out, severity: childSev || null });
562
667
 
563
- // outcome precedence:
564
- // fail if any fail
565
- // cantTell if any cantTell OR missing and none fail
566
- // notApplicable if all notApplicable (and there is at least one test)
567
- // pass otherwise
568
- let outcome = 'pass';
569
- let reasonCode = 'composite.rollup.pass.otherwise';
570
-
571
- if (failCount > 0) {
572
- outcome = 'fail';
573
- reasonCode = 'composite.rollup.fail.anyFail';
574
- } else if (cantTellCount > 0) {
575
- outcome = 'cantTell';
576
- reasonCode = 'composite.rollup.cantTell.anyCantTell';
577
- } else if (missingCount > 0) {
578
- outcome = 'cantTell';
579
- reasonCode = 'composite.rollup.cantTell.missingChild';
580
- } else if (checksIds.length > 0 && notApplicableCount === checksIds.length) {
581
- outcome = 'notApplicable';
582
- reasonCode = 'composite.rollup.notApplicable.allInapplicable';
583
- } else if (checksIds.length === 0) {
584
- outcome = 'cantTell';
585
- reasonCode = 'composite.rollup.cantTell.emptyComposite';
586
- }
668
+ if (out === 'fail' && childSev) {
669
+ rolledFailSeverity = maxSeverity(rolledFailSeverity, childSev);
670
+ } else if (out === 'cantTell' && childSev) {
671
+ rolledCantTellSeverity = maxSeverity(rolledCantTellSeverity, childSev);
672
+ }
587
673
 
588
- const raw = {
589
- outcome,
590
- occurrences: [],
591
-
592
- // REQUIRED by your reporting schema (top-level)
593
- summaryKey: 'Composite rule rollup',
594
- i18nKey: 'composite_rollup_summary',
595
- i18nParams: { reasonCode, testCount: String(checksIds.length) },
596
-
597
- // REQUIRED by your reporting schema (machine-readable payload)
598
- data: {
599
- details: {
600
- reasonCode,
601
- checksIds: checksIds.slice(),
602
- contributors,
603
- metrics: {
604
- failCount,
605
- cantTellCount,
606
- notApplicableCount,
607
- passCount,
608
- missingCount
609
- }
610
- }
611
- },
612
-
613
- engineOptions: {
614
- ...(engineOptionsResolved || {}),
615
- locale: normalizeLocale(engineOptionsResolved && engineOptionsResolved.locale)
616
- }
617
- };
618
-
619
- // Promote composite severity based on contributors (deterministic).
620
- // - If composite fails: use max severity among failing children.
621
- // - If composite cantTell: use max severity among cantTell children (fallback to failing if you prefer).
622
- if (outcome === 'fail' && rolledFailSeverity) {
623
- raw.severity = rolledFailSeverity;
624
- } else if (outcome === 'cantTell' && rolledCantTellSeverity) {
625
- raw.severity = rolledCantTellSeverity;
674
+ if (out === 'fail') failCount += 1;
675
+ else if (out === 'cantTell') cantTellCount += 1;
676
+ else if (out === 'notApplicable') notApplicableCount += 1;
677
+ else if (out === 'pass') passCount += 1;
678
+ }
679
+
680
+ // outcome precedence:
681
+ // fail if any fail
682
+ // cantTell if any cantTell OR missing and none fail
683
+ // notApplicable if all notApplicable (and there is at least one test)
684
+ // pass otherwise
685
+ let outcome = 'pass';
686
+ let reasonCode = 'composite.rollup.pass.otherwise';
687
+
688
+ if (failCount > 0) {
689
+ outcome = 'fail';
690
+ reasonCode = 'composite.rollup.fail.anyFail';
691
+ } else if (cantTellCount > 0) {
692
+ outcome = 'cantTell';
693
+ reasonCode = 'composite.rollup.cantTell.anyCantTell';
694
+ } else if (missingCount > 0) {
695
+ outcome = 'cantTell';
696
+ reasonCode = 'composite.rollup.cantTell.missingChild';
697
+ } else if (checksIds.length > 0 && notApplicableCount === checksIds.length) {
698
+ outcome = 'notApplicable';
699
+ reasonCode = 'composite.rollup.notApplicable.allInapplicable';
700
+ } else if (checksIds.length === 0) {
701
+ outcome = 'cantTell';
702
+ reasonCode = 'composite.rollup.cantTell.emptyComposite';
703
+ }
704
+
705
+ const raw = {
706
+ outcome,
707
+ occurrences: [],
708
+
709
+ // REQUIRED by your reporting schema (top-level)
710
+ summaryKey: 'Composite rule rollup',
711
+ i18nKey: 'composite_rollup_summary',
712
+ i18nParams: { reasonCode, testCount: String(checksIds.length) },
713
+
714
+ // REQUIRED by your reporting schema (machine-readable payload)
715
+ data: {
716
+ details: {
717
+ reasonCode,
718
+ checksIds: checksIds.slice(),
719
+ contributors,
720
+ metrics: {
721
+ failCount,
722
+ cantTellCount,
723
+ notApplicableCount,
724
+ passCount,
725
+ missingCount
626
726
  }
727
+ }
728
+ },
627
729
 
628
- rulesResults.push(normalizeRuleResult(cDefResolved, raw, SCHEMA_VERSION, policy, sharedHelpers));
730
+ engineOptions: {
731
+ ...(engineOptionsResolved || {}),
732
+ locale: normalizeLocale(engineOptionsResolved && engineOptionsResolved.locale)
629
733
  }
630
- } catch (e) {
631
- // no-throws: omit rulesResults if anything goes wrong
734
+ };
735
+
736
+ // Promote composite severity based on contributors (deterministic).
737
+ // - If composite fails: use max severity among failing children.
738
+ // - If composite cantTell: use max severity among cantTell children (fallback to failing if you prefer).
739
+ if (outcome === 'fail' && rolledFailSeverity) {
740
+ raw.severity = rolledFailSeverity;
741
+ } else if (outcome === 'cantTell' && rolledCantTellSeverity) {
742
+ raw.severity = rolledCantTellSeverity;
743
+ }
744
+
745
+ rulesResults.push(
746
+ normalizeRuleResult(cDefResolved, raw, SCHEMA_VERSION, policy, sharedHelpers)
747
+ );
632
748
  }
633
-
634
- // Optional perf counters passthrough (only when enabled). Deterministic.
635
- let perfStats = null;
636
- try {
637
- if (engineOptionsResolved && engineOptionsResolved.perfStats && sharedHelpers && typeof sharedHelpers.getPerfStats === 'function') {
638
- perfStats = sharedHelpers.getPerfStats();
639
- }
640
- } catch (e) {
641
- perfStats = null;
749
+ } catch (e) {
750
+ // no-throws: omit rulesResults if anything goes wrong
751
+ }
752
+
753
+ // Optional perf counters passthrough (only when enabled). Deterministic.
754
+ let perfStats = null;
755
+ try {
756
+ if (
757
+ engineOptionsResolved &&
758
+ engineOptionsResolved.perfStats &&
759
+ sharedHelpers &&
760
+ typeof sharedHelpers.getPerfStats === 'function'
761
+ ) {
762
+ perfStats = sharedHelpers.getPerfStats();
642
763
  }
764
+ } catch (e) {
765
+ perfStats = null;
766
+ }
643
767
 
644
- if (ruleTimings) {
645
- if (perfStats && engineOptionsResolved && engineOptionsResolved.profileRules) {
646
- perfStats.ruleTimings = ruleTimings; // (whatever your timing map is)
647
- }
768
+ if (ruleTimings) {
769
+ if (perfStats && engineOptionsResolved && engineOptionsResolved.profileRules) {
770
+ perfStats.ruleTimings = ruleTimings; // (whatever your timing map is)
648
771
  }
649
-
650
- return {
651
- engine: { tag: ENGINE_TAG, schemaVersion: SCHEMA_VERSION },
652
- url,
653
- title,
654
- timestamp,
655
- perfStats,
656
- contextSelector: ctxSelector,
657
- checksResults,
658
- rulesResults,
659
- overriddenBuiltinIds
660
- };
772
+ }
773
+
774
+ return {
775
+ engine: { tag: ENGINE_TAG, schemaVersion: SCHEMA_VERSION },
776
+ url,
777
+ title,
778
+ timestamp,
779
+ perfStats,
780
+ contextSelector: ctxSelector,
781
+ checksResults,
782
+ rulesResults,
783
+ overriddenBuiltinIds
784
+ };
661
785
  }
662
786
 
663
787
  module.exports = { runCore };