@surea11y/core 1.2.0 → 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 (157) hide show
  1. package/CHANGELOG.md +37 -7
  2. package/LICENSE +373 -21
  3. package/README.md +67 -1
  4. package/bin/core.js +144 -19
  5. package/docs/API_STABILITY.md +1 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/CLI.md +54 -1
  9. package/docs/ENGINE_OPTIONS.md +2 -0
  10. package/docs/INTEGRATION.md +18 -0
  11. package/docs/OUTPUT_SCHEMA.md +1 -1
  12. package/docs/RULE_CATALOG.md +1 -1
  13. package/docs/SARIF.md +59 -0
  14. package/package.json +15 -4
  15. package/src/baseline.js +0 -0
  16. package/src/catalogs/composites.wcag.js +414 -450
  17. package/src/checks/automatic/area-alt-present.js +59 -25
  18. package/src/checks/automatic/aria-allowed-attr.js +193 -33
  19. package/src/checks/automatic/aria-allowed-role.js +21 -7
  20. package/src/checks/automatic/aria-braille-equivalent.js +32 -10
  21. package/src/checks/automatic/aria-conditional-attr.js +24 -7
  22. package/src/checks/automatic/aria-deprecated-role.js +22 -8
  23. package/src/checks/automatic/aria-hidden-body.js +42 -19
  24. package/src/checks/automatic/aria-hidden-focus.js +408 -53
  25. package/src/checks/automatic/aria-prohibited-attr.js +296 -22
  26. package/src/checks/automatic/aria-prohibited-children.js +55 -16
  27. package/src/checks/automatic/aria-required-attr.js +23 -8
  28. package/src/checks/automatic/aria-required-children.js +37 -14
  29. package/src/checks/automatic/aria-required-parent.js +48 -14
  30. package/src/checks/automatic/aria-role-name-present.js +47 -21
  31. package/src/checks/automatic/aria-roles-valid.js +22 -12
  32. package/src/checks/automatic/aria-valid-attr-value.js +28 -7
  33. package/src/checks/automatic/aria-valid-attr.js +17 -5
  34. package/src/checks/automatic/autocomplete-valid.js +74 -16
  35. package/src/checks/automatic/avoid-inline-spacing.js +20 -6
  36. package/src/checks/automatic/binary-control-name-present.js +60 -50
  37. package/src/checks/automatic/button-name-present.js +48 -18
  38. package/src/checks/automatic/bypass-blocks-present.js +42 -25
  39. package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
  40. package/src/checks/automatic/combobox-name-present.js +38 -45
  41. package/src/checks/automatic/contrast-computable.js +361 -341
  42. package/src/checks/automatic/contrast-enhanced.js +487 -466
  43. package/src/checks/automatic/contrast-minimum.js +486 -465
  44. package/src/checks/automatic/css-orientation-lock.js +30 -8
  45. package/src/checks/automatic/definition-list-children-valid.js +40 -19
  46. package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
  47. package/src/checks/automatic/dialog-name-present.js +37 -75
  48. package/src/checks/automatic/dlitem-parent-valid.js +23 -8
  49. package/src/checks/automatic/duplicate-id-aria.js +24 -6
  50. package/src/checks/automatic/embed-text-alternative-present.js +86 -35
  51. package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
  52. package/src/checks/automatic/form-control-single-label.js +47 -10
  53. package/src/checks/automatic/html-xml-lang-mismatch.js +34 -18
  54. package/src/checks/automatic/iframe-focusable-content.js +26 -11
  55. package/src/checks/automatic/iframe-name-present.js +31 -9
  56. package/src/checks/automatic/iframe-title-unique.js +29 -8
  57. package/src/checks/automatic/img-alt-present.js +47 -43
  58. package/src/checks/automatic/input-image-alt-present.js +143 -112
  59. package/src/checks/automatic/label-in-name.js +50 -22
  60. package/src/checks/automatic/language-page-present.js +109 -109
  61. package/src/checks/automatic/link-in-text-block.js +59 -19
  62. package/src/checks/automatic/link-name-present.js +45 -14
  63. package/src/checks/automatic/list-children-valid.js +26 -9
  64. package/src/checks/automatic/listbox-name-present.js +39 -19
  65. package/src/checks/automatic/listitem-parent-valid.js +18 -6
  66. package/src/checks/automatic/menuitem-name-present.js +39 -61
  67. package/src/checks/automatic/meta-refresh-no-exceptions.js +28 -7
  68. package/src/checks/automatic/meta-refresh-timing-absent.js +20 -6
  69. package/src/checks/automatic/meta-viewport-zoom-enabled.js +24 -7
  70. package/src/checks/automatic/meter-name-present.js +36 -33
  71. package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
  72. package/src/checks/automatic/object-text-alternative-present.js +91 -39
  73. package/src/checks/automatic/option-name-present.js +38 -21
  74. package/src/checks/automatic/page-title-present.js +17 -6
  75. package/src/checks/automatic/progressbar-name-present.js +41 -34
  76. package/src/checks/automatic/role-img-alt-present.js +209 -157
  77. package/src/checks/automatic/searchbox-name-present.js +39 -19
  78. package/src/checks/automatic/server-side-image-map-absent.js +23 -8
  79. package/src/checks/automatic/slider-name-present.js +40 -47
  80. package/src/checks/automatic/spinbutton-name-present.js +39 -19
  81. package/src/checks/automatic/summary-name-present.js +37 -17
  82. package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
  83. package/src/checks/automatic/svg-text-alternative-present.js +246 -226
  84. package/src/checks/automatic/tab-name-present.js +37 -60
  85. package/src/checks/automatic/table-headers-attr-valid.js +24 -8
  86. package/src/checks/automatic/table-th-has-data-cells.js +22 -8
  87. package/src/checks/automatic/target-size-minimum.js +118 -48
  88. package/src/checks/automatic/td-has-header.js +29 -11
  89. package/src/checks/automatic/textbox-name-present.js +39 -19
  90. package/src/checks/automatic/tooltip-name-present.js +37 -18
  91. package/src/checks/automatic/treeitem-name-present.js +38 -21
  92. package/src/checks/automatic/valid-lang.js +20 -6
  93. package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
  94. package/src/checks/manual/accesskeys-manual.js +14 -5
  95. package/src/checks/manual/area-alt-decorative-manual.js +192 -193
  96. package/src/checks/manual/area-alt-quality-manual.js +182 -141
  97. package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
  98. package/src/checks/manual/aria-text-manual.js +14 -6
  99. package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
  100. package/src/checks/manual/css-hidden-focus.js +196 -165
  101. package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
  102. package/src/checks/manual/empty-heading-manual.js +24 -7
  103. package/src/checks/manual/empty-table-header-manual.js +17 -6
  104. package/src/checks/manual/focus-order-semantics-manual.js +45 -10
  105. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
  106. package/src/checks/manual/heading-order-manual.js +22 -7
  107. package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
  108. package/src/checks/manual/image-redundant-alt-manual.js +17 -7
  109. package/src/checks/manual/img-alt-decorative-manual.js +131 -96
  110. package/src/checks/manual/img-alt-quality-manual.js +176 -127
  111. package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
  112. package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
  113. package/src/checks/manual/label-title-only-manual.js +14 -5
  114. package/src/checks/manual/landmark-banner-is-top-level-manual.js +66 -16
  115. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +53 -16
  116. package/src/checks/manual/landmark-main-is-top-level-manual.js +35 -10
  117. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +29 -10
  118. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +29 -10
  119. package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
  120. package/src/checks/manual/landmark-one-main-manual.js +26 -20
  121. package/src/checks/manual/landmark-unique-manual.js +41 -15
  122. package/src/checks/manual/link-name-quality-manual.js +43 -12
  123. package/src/checks/manual/media-transcript-present-manual.js +35 -22
  124. package/src/checks/manual/meta-viewport-large-manual.js +16 -5
  125. package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
  126. package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
  127. package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
  128. package/src/checks/manual/p-as-heading-manual.js +22 -7
  129. package/src/checks/manual/page-has-heading-one-manual.js +31 -22
  130. package/src/checks/manual/page-title-patterns-manual.js +78 -50
  131. package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
  132. package/src/checks/manual/region-manual.js +241 -48
  133. package/src/checks/manual/scope-attr-valid-manual.js +10 -3
  134. package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
  135. package/src/checks/manual/skip-link-manual.js +35 -12
  136. package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
  137. package/src/checks/manual/tabindex-manual.js +10 -3
  138. package/src/checks/manual/table-duplicate-name-manual.js +17 -7
  139. package/src/checks/manual/table-fake-caption-manual.js +24 -7
  140. package/src/checks/manual/video-caption-manual.js +15 -4
  141. package/src/checks/manual-review.js +56 -12
  142. package/src/core/aria-helpers.js +1127 -886
  143. package/src/core/contrast-helpers.js +1217 -1062
  144. package/src/core/dom-helpers.js +4175 -3917
  145. package/src/core/dom-runner.js +720 -604
  146. package/src/core/frame-messaging.js +189 -138
  147. package/src/core/frame-scan.js +94 -82
  148. package/src/core/rollup-composites.js +94 -102
  149. package/src/core/rule-meta.js +58 -41
  150. package/src/core.js +36552 -29111
  151. package/src/i18n/en.js +1194 -889
  152. package/src/i18n/fr.js +1136 -795
  153. package/src/policy/contracts.js +13 -13
  154. package/src/policy/resolvePolicy.js +48 -44
  155. package/src/report.js +63 -43
  156. package/src/sarif.js +175 -0
  157. package/surea11y.browser.js +36042 -0
package/README.md CHANGED
@@ -198,6 +198,66 @@ For complete integration examples, see `docs/INTEGRATION.md`.
198
198
 
199
199
  ---
200
200
 
201
+ #### Standalone browser bundle
202
+
203
+ For a page that isn't driven by an automation framework at all — a manual
204
+ QA pass, a bookmarklet, a browser extension — the package also ships a
205
+ self-contained bundle that needs no `require`, no bundler, and no build
206
+ step:
207
+
208
+ ```html
209
+ <script src="node_modules/@surea11y/core/surea11y.browser.js"></script>
210
+ <script>
211
+ const result = a11ycore.runa11yCoreInPage(
212
+ location.href, // pageUrl
213
+ null, // contextSelector
214
+ {}, // engineOptions
215
+ null // runOnly
216
+ );
217
+
218
+ console.log(result.checksResults.filter(r => r.outcome === "fail"));
219
+ </script>
220
+ ```
221
+
222
+ Loading `surea11y.browser.js` defines a single global, `a11ycore`, exposing
223
+ the same `runa11yCoreInPage` function described above — calling it runs a
224
+ real scan against the page it's loaded into and returns the same result
225
+ shape documented in [Understanding the Results](#understanding-the-results).
226
+
227
+ `contextSelector`, `engineOptions`, and `runOnly` are the same three
228
+ arguments described throughout this README and `docs/ENGINE_OPTIONS.md` —
229
+ nothing about calling the engine changes just because it's loaded this
230
+ way. A scan scoped to one region, filtered to a specific WCAG level, with
231
+ a couple of known-noisy selectors excluded, looks like this:
232
+
233
+ ```html
234
+ <script src="node_modules/@surea11y/core/surea11y.browser.js"></script>
235
+ <script>
236
+ const result = a11ycore.runa11yCoreInPage(
237
+ location.href,
238
+ "#main", // contextSelector: scan only this region
239
+ {
240
+ excludeSelectors: ["#cookie-banner", ".intercom-launcher"],
241
+ contrast: { mode: "auditorAssist" } // trade some false-positive protection for more findings
242
+ },
243
+ { tags: ["wcag2a", "wcag2aa"] } // runOnly: WCAG 2.0 A/AA rules only
244
+ );
245
+
246
+ console.log(result.checksResults.filter(r => r.outcome === "fail"));
247
+ </script>
248
+ ```
249
+
250
+ This bundle is generated from the same rule sources as the rest of the
251
+ engine (`npm run build` regenerates it alongside `src/core.js`), so its
252
+ behavior never drifts from the library's. It intentionally exposes only
253
+ `runa11yCoreInPage` — cross-frame scanning
254
+ (`runa11yCoreAcrossFrames`/`a11yCoreEnableFrameResponder`) requires the
255
+ embedded frame to also load the engine and opt in, which doesn't fit a
256
+ single dropped-in `<script>` tag; reach for the npm package directly if
257
+ you need that.
258
+
259
+ ---
260
+
201
261
  ## Understanding the Results
202
262
 
203
263
  Every scan returns a structured JSON document designed for both
@@ -274,6 +334,8 @@ and progressively explore more advanced features.
274
334
  | `docs/CLI.md` | CLI commands, options, exit codes and examples. |
275
335
  | `docs/BASELINE.md` | CI baseline/allowlist: gate builds only on new violations. |
276
336
  | `docs/REPORT.md` | Self-contained HTML report: browsable summary, WCAG rollup, filterable occurrence table. |
337
+ | `docs/SARIF.md` | SARIF 2.1.0 report for GitHub Code Scanning and other SARIF dashboards. |
338
+ | `docs/CI_INTEGRATIONS.md` | GitHub Actions and Bitbucket Pipelines templates wrapping the CLI. |
277
339
  | `docs/ENGINE_OPTIONS.md` | Configuration, filtering, policies and localization. |
278
340
  | `docs/INTEGRATION.md` | Using surea11y with jsdom, Playwright, Puppeteer, Selenium, Cypress and other drivers. |
279
341
  | `docs/BINDING_AUTHORS_GUIDE.md` | Building new framework integrations on top of the engine. |
@@ -341,6 +403,8 @@ The repository is organised so that the accessibility engine, rule
341
403
  implementations and supporting infrastructure remain clearly separated.
342
404
 
343
405
  ```text
406
+ surea11y.browser.js # Generated standalone browser bundle
407
+
344
408
  bin/
345
409
  core.js # CLI entry point
346
410
 
@@ -423,10 +487,12 @@ supported versions and the preferred disclosure process.
423
487
 
424
488
  ## License
425
489
 
426
- This project is released under the MIT License.
490
+ This project is released under the Mozilla Public License 2.0 (MPL-2.0).
427
491
 
428
492
  See the accompanying `LICENSE` file for the complete license text.
429
493
 
494
+ MPL-2.0 is file-level copyleft: it applies to `@surea11y/core`'s own source files, not to code that merely depends on it. A project that installs `@surea11y/core` as a normal package dependency and imports its public API — without copying or modifying this repository's source files — is unaffected by MPL-2.0 and may keep its own license (including a permissive one like MIT).
495
+
430
496
  ---
431
497
 
432
498
  ## Final Notes
package/bin/core.js CHANGED
@@ -23,6 +23,7 @@ const path = require('path');
23
23
  const pkg = require('../package.json');
24
24
  const { buildBaselineEntries, matchBaseline } = require('../src/baseline.js');
25
25
  const { renderHtmlReport } = require('../src/report.js');
26
+ const { renderSarifReport } = require('../src/sarif.js');
26
27
 
27
28
  // Piping output to `head`/`less`/etc. closes stdout early — without this,
28
29
  // the next write throws an unhandled EPIPE and crashes with a raw stack
@@ -45,9 +46,11 @@ Options:
45
46
  --exclude-rules <ids> Comma-separated rule IDs to exclude
46
47
  --tags <tags> Comma-separated tags to run (e.g. wcag2a,wcag2aa)
47
48
  --context <selector> CSS selector to scope the scan to a subtree
49
+ --custom-rules <path> Load runtime custom rules from a JS file (repeatable)
48
50
  --write-baseline <path> Write every current "fail" occurrence to <path>; never fails the build
49
51
  --baseline <path> Gate only on occurrences not already recorded in <path>
50
52
  --html <path> Write a self-contained, browsable HTML report to <path>
53
+ --sarif <path> Write a SARIF 2.1.0 report to <path> (for GitHub Code Scanning etc.)
51
54
  -h, --help Show this help
52
55
  -v, --version Show the installed version
53
56
 
@@ -63,8 +66,11 @@ Examples:
63
66
  surea11y scan ./index.html --write-baseline baseline.json
64
67
  surea11y scan ./index.html --baseline baseline.json
65
68
  surea11y scan ./index.html --html report.html
69
+ surea11y scan ./index.html --baseline baseline.json --sarif results.sarif
70
+ surea11y scan ./index.html --custom-rules ./a11y-rules.js
66
71
 
67
- See docs/BASELINE.md for the baseline/allowlist mechanism, docs/REPORT.md for the HTML report.
72
+ See docs/BASELINE.md for the baseline/allowlist mechanism, docs/REPORT.md for the HTML report,
73
+ docs/SARIF.md for the SARIF report, docs/CLI.md#custom-rules for custom rules.
68
74
  `);
69
75
  }
70
76
 
@@ -91,6 +97,9 @@ function parseArgs(argv) {
91
97
  case '--context':
92
98
  out.context = argv[++i];
93
99
  break;
100
+ case '--custom-rules':
101
+ (out.customRules = out.customRules || []).push(argv[++i]);
102
+ break;
94
103
  case '--baseline':
95
104
  out.baseline = argv[++i];
96
105
  break;
@@ -100,6 +109,9 @@ function parseArgs(argv) {
100
109
  case '--html':
101
110
  out.html = argv[++i];
102
111
  break;
112
+ case '--sarif':
113
+ out.sarif = argv[++i];
114
+ break;
103
115
  case '-h':
104
116
  case '--help':
105
117
  out.help = true;
@@ -121,7 +133,12 @@ function isUrl(s) {
121
133
 
122
134
  function formatError(err) {
123
135
  const base = err && err.message ? err.message : String(err);
124
- const cause = err && err.cause && err.cause.message ? err.cause.message : (err && err.cause ? String(err.cause) : '');
136
+ const cause =
137
+ err && err.cause && err.cause.message
138
+ ? err.cause.message
139
+ : err && err.cause
140
+ ? String(err.cause)
141
+ : '';
125
142
  return cause ? `${base}: ${cause}` : base;
126
143
  }
127
144
 
@@ -141,7 +158,7 @@ async function loadHtml(target) {
141
158
  return { html: fs.readFileSync(resolved, 'utf8'), url: `file://${resolved}` };
142
159
  }
143
160
 
144
- function buildEngineOptions(args) {
161
+ function buildEngineOptions(args, customRules) {
145
162
  const engineOptions = {};
146
163
  if (args.locale) engineOptions.locale = args.locale;
147
164
  if (args.rules || args.excludeRules) {
@@ -150,9 +167,48 @@ function buildEngineOptions(args) {
150
167
  if (args.excludeRules) engineOptions.rules.exclude = args.excludeRules;
151
168
  }
152
169
  if (args.tags) engineOptions.tags = { include: args.tags };
170
+ if (customRules && customRules.length) engineOptions.customRules = customRules;
153
171
  return engineOptions;
154
172
  }
155
173
 
174
+ // Loads one --custom-rules file. The CLI runs custom rules in the same
175
+ // process/JS realm as the scan itself (unlike a binding whose engineOptions
176
+ // crosses page.evaluate()'s serialization boundary), so a descriptor's
177
+ // runInPage/applicability may be a real function -- see the customRules
178
+ // section of docs/ENGINE_OPTIONS.md for the full descriptor contract this
179
+ // mirrors. Validated up front (not left to the engine's own silent-skip of
180
+ // invalid entries) so a typo in a hand-authored file fails loudly as a usage
181
+ // error instead of quietly producing a scan with one fewer rule than expected.
182
+ function loadCustomRulesFile(customRulesPath) {
183
+ const resolved = path.resolve(process.cwd(), customRulesPath);
184
+
185
+ let loaded;
186
+ try {
187
+ loaded = require(resolved);
188
+ } catch (err) {
189
+ throw new Error(`Could not load custom rules file "${customRulesPath}": ${formatError(err)}`, {
190
+ cause: err
191
+ });
192
+ }
193
+
194
+ const descriptors = Array.isArray(loaded) ? loaded : [loaded];
195
+
196
+ for (const d of descriptors) {
197
+ const hasId = d && typeof d === 'object' && typeof d.id === 'string' && d.id.trim();
198
+ const hasRunInPage =
199
+ d &&
200
+ (typeof d.runInPage === 'function' ||
201
+ (typeof d.runInPage === 'string' && d.runInPage.trim()));
202
+ if (!hasId || !hasRunInPage) {
203
+ throw new Error(
204
+ `Custom rules file "${customRulesPath}" must export a rule descriptor ({ id, runInPage, ... }) or an array of them. See docs/CLI.md#custom-rules.`
205
+ );
206
+ }
207
+ }
208
+
209
+ return descriptors;
210
+ }
211
+
156
212
  function printSummary(result, baselineMatch) {
157
213
  const byOutcome = { pass: 0, fail: 0, cantTell: 0, notApplicable: 0 };
158
214
  for (const r of result.checksResults) {
@@ -160,13 +216,17 @@ function printSummary(result, baselineMatch) {
160
216
  }
161
217
 
162
218
  process.stdout.write(`\nsurea11y scan: ${result.url || '(no url)'}\n`);
163
- process.stdout.write(` pass: ${byOutcome.pass} fail: ${byOutcome.fail} cantTell: ${byOutcome.cantTell} notApplicable: ${byOutcome.notApplicable}\n\n`);
219
+ process.stdout.write(
220
+ ` pass: ${byOutcome.pass} fail: ${byOutcome.fail} cantTell: ${byOutcome.cantTell} notApplicable: ${byOutcome.notApplicable}\n\n`
221
+ );
164
222
 
165
223
  const fails = result.checksResults.filter((r) => r.outcome === 'fail');
166
224
  if (fails.length) {
167
225
  process.stdout.write(`FAIL (${fails.length} rule(s)):\n`);
168
226
  for (const r of fails) {
169
- process.stdout.write(`\n ${r.ruleId} (${r.severity}, ${r.occurrences.length} occurrence(s))\n`);
227
+ process.stdout.write(
228
+ `\n ${r.ruleId} (${r.severity}, ${r.occurrences.length} occurrence(s))\n`
229
+ );
170
230
  for (const occ of r.occurrences.slice(0, 5)) {
171
231
  process.stdout.write(` - ${occ.selector || '(no selector)'}\n ${occ.summary}\n`);
172
232
  if (occ.hint) process.stdout.write(` hint: ${occ.hint}\n`);
@@ -180,15 +240,21 @@ function printSummary(result, baselineMatch) {
180
240
 
181
241
  const cantTells = result.checksResults.filter((r) => r.outcome === 'cantTell');
182
242
  if (cantTells.length) {
183
- process.stdout.write(`cantTell — needs human review (${cantTells.length} rule(s)): ${cantTells.map((r) => r.ruleId).join(', ')}\n\n`);
243
+ process.stdout.write(
244
+ `cantTell — needs human review (${cantTells.length} rule(s)): ${cantTells.map((r) => r.ruleId).join(', ')}\n\n`
245
+ );
184
246
  }
185
247
 
186
248
  if (baselineMatch) {
187
- process.stdout.write(`baseline: ${baselineMatch.knownCount} known, ${baselineMatch.newCount} new, ${baselineMatch.staleCount} stale (no longer detected)\n`);
249
+ process.stdout.write(
250
+ `baseline: ${baselineMatch.knownCount} known, ${baselineMatch.newCount} new, ${baselineMatch.staleCount} stale (no longer detected)\n`
251
+ );
188
252
  if (baselineMatch.newCount) {
189
253
  process.stdout.write(`\nNEW (not in baseline, ${baselineMatch.newCount} occurrence(s)):\n`);
190
254
  for (const occ of baselineMatch.newOccurrences.slice(0, 5)) {
191
- process.stdout.write(` - ${occ.ruleId}: ${occ.selector || '(no selector)'}\n ${occ.summary}\n`);
255
+ process.stdout.write(
256
+ ` - ${occ.ruleId}: ${occ.selector || '(no selector)'}\n ${occ.summary}\n`
257
+ );
192
258
  }
193
259
  if (baselineMatch.newOccurrences.length > 5) {
194
260
  process.stdout.write(` ... and ${baselineMatch.newOccurrences.length - 5} more\n`);
@@ -203,18 +269,25 @@ function loadBaselineFile(baselinePath) {
203
269
  try {
204
270
  raw = fs.readFileSync(baselinePath, 'utf8');
205
271
  } catch (err) {
206
- throw new Error(`Could not read baseline file "${baselinePath}": ${formatError(err)}. Run with --write-baseline ${baselinePath} first to create one.`);
272
+ throw new Error(
273
+ `Could not read baseline file "${baselinePath}": ${formatError(err)}. Run with --write-baseline ${baselinePath} first to create one.`,
274
+ { cause: err }
275
+ );
207
276
  }
208
277
 
209
278
  let parsed;
210
279
  try {
211
280
  parsed = JSON.parse(raw);
212
281
  } catch (err) {
213
- throw new Error(`Baseline file "${baselinePath}" is not valid JSON: ${formatError(err)}`);
282
+ throw new Error(`Baseline file "${baselinePath}" is not valid JSON: ${formatError(err)}`, {
283
+ cause: err
284
+ });
214
285
  }
215
286
 
216
287
  if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.entries)) {
217
- throw new Error(`Baseline file "${baselinePath}" is not a supported baseline (expected { version: 1, entries: [...] }). Regenerate it with --write-baseline.`);
288
+ throw new Error(
289
+ `Baseline file "${baselinePath}" is not a supported baseline (expected { version: 1, entries: [...] }). Regenerate it with --write-baseline.`
290
+ );
218
291
  }
219
292
 
220
293
  return parsed;
@@ -229,7 +302,9 @@ async function runScan(args) {
229
302
  }
230
303
 
231
304
  if (args.baseline && args.writeBaseline) {
232
- process.stderr.write('Error: --baseline and --write-baseline cannot be used together in the same run. See --help.\n');
305
+ process.stderr.write(
306
+ 'Error: --baseline and --write-baseline cannot be used together in the same run. See --help.\n'
307
+ );
233
308
  process.exitCode = 2;
234
309
  return;
235
310
  }
@@ -245,6 +320,19 @@ async function runScan(args) {
245
320
  }
246
321
  }
247
322
 
323
+ let customRules = [];
324
+ if (args.customRules && args.customRules.length) {
325
+ try {
326
+ for (const customRulesPath of args.customRules) {
327
+ customRules = customRules.concat(loadCustomRulesFile(customRulesPath));
328
+ }
329
+ } catch (err) {
330
+ process.stderr.write(`Error: ${formatError(err)}\n`);
331
+ process.exitCode = 2;
332
+ return;
333
+ }
334
+ }
335
+
248
336
  let html, url;
249
337
  try {
250
338
  ({ html, url } = await loadHtml(target));
@@ -258,7 +346,9 @@ async function runScan(args) {
258
346
  try {
259
347
  ({ JSDOM } = require('jsdom'));
260
348
  } catch {
261
- process.stderr.write('Error: the surea11y CLI requires jsdom. Run `npm install jsdom` (it should already be a dependency of this package — this likely means a broken install).\n');
349
+ process.stderr.write(
350
+ 'Error: the surea11y CLI requires jsdom. Run `npm install jsdom` (it should already be a dependency of this package — this likely means a broken install).\n'
351
+ );
262
352
  process.exitCode = 2;
263
353
  return;
264
354
  }
@@ -271,27 +361,58 @@ async function runScan(args) {
271
361
 
272
362
  let result;
273
363
  try {
274
- result = runDomRulesInPage(url, args.context || null, buildEngineOptions(args), null);
364
+ result = runDomRulesInPage(
365
+ url,
366
+ args.context || null,
367
+ buildEngineOptions(args, customRules),
368
+ null
369
+ );
275
370
  } finally {
276
371
  dom.window.close();
277
372
  }
278
373
 
279
374
  if (args.html) {
280
- fs.writeFileSync(args.html, renderHtmlReport(result, { title: `surea11y scan report — ${target}` }));
375
+ fs.writeFileSync(
376
+ args.html,
377
+ renderHtmlReport(result, { title: `surea11y scan report — ${target}` })
378
+ );
281
379
  process.stderr.write(`Wrote HTML report to: ${args.html}\n`);
282
380
  }
283
381
 
382
+ if (args.sarif) {
383
+ fs.writeFileSync(
384
+ args.sarif,
385
+ renderSarifReport(result, {
386
+ toolVersion: pkg.version,
387
+ informationUri: (pkg.homepage || '').replace(/#.*$/, ''),
388
+ baselineEntries: baselineFile ? baselineFile.entries : undefined
389
+ })
390
+ );
391
+ process.stderr.write(`Wrote SARIF report to: ${args.sarif}\n`);
392
+ }
393
+
284
394
  if (args.writeBaseline) {
285
395
  const entries = buildBaselineEntries(result);
286
396
  const payload = { version: 1, generatedAt: new Date().toISOString(), entries };
287
397
  fs.writeFileSync(args.writeBaseline, JSON.stringify(payload, null, 2) + '\n');
288
398
 
289
399
  if (args.json) {
290
- process.stdout.write(JSON.stringify({ ...result, baseline: { mode: 'write', path: args.writeBaseline, entries: entries.length } }, null, 2) + '\n');
400
+ process.stdout.write(
401
+ JSON.stringify(
402
+ {
403
+ ...result,
404
+ baseline: { mode: 'write', path: args.writeBaseline, entries: entries.length }
405
+ },
406
+ null,
407
+ 2
408
+ ) + '\n'
409
+ );
291
410
  } else {
292
411
  printSummary(result);
293
412
  }
294
- process.stderr.write(`Wrote ${entries.length} occurrence(s) to baseline: ${args.writeBaseline}\n`);
413
+ process.stderr.write(
414
+ `Wrote ${entries.length} occurrence(s) to baseline: ${args.writeBaseline}\n`
415
+ );
295
416
  process.exitCode = 0;
296
417
  return;
297
418
  }
@@ -300,7 +421,9 @@ async function runScan(args) {
300
421
  const match = matchBaseline(result, baselineFile.entries);
301
422
 
302
423
  if (args.json) {
303
- process.stdout.write(JSON.stringify({ ...result, baseline: { mode: 'check', ...match } }, null, 2) + '\n');
424
+ process.stdout.write(
425
+ JSON.stringify({ ...result, baseline: { mode: 'check', ...match } }, null, 2) + '\n'
426
+ );
304
427
  } else {
305
428
  printSummary(result, match);
306
429
  }
@@ -333,7 +456,9 @@ async function main() {
333
456
 
334
457
  const [command, ...rest] = args._;
335
458
  if (command !== 'scan') {
336
- process.stderr.write(`Error: unknown command "${command}". Only "scan" is supported. See --help.\n`);
459
+ process.stderr.write(
460
+ `Error: unknown command "${command}". Only "scan" is supported. See --help.\n`
461
+ );
337
462
  process.exitCode = 2;
338
463
  return;
339
464
  }
@@ -1,6 +1,6 @@
1
1
  # API stability & versioning
2
2
 
3
- `@surea11y/core` has real downstream consumers today (5 framework bindings — Playwright, Puppeteer, Selenium, WebdriverIO, Cypress — plus a Jest/Vitest matcher package), all pinned to a `^1.1.0`-style semver range. Until now, "what counts as a breaking change" was implicit — discoverable only by reading source, not written down anywhere. This document makes that contract explicit.
3
+ `@surea11y/core` has real downstream consumers today: 5 first-party framework bindings (Playwright, Puppeteer, Selenium, WebdriverIO, Cypress) published to npm, plus a Jest/Vitest matcher (`@surea11y/test-matchers`, `toHaveNoA11yViolations()`) — all pinned to a `^1.1.0`-style semver range. Until now, "what counts as a breaking change" was implicit — discoverable only by reading source, not written down anywhere. This document makes that contract explicit.
4
4
 
5
5
  ## Stable fields (covered by semver)
6
6
 
@@ -1,8 +1,8 @@
1
1
  # Binding authors' guide
2
2
 
3
- Written for whoever builds the *next* framework binding on top of `surea11y` (Puppeteer, Cypress, Selenium, WebdriverIO, whatever comes after) — not for someone consuming a binding, and not for someone calling the engine directly. If that's you, see [`INTEGRATION.md`](./INTEGRATION.md) instead.
3
+ Written for whoever builds the *next* framework binding on top of `@surea11y/core` (Puppeteer, Cypress, Selenium, WebdriverIO, whatever comes after) — not for someone consuming a binding, and not for someone calling the engine directly. If that's you, see [`INTEGRATION.md`](./INTEGRATION.md) instead.
4
4
 
5
- The first real binding, `surea11y-playwright` (a sibling project, not part of this repo), has already worked through most of the design questions a new binding hits. This doc exists so the next one doesn't have to re-derive them — it's a checklist and a map of "what the engine already gives you for free" vs. "what every binding has to build itself," backed by what that binding actually did, not theory.
5
+ The first real binding, `@surea11y/playwright` (a sibling project, not part of this repo), has already worked through most of the design questions a new binding hits. This doc exists so the next one doesn't have to re-derive them — it's a checklist and a map of "what the engine already gives you for free" vs. "what every binding has to build itself," backed by what that binding actually did, not theory.
6
6
 
7
7
  ## Core-engine vs. binding-layer: know which side you're on
8
8
 
@@ -10,19 +10,19 @@ Some engine-parity features (relative to other established engines) are **engine
10
10
 
11
11
  **Already engine-level, works the moment you call `runa11yCoreInPage`/`runDomRulesInPage` — no binding code needed:**
12
12
  - All rule execution, WCAG SC mapping, composite rollups.
13
- - `runOnly`/`engineOptions.rules`/`.tags`/`.tests` rule selection — including [WCAG-version filtering](./ENGINE_OPTIONS.md#filtering-by-wcag-version-21-vs-22) (`wcag21a`/`wcag22aa`-style tags): if your binding has any kind of `.withTags()`/`.options()` passthrough that forwards `runOnly`/`engineOptions` generically, WCAG-version filtering already works through it with zero extra code — just document the tag vocabulary for your users, the way `surea11y-playwright`'s README does.
13
+ - `runOnly`/`engineOptions.rules`/`.tags`/`.tests` rule selection — including [WCAG-version filtering](./ENGINE_OPTIONS.md#filtering-by-wcag-version-21-vs-22) (`wcag21a`/`wcag22aa`-style tags): if your binding has any kind of `.withTags()`/`.options()` passthrough that forwards `runOnly`/`engineOptions` generically, WCAG-version filtering already works through it with zero extra code — just document the tag vocabulary for your users, the way `@surea11y/playwright`'s README does.
14
14
  - `structuralPath` on every `fail`/`cantTell` occurrence — if your binding passes occurrences through unreshaped (don't strip fields you don't recognize), this reaches your consumers automatically.
15
15
  - `engineOptions.customRules` — runtime rule registration. Works through a generic `engineOptions` passthrough too, **but** see the caveat below: if your binding crosses a serialization boundary (see next section), your consumers must pass `runInPage`/`applicability` as `fn.toString()` source, not a live function. Worth a dedicated `.withCustomRules([...])` convenience method for ergonomics, but not required for the feature to work.
16
- - Cross-frame scanning **if** your driver reaches every frame itself already (Puppeteer/Cypress/Selenium all can, via CDP or equivalent) — you don't need `runa11yCoreAcrossFrames`/`a11yCoreEnableFrameResponder` at all. Just call the engine once per frame your driver already gives you and merge the results yourself (see `surea11y-playwright`'s `.frames(true)`, which does exactly this — no engine change was needed for it). The `postMessage`-based cross-frame functions exist specifically for the *no-automation-driver* case (a plain injected script) and are the wrong tool for a driver-based binding.
16
+ - Cross-frame scanning **if** your driver reaches every frame itself already (Puppeteer/Cypress/Selenium all can, via CDP or equivalent) — you don't need `runa11yCoreAcrossFrames`/`a11yCoreEnableFrameResponder` at all. Just call the engine once per frame your driver already gives you and merge the results yourself (see `@surea11y/playwright`'s `.frames(true)`, which does exactly this — no engine change was needed for it). The `postMessage`-based cross-frame functions exist specifically for the *no-automation-driver* case (a plain injected script) and are the wrong tool for a driver-based binding.
17
17
 
18
18
  **Binding-layer — your binding has to build these itself, the engine won't:**
19
- - **Element references.** The engine returns `selector`/`structuralPath` strings, never a live handle — it has no concept of your driver's element-reference type. Resolve `occurrences[i].selector` back to a real handle yourself (Playwright's approach: `page.evaluateHandle` instead of `page.evaluate`, then `elementHandle.$(selector)` per occurrence — see `.elementRef(true)` in `surea11y-playwright`).
20
- - **Result verbosity/reporter filtering.** The engine deliberately always returns every rule's outcome, including `pass`/`notApplicable` — "not a violations-only list" is a stated engine design choice (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md)), not an oversight to work around. If your consumers want a trimmed view for CI-scale output, that's a post-filter your binding adds (`surea11y-playwright`'s `.reportOnly(['fail','cantTell'])` is a simple array-filter over the full result — no engine change).
19
+ - **Element references.** The engine returns `selector`/`structuralPath` strings, never a live handle — it has no concept of your driver's element-reference type. Resolve `occurrences[i].selector` back to a real handle yourself (Playwright's approach: `page.evaluateHandle` instead of `page.evaluate`, then `elementHandle.$(selector)` per occurrence — see `.elementRef(true)` in `@surea11y/playwright`).
20
+ - **Result verbosity/reporter filtering.** The engine deliberately always returns every rule's outcome, including `pass`/`notApplicable` — "not a violations-only list" is a stated engine design choice (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md)), not an oversight to work around. If your consumers want a trimmed view for CI-scale output, that's a post-filter your binding adds (`@surea11y/playwright`'s `.reportOnly(['fail','cantTell'])` is a simple array-filter over the full result — no engine change).
21
21
  - **Formatted failure output for your framework's own assertion/reporting style** (e.g. Playwright/Jest-style multi-line failure messages). The engine's raw result is framework-agnostic on purpose; shaping it into "what shows up in a failed test's stack trace" is squarely binding territory.
22
22
 
23
23
  ## The serialization-boundary caveat
24
24
 
25
- If your binding drives a *separate JS realm* (a browser page/tab is a different realm than your Node test process — this is Playwright/Puppeteer/Selenium's situation, not Cypress's, since Cypress test code already runs in-browser), anything you hand to `page.evaluate()`-equivalent gets structurally cloned/JSON-serialized. **Live functions do not survive that boundary.** This bit `surea11y-playwright` in two places:
25
+ If your binding drives a *separate JS realm* (a browser page/tab is a different realm than your Node test process — this is Playwright/Puppeteer/Selenium's situation, not Cypress's, since Cypress test code already runs in-browser), anything you hand to `page.evaluate()`-equivalent gets structurally cloned/JSON-serialized. **Live functions do not survive that boundary.** This bit `@surea11y/playwright` in two places:
26
26
  1. `runa11yCoreInPage` itself is designed around this — it's fully self-contained (`.toString()`-serializable, no closure over outer scope) specifically so it can be reconstructed from source inside the page realm.
27
27
  2. `engineOptions.customRules[].runInPage`/`.applicability` accept a function-source string for exactly this reason — a binding crossing this boundary must tell its consumers to pass `fn.toString()`, not `fn`. Document this prominently; it's an easy trap (a function looks like it should just work as an argument until it silently fails to serialize).
28
28
 
@@ -30,7 +30,7 @@ If your binding runs in the *same* realm as the page (a browser extension conten
30
30
 
31
31
  ## Things to check before shipping a new binding
32
32
 
33
- A short list, derived from what the audit pass on `surea11y-playwright` actually found missing on a first pass (per its own `ROADMAP.md`) — worth checking explicitly rather than assuming your binding's generic passthrough covers them:
33
+ A short list, derived from what the audit pass on `@surea11y/playwright` actually found missing on a first pass (per its own `ROADMAP.md`) — worth checking explicitly rather than assuming your binding's generic passthrough covers them:
34
34
  - [ ] Combinations of your own filtering methods behave sanely together (e.g. include+exclude on the same ID, tag-include + tag-exclude on the same tag) — these interact through the engine's `includeMode`/exclude-always-wins semantics ([`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md)), test them explicitly rather than assuming.
35
35
  - [ ] `structuralPath` and `customRules` still work correctly when combined with whatever binding-layer features you build (verbosity filtering, element refs, per-frame scanning) — a filter applied after the fact should never silently drop fields a consumer expects on a surviving occurrence.
36
36
  - [ ] If you support cross-frame scanning via your own driver, confirm each frame's result gets the same normalization (selector/structuralPath/severity) as a single-document scan — don't let a "per-frame" code path silently skip the shared result-shaping logic.
@@ -38,4 +38,4 @@ A short list, derived from what the audit pass on `surea11y-playwright` actually
38
38
 
39
39
  ## A known engine-side tradeoff worth knowing about
40
40
 
41
- `src/core.js` is not small (~3.1MB as of 2026-07-22) because the bundler-free, no-driver-context functions (`runa11yCoreInPage`, `runa11yCoreAcrossFrames`, `a11yCoreEnableFrameResponder`) each carry their own complete self-contained copy of the rule catalog. If your binding only ever uses `require('@surea11y/core')` in Node and injects `runa11yCoreInPage.toString()` into the page (the same pattern `surea11y-playwright` uses), your actual browser-injected payload is unaffected by this — only your Node-side `require()` footprint grows. Worth knowing if your binding's own package size matters to your consumers.
41
+ `src/core.js` is not small (~3.1MB as of 2026-07-22) because the bundler-free, no-driver-context functions (`runa11yCoreInPage`, `runa11yCoreAcrossFrames`, `a11yCoreEnableFrameResponder`) each carry their own complete self-contained copy of the rule catalog. If your binding only ever uses `require('@surea11y/core')` in Node and injects `runa11yCoreInPage.toString()` into the page (the same pattern `@surea11y/playwright` uses), your actual browser-injected payload is unaffected by this — only your Node-side `require()` footprint grows. Worth knowing if your binding's own package size matters to your consumers.
@@ -0,0 +1,103 @@
1
+ # CI/CD pipeline integrations
2
+
3
+ Ready-to-paste templates wrapping the [CLI](./CLI.md) (`npx @surea11y/core scan ...`) in GitHub Actions and Bitbucket Pipelines. If you're calling the library directly from your own Node script instead of the CLI, see [`INTEGRATION.md`](./INTEGRATION.md#ci-gating-a-build-on-the-result) instead — this page is specifically about the CLI as a pipeline step.
4
+
5
+ All of these rely on the CLI's own exit codes (`0` clean, `1` at least one — or one *new*, with `--baseline` — `fail` outcome, `2` a usage/scan error) to gate the pipeline; no extra scripting is required for basic pass/fail gating.
6
+
7
+ ## GitHub Actions
8
+
9
+ ### Basic: gate on exit code
10
+
11
+ ```yaml
12
+ name: Accessibility scan
13
+ on: [pull_request]
14
+
15
+ jobs:
16
+ a11y-scan:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: actions/setup-node@v4
21
+ with:
22
+ node-version: 20
23
+ - run: npm ci && npm run build # produce whatever static HTML you're scanning
24
+ - run: npx @surea11y/core scan ./dist/index.html
25
+ ```
26
+
27
+ This fails the job the moment any `fail` outcome is found. For an existing site with pre-existing violations, see the baseline variant below instead of disabling the step.
28
+
29
+ ### With a baseline (existing site, gate only on new violations)
30
+
31
+ ```sh
32
+ # Once, locally: record every current fail occurrence, commit the file.
33
+ npx @surea11y/core scan ./dist/index.html --write-baseline a11y-baseline.json
34
+ git add a11y-baseline.json
35
+ ```
36
+
37
+ ```yaml
38
+ - run: npm ci && npm run build
39
+ - run: npx @surea11y/core scan ./dist/index.html --baseline a11y-baseline.json
40
+ ```
41
+
42
+ See [`BASELINE.md`](./BASELINE.md) for what counts as "known" vs. "new", and how to regenerate the file as violations get fixed.
43
+
44
+ ### Uploading SARIF to GitHub Code Scanning
45
+
46
+ `upload-sarif` needs `security-events: write` permission and does not fail the job itself — pair it with a separate gating step (or `continue-on-error` + your own check) if you also want the build to fail on new violations.
47
+
48
+ ```yaml
49
+ name: Accessibility scan
50
+ on: [pull_request]
51
+
52
+ permissions:
53
+ contents: read
54
+ security-events: write
55
+
56
+ jobs:
57
+ a11y-scan:
58
+ runs-on: ubuntu-latest
59
+ steps:
60
+ - uses: actions/checkout@v4
61
+ - uses: actions/setup-node@v4
62
+ with:
63
+ node-version: 20
64
+ - run: npm ci && npm run build
65
+ - name: Scan (report, don't fail the job here)
66
+ run: npx @surea11y/core scan ./dist/index.html --baseline a11y-baseline.json --sarif results.sarif
67
+ continue-on-error: true
68
+ id: scan
69
+ - name: Upload SARIF to Code Scanning
70
+ uses: github/codeql-action/upload-sarif@v3
71
+ with:
72
+ sarif_file: results.sarif
73
+ - name: Fail the job on new violations
74
+ if: steps.scan.outcome == 'failure'
75
+ run: exit 1
76
+ ```
77
+
78
+ See [`SARIF.md`](./SARIF.md) for the output format and a known limitation: a scan of a **local file** in your checkout gets inline annotations in the Code Scanning UI; a scan of a **live URL** doesn't (SARIF/Code Scanning can only associate a finding with a real file in the repository).
79
+
80
+ ## Bitbucket Pipelines
81
+
82
+ Bitbucket Pipelines has no built-in SARIF-consuming dashboard equivalent to GitHub Code Scanning, so the CLI's exit code (plus, optionally, `--html` as a downloadable pipeline artifact) is the practical integration point rather than `--sarif`.
83
+
84
+ ```yaml
85
+ pipelines:
86
+ pull-requests:
87
+ '**':
88
+ - step:
89
+ name: Accessibility scan
90
+ image: node:20
91
+ script:
92
+ - npm ci
93
+ - npm run build
94
+ - npx @surea11y/core scan ./dist/index.html --baseline a11y-baseline.json --html a11y-report.html
95
+ artifacts:
96
+ - a11y-report.html
97
+ ```
98
+
99
+ The step fails the pipeline on the CLI's exit code exactly like any other `script` entry; `a11y-report.html` (see [`REPORT.md`](./REPORT.md)) is attached as a downloadable build artifact so a reviewer can open it without re-running the scan locally.
100
+
101
+ ## Free-tier/private-repo minute limits
102
+
103
+ If your pipeline provider's free tier is minute-limited (Bitbucket Pipelines' free tier is 50 build-minutes/month on private workspaces, for example), a `jsdom`-based scan of static/server-rendered HTML (what the CLI does) is far cheaper than driving a real browser — see [`CLI.md`](./CLI.md#what-it-can-and-cant-scan) for what that trades away (no client-rendered content, no real CSS layout).