@surea11y/core 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +3 -0
- package/bin/core.js +107 -3
- package/docs/API_STABILITY.md +61 -0
- package/docs/BASELINE.md +66 -0
- package/docs/CLI.md +26 -0
- package/docs/ENGINE_OPTIONS.md +2 -0
- package/docs/INTEGRATION.md +1 -1
- package/docs/OUTPUT_SCHEMA.md +1 -1
- package/docs/REPORT.md +33 -0
- package/docs/RULE_AUTHORING.md +31 -0
- package/package.json +1 -1
- package/src/baseline.js +0 -0
- package/src/checks/automatic/aria-hidden-body.js +9 -1
- package/src/checks/automatic/aria-prohibited-children.js +71 -14
- package/src/checks/automatic/bypass-blocks-present.js +9 -1
- package/src/checks/automatic/css-orientation-lock.js +9 -1
- package/src/checks/automatic/html-xml-lang-mismatch.js +9 -1
- package/src/checks/automatic/language-page-present.js +9 -1
- package/src/checks/automatic/meta-refresh-no-exceptions.js +9 -1
- package/src/checks/automatic/meta-refresh-timing-absent.js +9 -1
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +9 -1
- package/src/checks/automatic/page-title-present.js +9 -1
- package/src/checks/manual/empty-heading-manual.js +1 -6
- package/src/checks/manual/empty-table-header-manual.js +5 -9
- package/src/checks/manual/heading-order-manual.js +1 -6
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +30 -14
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +30 -14
- package/src/checks/manual/landmark-main-is-top-level-manual.js +30 -14
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +25 -13
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +25 -13
- package/src/checks/manual/landmark-one-main-manual.js +9 -1
- package/src/checks/manual/landmark-unique-manual.js +32 -30
- package/src/checks/manual/meta-viewport-large-manual.js +9 -1
- package/src/checks/manual/page-has-heading-one-manual.js +9 -1
- package/src/checks/manual/page-title-patterns-manual.js +9 -1
- package/src/checks/manual/region-manual.js +34 -14
- package/src/checks/manual/scope-attr-valid-manual.js +2 -7
- package/src/checks/manual/tabindex-manual.js +2 -7
- package/src/core/aria-helpers.js +82 -18
- package/src/core/dom-helpers.js +32 -0
- package/src/core/dom-runner.js +8 -0
- package/src/core/rule-meta.js +19 -0
- package/src/core.js +1781 -404
- package/src/i18n/en.js +2 -0
- package/src/i18n/fr.js +2 -0
- package/src/report.js +482 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.2.0] - 2026-07-31
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- CLI baseline/allowlist mechanism: `surea11y scan --write-baseline <path>` records every current `fail` occurrence (never fails the build); `surea11y scan --baseline <path>` then gates only on occurrences not already recorded there. Matching identity is `ruleId` + `reasonCode` + the occurrence's `html` snippet (deliberately not `selector`/`structuralPath`, both of which are position-derived and can shift when unrelated markup changes elsewhere on the page) — multiset-matched, so repeated identical violations are counted correctly rather than all matching one baseline entry. The underlying `buildBaselineEntries`/`matchBaseline` functions (`src/baseline.js`) are also usable directly by library consumers, not just the CLI. See `docs/BASELINE.md` for the full design, file format, and known limitations (a flagged element with dynamic content in its own markup won't match itself across scans).
|
|
11
|
+
- `engineOptions.fragment`: 14 rules that check for the presence of a page-wide property (`page-title-present`, `html-lang-attr-present`, `html-xml-lang-mismatch`, `aria-hidden-body`, `css-orientation-lock`, the 4 `meta-refresh`/`meta-viewport` rules, `page-title-patterns`, `region`, `bypass-blocks-present`, `landmark-one-main`, `page-has-heading-one`) now correctly report `notApplicable` — instead of an incorrect `fail`/`cantTell` — when a scan is scoped to a subtree narrower than the whole document (via `contextSelector`) or run with the new `engineOptions.fragment: true` (for a scan target that's the whole given document but was never meant to represent a real page, e.g. a component snippet parsed on its own — `contextSelector` scoping alone can't detect that case, since `document.documentElement` still exists and is unscoped). A scoped subtree or bare fragment was never expected to carry its own `<title>`/`<html lang>`/page-wide landmark structure, so flagging its absence was a false positive. New `helpers.isWholeDocumentScope()` (`src/core/dom-helpers.js`) backs this via each rule's `applicability(ctx)` export — the first real use of that already-existing, previously-dormant engine mechanism. Investigated axe-core's own equivalent behavior first (it has no special fragment-detection either — its `document-title` rule's selector just happens to target `<html>` itself, so it naturally finds no matches when `<html>` is out of scope) rather than assuming a design. See `docs/ENGINE_OPTIONS.md` and `docs/RULE_AUTHORING.md` §11.2.
|
|
12
|
+
- Versioned public API contract: `docs/API_STABILITY.md` (new) codifies which result-shape fields are covered by semver, which aren't (`perfStats`/`ruleTimings`, `occurrences[].data.details`, the previously-unused `ruleVersion`/`ruleInterfaceVersion` scaffolding), and what triggers a patch/minor/major bump — e.g. explicitly calling out that a correctness fix changing which outcome a rule produces (like the `engineOptions.fragment` work above) is a patch, not a major bump. Also adds a rule-ID deprecation mechanism: `meta.deprecated`/`meta.deprecation` (`{ replacedBy, reason, sinceVersion }`) on any rule, validated by `normalizeRuleMeta` (`src/core/rule-meta.js`) and surfaced through `getChecksCatalog()`. A deprecated rule keeps running and producing results completely normally — this is a catalog-level migration signal for integrators, not an automatic exclusion (no `engineOptions.excludeDeprecated` flag). No rule is deprecated yet; this is the mechanism, exercised so far only by a synthetic rule in the test suite.
|
|
13
|
+
- `surea11y scan --html <path>`: a self-contained, browsable HTML report (`src/report.js`'s `renderHtmlReport`) — hero summary, "worth reviewing" cards grouped by rule (not one per raw occurrence), a WCAG rollup grouped by conformance level sourced directly from `rulesResults[]`'s existing composite data (not an invented grouping), and a collapsed "full technical data" section with a searchable/filterable/paginated occurrence table. No external requests, dark-mode aware. Structurally adapted from the cross-engine-diff project's own HTML report tool (its shell — self-contained file, hero-bar-plus-legend, grouped cards with an overflow cap, collapsible technical detail — is generic and reusable; its actual organizing principle, a 7-way "do the two engines agree" taxonomy, has no single-engine analog and wasn't reused). See `docs/REPORT.md`.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- `aria-prohibited-children` resolved an owned child's role via `getExplicitRole` (explicit `role=""` attribute only), unlike its sibling `aria-required-children`, which resolves via `getContainmentRole` (explicit role, falling back to a native-tag map — `li`→listitem, `tr`→row, `td`→cell, `th`→columnheader, `thead`/`tbody`/`tfoot`→rowgroup, `ul`/`ol`→list, `table`→table, `select`→listbox, `input[type=radio]`→radio). A bare `<li>` with no `role=""` attribute — the common CSS-reset pattern `<ul role="list"><li>...</li></ul>` that `getContainmentRole` exists specifically to handle — was read as roleless and therefore structurally transparent, so the ownership walk recursed straight through the listitem boundary and could report a focusable descendant several DOM levels down as a disallowed owned child of the list, instead of stopping at the (implicit) listitem the way `aria-required-children` already does. Found via a real Angular Material-style component library: an `<a routerlink>` nested several levels inside a bare `<li>` under `<ul role="list">` was reported as an unallowed owned child of the list. Now uses `getContainmentRole`, so both rules resolve an owned child's role identically; the fix is general, not list/listitem-specific — it applies to every container role in `REQUIRED_OWNED_ROLES` whose native-tag counterpart the containment map covers (e.g. a bare `<tr>`/`<td>` under `role="table"`/`role="grid"`/`role="row"` with no explicit `role=""` was subject to the same flattening bug).
|
|
17
|
+
- `landmark-no-duplicate-banner`, `landmark-no-duplicate-contentinfo`, `landmark-unique`, `landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`, `landmark-main-is-top-level`, and `region` all computed whether a `<header>`/`<footer>`/`<aside>` sits inside a sectioning-content ancestor (the W3C ARIA-in-HTML condition that suppresses its implicit banner/contentinfo/complementary role) by checking the ancestor's HTML tag name alone. A widely-used reference engine's real algorithm is role-aware: an ancestor's bare tag only counts when it carries no `role` attribute at all — once a `role` is present, only that role's own value decides membership (`article`/`complementary`/`navigation`/`region`, plus `main` for header/footer), verified by reading that engine's `getSectioningContentSelector`/`getSectioningContentPlusMainSelector` source directly. Found via the cross-engine comparisons project on handsontable.com's demo page: a documentation-assistant side panel is an `<aside role="dialog">` containing its own `<header>` — `role="dialog"` isn't one of the four scoping roles, so the nested `<header>` should keep its implicit "banner" role and collide with the page's real banner, which that reference engine correctly flags and surea11y silently missed entirely (not even a `cantTell`). All 7 rules previously carried their own duplicated copy of this tag-only check (one, `landmark-unique`, already had a partial, role-*unaware* fix for a related "must not also suppress on `<main>`" bug found earlier via Know Your Meme's homepage); they now share one `helpers.hasLandmarkScopingAncestor` implementation (`src/core/aria-helpers.js`, re-exported from `src/core/dom-helpers.js`), and a `<header>`/`<footer>` nested inside a role-overridden `<aside>` now correctly regains its landmark role. `getElementRoleKey`'s own `<header>` implicit-role branch (used by `aria-allowed-role`/`aria-roles-valid`-style checks to decide whether an explicit `role="banner"` restatement is a permitted no-op) now shares the same corrected logic instead of its own separate tag-only copy.
|
|
18
|
+
- `hasLandmarkScopingAncestor` (the shared helper above) and the separate local `hasLandmarkAncestor` in `landmark-banner-is-top-level`/`landmark-contentinfo-is-top-level`/`landmark-main-is-top-level` both climbed via `parentElement` with no scope boundary, so a `contextSelector`-scoped scan could be affected by real DOM ancestry *outside* the analyzed subtree — e.g. a page's own `<nav>` wrapping a scanned `#widget` region would incorrectly count as a landmark ancestor of something inside `#widget`, even though that `<nav>` was never in scope. Found while auditing rules for the `engineOptions.fragment` work above. Both now stop climbing once they reach one of the scan's own resolved roots; unscoped (the default, root is `document.documentElement`), behavior is unchanged.
|
|
19
|
+
- `tabindex`, `heading-order`, `empty-heading`, `empty-table-header`, and `scope-attr-valid` all queried `document.querySelectorAll` directly instead of the shared `helpers.queryAllSmart`, so none of them respected `contextSelector` scoping, `excludeSelectors`, or shadow-DOM traversal the way every other rule does — a violation anywhere in the document would still be reported even when the scan was explicitly scoped away from it. Found during the same audit as the fragment-scan work above (a separate, unrelated bug class — these aren't inherently whole-document, they were just never wired through the shared helper). All five now delegate to `helpers.queryAllSmart`/`.queryAll` like the rest of the rule catalog; unscoped behavior is unchanged.
|
|
20
|
+
|
|
7
21
|
## [1.1.2] - 2026-07-30
|
|
8
22
|
|
|
9
23
|
### Fixed
|
package/README.md
CHANGED
|
@@ -270,7 +270,10 @@ and progressively explore more advanced features.
|
|
|
270
270
|
| Document | Description |
|
|
271
271
|
|---|---|
|
|
272
272
|
| `docs/OUTPUT_SCHEMA.md` | Complete description of every field returned by the engine. |
|
|
273
|
+
| `docs/API_STABILITY.md` | Semver guarantees on the result shape, and the rule-ID deprecation policy. |
|
|
273
274
|
| `docs/CLI.md` | CLI commands, options, exit codes and examples. |
|
|
275
|
+
| `docs/BASELINE.md` | CI baseline/allowlist: gate builds only on new violations. |
|
|
276
|
+
| `docs/REPORT.md` | Self-contained HTML report: browsable summary, WCAG rollup, filterable occurrence table. |
|
|
274
277
|
| `docs/ENGINE_OPTIONS.md` | Configuration, filtering, policies and localization. |
|
|
275
278
|
| `docs/INTEGRATION.md` | Using surea11y with jsdom, Playwright, Puppeteer, Selenium, Cypress and other drivers. |
|
|
276
279
|
| `docs/BINDING_AUTHORS_GUIDE.md` | Building new framework integrations on top of the engine. |
|
package/bin/core.js
CHANGED
|
@@ -21,6 +21,8 @@ const fs = require('fs');
|
|
|
21
21
|
const path = require('path');
|
|
22
22
|
|
|
23
23
|
const pkg = require('../package.json');
|
|
24
|
+
const { buildBaselineEntries, matchBaseline } = require('../src/baseline.js');
|
|
25
|
+
const { renderHtmlReport } = require('../src/report.js');
|
|
24
26
|
|
|
25
27
|
// Piping output to `head`/`less`/etc. closes stdout early — without this,
|
|
26
28
|
// the next write throws an unhandled EPIPE and crashes with a raw stack
|
|
@@ -43,18 +45,26 @@ Options:
|
|
|
43
45
|
--exclude-rules <ids> Comma-separated rule IDs to exclude
|
|
44
46
|
--tags <tags> Comma-separated tags to run (e.g. wcag2a,wcag2aa)
|
|
45
47
|
--context <selector> CSS selector to scope the scan to a subtree
|
|
48
|
+
--write-baseline <path> Write every current "fail" occurrence to <path>; never fails the build
|
|
49
|
+
--baseline <path> Gate only on occurrences not already recorded in <path>
|
|
50
|
+
--html <path> Write a self-contained, browsable HTML report to <path>
|
|
46
51
|
-h, --help Show this help
|
|
47
52
|
-v, --version Show the installed version
|
|
48
53
|
|
|
49
54
|
Exit codes:
|
|
50
|
-
0 scan completed, no "fail" outcomes
|
|
51
|
-
1 scan completed, at least one "fail" outcome
|
|
55
|
+
0 scan completed, no "fail" outcomes (or no *new* ones, with --baseline)
|
|
56
|
+
1 scan completed, at least one "fail" outcome (or *new* one, with --baseline)
|
|
52
57
|
2 usage error or the scan itself could not run (bad path/URL, network failure, etc.)
|
|
53
58
|
|
|
54
59
|
Examples:
|
|
55
60
|
surea11y scan ./index.html
|
|
56
61
|
surea11y scan https://example.com/ --tags wcag2a,wcag2aa
|
|
57
62
|
surea11y scan ./index.html --json > result.json
|
|
63
|
+
surea11y scan ./index.html --write-baseline baseline.json
|
|
64
|
+
surea11y scan ./index.html --baseline baseline.json
|
|
65
|
+
surea11y scan ./index.html --html report.html
|
|
66
|
+
|
|
67
|
+
See docs/BASELINE.md for the baseline/allowlist mechanism, docs/REPORT.md for the HTML report.
|
|
58
68
|
`);
|
|
59
69
|
}
|
|
60
70
|
|
|
@@ -81,6 +91,15 @@ function parseArgs(argv) {
|
|
|
81
91
|
case '--context':
|
|
82
92
|
out.context = argv[++i];
|
|
83
93
|
break;
|
|
94
|
+
case '--baseline':
|
|
95
|
+
out.baseline = argv[++i];
|
|
96
|
+
break;
|
|
97
|
+
case '--write-baseline':
|
|
98
|
+
out.writeBaseline = argv[++i];
|
|
99
|
+
break;
|
|
100
|
+
case '--html':
|
|
101
|
+
out.html = argv[++i];
|
|
102
|
+
break;
|
|
84
103
|
case '-h':
|
|
85
104
|
case '--help':
|
|
86
105
|
out.help = true;
|
|
@@ -134,7 +153,7 @@ function buildEngineOptions(args) {
|
|
|
134
153
|
return engineOptions;
|
|
135
154
|
}
|
|
136
155
|
|
|
137
|
-
function printSummary(result) {
|
|
156
|
+
function printSummary(result, baselineMatch) {
|
|
138
157
|
const byOutcome = { pass: 0, fail: 0, cantTell: 0, notApplicable: 0 };
|
|
139
158
|
for (const r of result.checksResults) {
|
|
140
159
|
if (Object.prototype.hasOwnProperty.call(byOutcome, r.outcome)) byOutcome[r.outcome] += 1;
|
|
@@ -163,6 +182,42 @@ function printSummary(result) {
|
|
|
163
182
|
if (cantTells.length) {
|
|
164
183
|
process.stdout.write(`cantTell — needs human review (${cantTells.length} rule(s)): ${cantTells.map((r) => r.ruleId).join(', ')}\n\n`);
|
|
165
184
|
}
|
|
185
|
+
|
|
186
|
+
if (baselineMatch) {
|
|
187
|
+
process.stdout.write(`baseline: ${baselineMatch.knownCount} known, ${baselineMatch.newCount} new, ${baselineMatch.staleCount} stale (no longer detected)\n`);
|
|
188
|
+
if (baselineMatch.newCount) {
|
|
189
|
+
process.stdout.write(`\nNEW (not in baseline, ${baselineMatch.newCount} occurrence(s)):\n`);
|
|
190
|
+
for (const occ of baselineMatch.newOccurrences.slice(0, 5)) {
|
|
191
|
+
process.stdout.write(` - ${occ.ruleId}: ${occ.selector || '(no selector)'}\n ${occ.summary}\n`);
|
|
192
|
+
}
|
|
193
|
+
if (baselineMatch.newOccurrences.length > 5) {
|
|
194
|
+
process.stdout.write(` ... and ${baselineMatch.newOccurrences.length - 5} more\n`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
process.stdout.write('\n');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function loadBaselineFile(baselinePath) {
|
|
202
|
+
let raw;
|
|
203
|
+
try {
|
|
204
|
+
raw = fs.readFileSync(baselinePath, 'utf8');
|
|
205
|
+
} catch (err) {
|
|
206
|
+
throw new Error(`Could not read baseline file "${baselinePath}": ${formatError(err)}. Run with --write-baseline ${baselinePath} first to create one.`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let parsed;
|
|
210
|
+
try {
|
|
211
|
+
parsed = JSON.parse(raw);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
throw new Error(`Baseline file "${baselinePath}" is not valid JSON: ${formatError(err)}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
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.`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return parsed;
|
|
166
221
|
}
|
|
167
222
|
|
|
168
223
|
async function runScan(args) {
|
|
@@ -173,6 +228,23 @@ async function runScan(args) {
|
|
|
173
228
|
return;
|
|
174
229
|
}
|
|
175
230
|
|
|
231
|
+
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');
|
|
233
|
+
process.exitCode = 2;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let baselineFile = null;
|
|
238
|
+
if (args.baseline) {
|
|
239
|
+
try {
|
|
240
|
+
baselineFile = loadBaselineFile(args.baseline);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
process.stderr.write(`Error: ${formatError(err)}\n`);
|
|
243
|
+
process.exitCode = 2;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
176
248
|
let html, url;
|
|
177
249
|
try {
|
|
178
250
|
({ html, url } = await loadHtml(target));
|
|
@@ -204,6 +276,38 @@ async function runScan(args) {
|
|
|
204
276
|
dom.window.close();
|
|
205
277
|
}
|
|
206
278
|
|
|
279
|
+
if (args.html) {
|
|
280
|
+
fs.writeFileSync(args.html, renderHtmlReport(result, { title: `surea11y scan report — ${target}` }));
|
|
281
|
+
process.stderr.write(`Wrote HTML report to: ${args.html}\n`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (args.writeBaseline) {
|
|
285
|
+
const entries = buildBaselineEntries(result);
|
|
286
|
+
const payload = { version: 1, generatedAt: new Date().toISOString(), entries };
|
|
287
|
+
fs.writeFileSync(args.writeBaseline, JSON.stringify(payload, null, 2) + '\n');
|
|
288
|
+
|
|
289
|
+
if (args.json) {
|
|
290
|
+
process.stdout.write(JSON.stringify({ ...result, baseline: { mode: 'write', path: args.writeBaseline, entries: entries.length } }, null, 2) + '\n');
|
|
291
|
+
} else {
|
|
292
|
+
printSummary(result);
|
|
293
|
+
}
|
|
294
|
+
process.stderr.write(`Wrote ${entries.length} occurrence(s) to baseline: ${args.writeBaseline}\n`);
|
|
295
|
+
process.exitCode = 0;
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (baselineFile) {
|
|
300
|
+
const match = matchBaseline(result, baselineFile.entries);
|
|
301
|
+
|
|
302
|
+
if (args.json) {
|
|
303
|
+
process.stdout.write(JSON.stringify({ ...result, baseline: { mode: 'check', ...match } }, null, 2) + '\n');
|
|
304
|
+
} else {
|
|
305
|
+
printSummary(result, match);
|
|
306
|
+
}
|
|
307
|
+
process.exitCode = match.newCount > 0 ? 1 : 0;
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
207
311
|
if (args.json) {
|
|
208
312
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
209
313
|
} else {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# API stability & versioning
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Stable fields (covered by semver)
|
|
6
|
+
|
|
7
|
+
Removing, renaming, or changing the type/meaning of any of these is a **major** version bump:
|
|
8
|
+
|
|
9
|
+
- Top-level result: `engine.tag`, `engine.schemaVersion`, `url`, `checksResults` (an array), `rulesResults` (an array).
|
|
10
|
+
- Each `checksResults[i]` / `rulesResults[i]` entry: `ruleId`, `outcome`, `outcomeNormalized`, `severity`, `confidence`, `type`, `title`, `description`, `meta` (including `meta.normativeMappings`, `meta.deprecated`/`.deprecation` — see below), `engineOptions`, `schemaVersion`.
|
|
11
|
+
- Each occurrence (`occurrences[i]`): `selector`, `html`, `summary`, `hint`, `i18n`, `structuralPath`.
|
|
12
|
+
- The rule **catalog** (`getChecksCatalog()`/`getRulesCatalog()`, a separate surface from a scan result — see `RULE_AUTHORING.md`): `ruleId`, `title`, `description`, `tags`, `wcagSc`, `normativeMappings`, `defaultSeverity`, `defaultConfidence`, `type`, `deprecated`/`.deprecation`. Note `tags` lives here, not on a per-scan `checksResults[i].meta` — the two surfaces intentionally carry different subsets of a rule's metadata.
|
|
13
|
+
|
|
14
|
+
This list is deliberately not a new, invented guarantee — it codifies what the 6 real consumers above (and `docs/OUTPUT_SCHEMA.md`'s own worked examples) already depend on today, either directly or as documented shape.
|
|
15
|
+
|
|
16
|
+
## Explicitly unstable (not covered by semver)
|
|
17
|
+
|
|
18
|
+
- `perfStats` and `ruleTimings` — internal timing/debug counters, only present when `engineOptions.perfStats`/`.profileRules` is set. Shape not covered by this document.
|
|
19
|
+
- `occurrences[i].data.details` — rule-specific, non-normative extra context. Shape varies per rule and may change in a patch release; treat as best-effort, not a stable contract (this was already noted in `docs/OUTPUT_SCHEMA.md` before this document existed).
|
|
20
|
+
- `ruleInterfaceVersion` / `ruleVersion` on a rule's meta — currently unused scaffolding (every rule defaults to the same two static strings; nothing meaningfully sets or consumes them today). Not part of this contract until they're actually wired up to mean something.
|
|
21
|
+
|
|
22
|
+
## What triggers which version bump
|
|
23
|
+
|
|
24
|
+
- **Patch**: a correctness fix that changes *which* outcome a rule produces for the same input, without changing the shape or mechanism. Example: the fragment-scan applicability fix (`engineOptions.fragment`, see `ENGINE_OPTIONS.md`) changed several rules from incorrectly `fail`ing on a scoped subtree to correctly `notApplicable` — that's a patch, not a major bump, because no stable field's *shape* changed, only a bug got fixed. Don't over-index on "any output change = major" — bug fixes are expected to change output.
|
|
25
|
+
- **Minor**: adding a new stable field, adding a new rule to the catalog, or marking an existing rule `deprecated` (see below).
|
|
26
|
+
- **Major**: removing or renaming a stable field, changing a stable field's type or meaning, or removing a rule ID once its deprecation notice period has passed. Paired with an `engine.schemaVersion` bump specifically when the *shape* changes (as opposed to package-level major bumps for other reasons, e.g. dropping support for an old Node version).
|
|
27
|
+
|
|
28
|
+
`engine.schemaVersion` has been `"1.0.0"` since the engine's first release and has never needed a bump — nothing has changed a stable field's shape yet. Adding the `deprecated`/`deprecation` meta fields described below is purely additive (new optional fields, ignored safely by anything not looking for them), so it does **not** warrant a schema bump either — this is the policy's first real application.
|
|
29
|
+
|
|
30
|
+
## Rule-ID deprecation policy
|
|
31
|
+
|
|
32
|
+
A rule can be marked deprecated in its own `meta`:
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
const meta = {
|
|
36
|
+
// ...
|
|
37
|
+
deprecated: true,
|
|
38
|
+
deprecation: {
|
|
39
|
+
replacedBy: 'new-rule-id', // or null if there's no direct replacement
|
|
40
|
+
reason: 'Why this rule is being retired.',
|
|
41
|
+
sinceVersion: '1.2.0' // the package version this was first marked deprecated in
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`meta.deprecated: true` requires both `deprecation.reason` and `deprecation.sinceVersion` — `normalizeRuleMeta` (`src/core/rule-meta.js`) throws a clear build-time error otherwise, the same way it already validates `meta.i18n.titleKey`.
|
|
47
|
+
|
|
48
|
+
**A deprecated rule keeps running and producing results completely normally** — `pass`/`fail`/`cantTell`/`notApplicable` exactly as before. Deprecation is a catalog-level signal (visible via `getChecksCatalog()`, and in `docs/RULE_CATALOG.md`) for integrators to plan a migration on their own schedule — deliberately **not** an automatic exclusion (there is no `engineOptions.excludeDeprecated` flag). Silently dropping a rule's results the moment it's deprecated would be exactly the kind of surprise this document exists to prevent.
|
|
49
|
+
|
|
50
|
+
The process:
|
|
51
|
+
1. Mark the rule `deprecated: true` with `deprecation.reason`/`.replacedBy`/`.sinceVersion` set. Document it under `CHANGELOG.md`'s `### Deprecated` section (a standard Keep-a-Changelog category that's been in this project's changelog template since the beginning but never actually used until now).
|
|
52
|
+
2. Leave it running normally for at least one full minor version cycle after the deprecation, so integrators pinned to `^x.y.0` have a real chance to see it before it's gone.
|
|
53
|
+
3. Remove the rule file entirely in a future **major** version, documented under `### Removed`.
|
|
54
|
+
|
|
55
|
+
No rule has been deprecated yet as of this document's introduction — this is the mechanism, ready for the first real case.
|
|
56
|
+
|
|
57
|
+
## See also
|
|
58
|
+
|
|
59
|
+
- [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md) — the full result shape this document's stability rules apply to.
|
|
60
|
+
- [`RULE_AUTHORING.md`](./RULE_AUTHORING.md) §4.1 — the full rule `meta` contract, including `deprecated`/`deprecation`.
|
|
61
|
+
- [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md) — `engineOptions.fragment`, referenced above as a worked example of a patch-level behavior fix.
|
package/docs/BASELINE.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Baseline / allowlist
|
|
2
|
+
|
|
3
|
+
A strict CI gate ("fail the build on any `fail` outcome") is an adoption blocker for a team scanning an existing, imperfect site for the first time — they can't ship anything until every pre-existing violation is fixed. A baseline lets a team say "these N are already known — don't gate on them, but fail the moment a genuinely new one appears."
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
# Once: record every current fail occurrence, does not fail the build.
|
|
7
|
+
surea11y scan ./dist/index.html --write-baseline baseline.json
|
|
8
|
+
|
|
9
|
+
# Commit baseline.json to git.
|
|
10
|
+
|
|
11
|
+
# From then on, in CI: only a NEW (not-yet-baselined) fail occurrence gates the build.
|
|
12
|
+
surea11y scan ./dist/index.html --baseline baseline.json
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The baseline file is meant to be committed to git, so accepting more accessibility debt becomes an explicit, reviewable decision — a new violation shows up as a new row in the file's diff in a PR, not a silent change in a count.
|
|
16
|
+
|
|
17
|
+
## When this is useful
|
|
18
|
+
|
|
19
|
+
- **Adopting scanning on an existing site.** The motivating case above: a first scan on a mature site can turn up hundreds of pre-existing violations. Baselining them once unblocks gating immediately instead of requiring "fix everything first."
|
|
20
|
+
- **Incremental remediation with a visible burn-down.** Fix violations in batches, then periodically regenerate the baseline with `--write-baseline`. Each regeneration's git diff *shrinks* as entries disappear — the file itself becomes a progress tracker, and a PR that fixes a batch of issues shows exactly what got cleaned up.
|
|
21
|
+
- **Third-party/vendor content you don't control.** A page embeds a widget (chat bubble, ad unit, payment iframe) with known, unfixable-by-you violations. Baselining just those specific occurrences is more precise than excluding the whole subtree via `excludeSelectors` (see [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md)) — new issues introduced anywhere else near it, including inside your own code, still gate normally.
|
|
22
|
+
- **Regression safety net during a large refactor.** Mid-migration, some rules may legitimately fail temporarily in ways already tracked and being fixed across many PRs. Baselining the known-in-progress set still catches *unrelated* new regressions from other PRs during the same window, instead of turning the gate off entirely or blocking every PR on it.
|
|
23
|
+
- **Staged rollout of a new or stricter rule.** Turning on a rule (or a whole WCAG level) the codebase isn't clean against yet: baseline the existing gaps, enable gating immediately, and prevent any *new* violations of that rule while the backlog is cleaned up separately — rather than waiting to enable the rule until the codebase is already compliant.
|
|
24
|
+
|
|
25
|
+
## How matching works
|
|
26
|
+
|
|
27
|
+
Nothing in an occurrence's shape is a perfect, page-position-independent identity (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md)): `selector` and `structuralPath` are both derived from the element's position in the DOM, so either can shift when *unrelated* markup changes elsewhere on the page, even though the flagged element itself never changed.
|
|
28
|
+
|
|
29
|
+
Instead, a baseline entry's identity is:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
ruleId + reasonCode + html
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
where `reasonCode` is the rule-specific code from `occurrence.data.details.reasonCode` (defaulting to `"DEFAULT"` when a rule doesn't set one), and `html` is the occurrence's outer-HTML snippet. This is content-based rather than position-based: it survives the flagged element moving around the page (a reorder, an unrelated sibling added/removed) as long as the flagged element's own markup doesn't change. `selector` is still recorded in the baseline file, but purely for human context when reading a diff — it is never used for matching.
|
|
36
|
+
|
|
37
|
+
**Known limitation**: an element whose *own* markup includes dynamic content (a timestamp, a live counter, a randomly-generated id) will never match itself across two scans, since its `html` snippet differs every time. If your pages hit this case, the baseline mechanism won't help for those specific rules/elements — see "Alternative" below.
|
|
38
|
+
|
|
39
|
+
Matching counts occurrences, not just presence: if a page has 3 elements that produce byte-identical `ruleId`+`reasonCode`+`html` (e.g. the same broken component repeated 3 times) and the baseline recorded only 1 of them, a fresh scan reports 1 known and 2 new — not all 3 as known.
|
|
40
|
+
|
|
41
|
+
Baseline entries that don't match anything in a fresh scan are reported as **stale** (the violation was presumably fixed) — this is informational only and never gates the build; regenerate the baseline with `--write-baseline` periodically to clean these up.
|
|
42
|
+
|
|
43
|
+
## File format
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"version": 1,
|
|
48
|
+
"generatedAt": "2026-07-30T12:00:00.000Z",
|
|
49
|
+
"entries": [
|
|
50
|
+
{ "ruleId": "img-alt-present", "reasonCode": "DEFAULT", "selector": "html > body > img", "html": "<img src=\"logo.png\">" }
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`--baseline <path>` rejects a file that isn't `{ version: 1, entries: [...] }` with a clear exit-2 error rather than guessing at an older/different format.
|
|
56
|
+
|
|
57
|
+
## Combining with `--json`
|
|
58
|
+
|
|
59
|
+
When `--baseline` or `--write-baseline` is used together with `--json`, the printed object gains a `baseline` key alongside the normal engine result (this is CLI-output-only — it is not part of the engine's own result contract described in `OUTPUT_SCHEMA.md`):
|
|
60
|
+
|
|
61
|
+
- `--write-baseline`: `{ mode: "write", path, entries: <number written> }`
|
|
62
|
+
- `--baseline`: `{ mode: "check", totalFail, knownCount, newCount, staleCount, newOccurrences: [...] }`
|
|
63
|
+
|
|
64
|
+
## Alternative: diff two scans yourself
|
|
65
|
+
|
|
66
|
+
If your pages don't fit this model (heavy dynamic content inside the flagged elements themselves), the always-available fallback is to diff two full `--json` outputs yourself — see [`INTEGRATION.md`](./INTEGRATION.md#ci-gating-a-build-on-the-result). That gives you full control over the identity/matching logic at the cost of writing it yourself.
|
package/docs/CLI.md
CHANGED
|
@@ -21,6 +21,9 @@ The CLI reads **static HTML only** — a local file, or the raw response of an H
|
|
|
21
21
|
| `--exclude-rules <ids>` | Comma-separated rule IDs — never run these. |
|
|
22
22
|
| `--tags <tags>` | Comma-separated tags — e.g. `--tags wcag2a,wcag2aa` to target a conformance level (see [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md)). |
|
|
23
23
|
| `--context <selector>` | Scope the scan to one CSS-selected subtree. |
|
|
24
|
+
| `--write-baseline <path>` | Write every current `fail` occurrence to `<path>`; never fails the build. See [`BASELINE.md`](./BASELINE.md). |
|
|
25
|
+
| `--baseline <path>` | Gate only on occurrences not already recorded in `<path>`. See [`BASELINE.md`](./BASELINE.md). |
|
|
26
|
+
| `--html <path>` | Write a self-contained, browsable HTML report to `<path>`. See [`REPORT.md`](./REPORT.md). |
|
|
24
27
|
| `-h`, `--help` | Show usage. |
|
|
25
28
|
| `-v`, `--version` | Show the installed version. |
|
|
26
29
|
|
|
@@ -36,6 +39,29 @@ These map directly onto `engineOptions`/`runOnly` (see [`ENGINE_OPTIONS.md`](./E
|
|
|
36
39
|
|
|
37
40
|
`cantTell` outcomes never affect the exit code — they're printed as a "needs human review" summary, consistent with the manual/`cantTell` mental model in [`TROUBLESHOOTING.md`](./TROUBLESHOOTING.md#should-i-treat-canttell-as-a-failure). If you need `cantTell`-aware gating, use `--json` and inspect `checksResults` yourself, or call the library directly (see [`INTEGRATION.md`](./INTEGRATION.md#ci-gating-a-build-on-the-result)).
|
|
38
41
|
|
|
42
|
+
With `--baseline`, exit code `1` means at least one *new* (not-yet-baselined) `fail` occurrence, not any `fail` occurrence — see [`BASELINE.md`](./BASELINE.md).
|
|
43
|
+
|
|
44
|
+
## Baseline / allowlist
|
|
45
|
+
|
|
46
|
+
For an existing, imperfect site, gating on every `fail` on day one is often an adoption blocker. `--write-baseline`/`--baseline` let you accept the current state once and gate CI only on genuinely new violations from then on:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
surea11y scan ./dist/index.html --write-baseline baseline.json # once, commit the file
|
|
50
|
+
surea11y scan ./dist/index.html --baseline baseline.json # in CI, from then on
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
See [`BASELINE.md`](./BASELINE.md) for the matching semantics, file format, and known limitations.
|
|
54
|
+
|
|
55
|
+
## HTML report
|
|
56
|
+
|
|
57
|
+
For a browsable view of a scan's results — hero summary, WCAG rollup grouped by conformance level, and a searchable/filterable occurrence table — rather than raw JSON or a terminal summary:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
surea11y scan ./dist/index.html --html report.html
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Open `report.html` directly from disk; no server, no external assets. Works alongside any other output mode. See [`REPORT.md`](./REPORT.md).
|
|
64
|
+
|
|
39
65
|
## In CI
|
|
40
66
|
|
|
41
67
|
```sh
|
package/docs/ENGINE_OPTIONS.md
CHANGED
|
@@ -74,6 +74,7 @@ const engineOptions = {
|
|
|
74
74
|
locale: 'en', // default 'en'; falls back to 'en' per-string if a key is missing in the requested locale
|
|
75
75
|
includeHiddenElements: false, // default false — set true to evaluate hidden/collapsed subtrees too
|
|
76
76
|
includeShadowDom: true, // default true — opt OUT with `false` to skip open shadow roots
|
|
77
|
+
fragment: false, // default false — set true when the scan target isn't a real page (see below)
|
|
77
78
|
excludeSelectors: ['#cookie-banner', '.third-party-widget'], // array or comma-separated string
|
|
78
79
|
timestamp: '2026-07-20T12:00:00Z', // optional — engine has no built-in clock, see OUTPUT_SCHEMA.md
|
|
79
80
|
perfStats: false, // default false — internal timing counters, debug-only shape
|
|
@@ -117,6 +118,7 @@ const engineOptions = {
|
|
|
117
118
|
| `locale` | Any string; resolution is per-string with graceful fallback (requested locale → `en` → the rule's literal English fallback text), so a partially-translated locale never produces missing text. See [`I18N.md`](./I18N.md) for current locale coverage. |
|
|
118
119
|
| `includeHiddenElements` | Default `false`: helper queries exclude elements hidden by structural/CSS mechanisms such as `display:none`, `[hidden]`, closed `<details>`, and hidden rendering-only host elements (with descendants excluded too). Set `true` to include those hidden/collapsed subtrees in evaluation (legacy/static-markup behavior). |
|
|
119
120
|
| `includeShadowDom` | Default `true`: rules using `helpers.queryAllSmart` traverse into open shadow roots. Set `false` to scan only the light DOM. Closed shadow roots are never reachable either way (no DOM API exposes them). |
|
|
121
|
+
| `fragment` | Default `false`. A handful of rules check for the presence of a property that exists once per real page — `page-title-present`, `html-lang-attr-present`, `html-xml-lang-mismatch`, `aria-hidden-body`, `css-orientation-lock`, `meta-refresh-no-exceptions`, `meta-refresh-timing-absent`, `meta-viewport-zoom-enabled`, `meta-viewport-large`, `page-title-patterns`, `region`, `bypass-blocks-present`, `landmark-one-main`, `page-has-heading-one` — and correctly report `notApplicable` for these once `contextSelector` has scoped a run narrower than the whole document (`document.documentElement` no longer among the resolved roots), since a scoped subtree was never expected to carry its own `<title>`/`<html lang>`/etc. Set `fragment: true` for the case that scoping alone can't detect: a scan target that's the *whole* given document but was never meant to represent a real page at all (e.g. a raw component snippet parsed on its own) — this forces the same `notApplicable` gating even when unscoped. See `RULE_AUTHORING.md` §11.2 ("Whole-document checks") for the underlying rule-authoring convention, and `helpers.isWholeDocumentScope()` (`src/core/dom-helpers.js`) for the mechanism these 14 rules gate on via their `applicability(ctx)` export. |
|
|
120
122
|
| `excludeSelectors` | Elements matching any of these selectors (and their descendants) are skipped entirely, for **every** rule — useful for cookie banners, third-party embeds, or known-noisy widgets you don't control. To exclude something from just one specific rule instead, use `rules[ruleId].excludeSelectors` below. |
|
|
121
123
|
| `timestamp` | Passed straight through to the result's top-level `timestamp` field; the engine does not generate one itself (deterministic-by-design). |
|
|
122
124
|
| `contrast.mode` | `strictConformance` (default): contrast rules stay silent (`notApplicable`/skip) whenever the true rendered background isn't confidently computable, to protect against false `fail`s. `auditorAssist`: trades some of that safety margin for more findings, intended for a human auditor who will double-check flagged cases, not for unattended CI gating. |
|
package/docs/INTEGRATION.md
CHANGED
|
@@ -112,7 +112,7 @@ if (failures.length > 0) {
|
|
|
112
112
|
|
|
113
113
|
Notes for CI specifically:
|
|
114
114
|
- `cantTell` outcomes are advisory by design (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md#outcome-values)) — most teams log them without failing the build, since they require human judgment the CI run can't make.
|
|
115
|
-
-
|
|
115
|
+
- For "only fail on *new* violations," the CLI has a built-in baseline/allowlist mechanism (`--write-baseline`/`--baseline`, see [`BASELINE.md`](./BASELINE.md)). Calling the library directly, the same matching logic is available as `buildBaselineEntries(result)`/`matchBaseline(result, baselineEntries)` from `require('@surea11y/core/src/baseline')` — or diff `checksResults` against a saved prior run yourself if your pages don't fit that model (see `BASELINE.md`'s "known limitation").
|
|
116
116
|
- Prefer Pattern 1 (jsdom) in CI unless you specifically need real-browser layout — it avoids the extra weight of a Puppeteer/Playwright + browser-binary install in your pipeline.
|
|
117
117
|
|
|
118
118
|
## Browser extension context
|
package/docs/OUTPUT_SCHEMA.md
CHANGED
|
@@ -30,7 +30,7 @@ This is the exact shape of the object returned by `runDomRulesInPage(...)` / `ru
|
|
|
30
30
|
| Field | Meaning |
|
|
31
31
|
|---|---|
|
|
32
32
|
| `engine.tag` | The engine's own identity tag, currently `"a11ycore"`. Every rule (built-in or custom) carries it in `meta.tags` — rule `ruleId`s themselves are bare (no prefix). |
|
|
33
|
-
| `engine.schemaVersion` | The result-schema version (`"1.0.0"`). Bump-worthy if this document's shape ever changes incompatibly — pin to it if you're parsing output programmatically. |
|
|
33
|
+
| `engine.schemaVersion` | The result-schema version (`"1.0.0"`). Bump-worthy if this document's shape ever changes incompatibly — pin to it if you're parsing output programmatically. See [`API_STABILITY.md`](./API_STABILITY.md) for the full stable/unstable field list and version-bump policy. |
|
|
34
34
|
| `url` | The `pageUrl` argument you passed in, or `document.location.href` if you passed `null`/omitted it, or `null` if neither is available. |
|
|
35
35
|
| `title` | `document.title` at scan time, or `null`. |
|
|
36
36
|
| `timestamp` | **Not auto-generated.** Only set if you pass `engineOptions.timestamp` as a non-empty string — the engine has no built-in clock (deterministic-by-design). If you want a scan timestamp in the result, supply it yourself. |
|
package/docs/REPORT.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# HTML report
|
|
2
|
+
|
|
3
|
+
A self-contained HTML report you can open in a browser after a scan — no server, no external assets, no network requests. Built for QA/manual testers who want a browsable view of a scan's results rather than raw JSON or a terminal summary.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
surea11y scan ./dist/index.html --html report.html
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Open `report.html` directly from disk. Works alongside any other output mode — `--html` doesn't replace `--json`/the default summary, it's an additional artifact written to the given path.
|
|
10
|
+
|
|
11
|
+
## What it shows
|
|
12
|
+
|
|
13
|
+
- **Hero**: one plain-language headline ("N of M applicable checks passed") plus a stacked bar and legend (icon + label + count — status is never color-only) broken down by outcome (`fail`/`cantTell`/`pass`/`notApplicable`).
|
|
14
|
+
- **Worth reviewing**: one card per rule with `fail`/`cantTell` occurrences (not one per occurrence — a rule with many identical occurrences is one thing worth attention, not many), each showing severity, WCAG SC chip(s), a representative occurrence, and the total occurrence count. Capped at the 24 highest-priority rules with an overflow note past that.
|
|
15
|
+
- **WCAG rollup**: grouped by conformance level (A / AA / AAA), sourced directly from the engine's own `rulesResults[]` composite rollups (`docs/WCAG_CONFORMANCE.md`) — one row per Success Criterion, its outcome, a pass/fail/needs-review/n/a breakdown, and which atomic rules contributed. This is real engine data, not an invented grouping — the same rollup you'd get from the raw JSON's `rulesResults`.
|
|
16
|
+
- **Full technical data** (collapsed by default): a scorecard (tiles per outcome) and a searchable, filterable (by outcome), paginated table of every individual occurrence across the whole scan.
|
|
17
|
+
|
|
18
|
+
## Library usage
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
const { renderHtmlReport } = require('@surea11y/core/src/report');
|
|
22
|
+
const { runDomRulesInPage } = require('@surea11y/core');
|
|
23
|
+
|
|
24
|
+
const result = runDomRulesInPage(url, null, {}, null);
|
|
25
|
+
const html = renderHtmlReport(result, { title: 'My scan report' });
|
|
26
|
+
require('fs').writeFileSync('report.html', html);
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`renderHtmlReport(result, options)` is a pure function — it returns a string, it never touches the filesystem itself (the CLI's `--html` flag does the writing). `options.title` is optional (defaults to `"surea11y scan report"`).
|
|
30
|
+
|
|
31
|
+
## Scope
|
|
32
|
+
|
|
33
|
+
This is a single-scan report — one point-in-time snapshot, not a dashboard tracking results across many scans over time. Multi-run history/trend tracking is a separate, larger concern (see the project roadmap's "Enterprise/compliance features" — historical trend tracking across scans) and isn't part of this tool.
|
package/docs/RULE_AUTHORING.md
CHANGED
|
@@ -144,6 +144,23 @@ Each atomic rule declares which “facet(s)” of an SC it covers.
|
|
|
144
144
|
|
|
145
145
|
Keep facet naming consistent across a family.
|
|
146
146
|
|
|
147
|
+
#### `meta.deprecated` / `meta.deprecation`
|
|
148
|
+
Optional — how to retire a rule ID without breaking downstream consumers. See [`API_STABILITY.md`](./API_STABILITY.md) for the full policy (a deprecated rule keeps running normally; this is a catalog-level migration signal, not an automatic exclusion). Shape:
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
const meta = {
|
|
152
|
+
// ...
|
|
153
|
+
deprecated: true,
|
|
154
|
+
deprecation: {
|
|
155
|
+
replacedBy: 'new-rule-id', // or null
|
|
156
|
+
reason: 'Why this rule is being retired.',
|
|
157
|
+
sinceVersion: '1.2.0'
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`deprecated: true` without both `deprecation.reason` and `.sinceVersion` throws at build time (`normalizeRuleMeta`, `src/core/rule-meta.js`).
|
|
163
|
+
|
|
147
164
|
---
|
|
148
165
|
|
|
149
166
|
## 5) i18n in occurrences (repo reality)
|
|
@@ -325,6 +342,20 @@ why:
|
|
|
325
342
|
outcome is demonstrable per fixture file. Pick the most illustrative FAIL case; note
|
|
326
343
|
that PASS/other branches are covered by the rule's inline unit tests instead of
|
|
327
344
|
minting near-duplicate fixture files.
|
|
345
|
+
|
|
346
|
+
This category isn't just a fixture-authoring convention — it now backs a real
|
|
347
|
+
behavioral contract. These 14 rules (`page-title-present`, `html-lang-attr-present`,
|
|
348
|
+
`html-xml-lang-mismatch`, `aria-hidden-body`, `css-orientation-lock`,
|
|
349
|
+
`meta-refresh-no-exceptions`, `meta-refresh-timing-absent`, `meta-viewport-zoom-enabled`,
|
|
350
|
+
`meta-viewport-large`, `page-title-patterns`, `region`, `bypass-blocks-present`,
|
|
351
|
+
`landmark-one-main`, `page-has-heading-one`) each export an `applicability(ctx)`
|
|
352
|
+
gating on `helpers.isWholeDocumentScope()` (`src/core/dom-helpers.js`) — `notApplicable`
|
|
353
|
+
when `contextSelector` scoped the run narrower than the whole document, or when
|
|
354
|
+
`engineOptions.fragment: true` was set (see `ENGINE_OPTIONS.md`). A scoped subtree
|
|
355
|
+
or a bare component fragment was never expected to carry its own `<title>`/`<html lang>`/
|
|
356
|
+
page-wide landmark structure, so flagging its absence there is a false positive, not a
|
|
357
|
+
real finding. If you add a new rule to this category, add the same `applicability`
|
|
358
|
+
export rather than letting it silently evaluate document-wide facts regardless of scope.
|
|
328
359
|
- **Runtime-mutation-only branches** (e.g. `iframe-focusable-content`'s FAIL branch,
|
|
329
360
|
which requires mutating `iframe.contentDocument` after parse — jsdom does not
|
|
330
361
|
populate `srcdoc` synchronously): cover every branch that IS expressible statically;
|
package/package.json
CHANGED
package/src/baseline.js
ADDED
|
Binary file
|
|
@@ -48,6 +48,14 @@ const meta = {
|
|
|
48
48
|
}
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
52
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
53
|
+
// scoped this run narrower than the whole document, or when
|
|
54
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
55
|
+
function applicability(ctx) {
|
|
56
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
57
|
+
}
|
|
58
|
+
|
|
51
59
|
function runInPage(ctx) {
|
|
52
60
|
const { document, helpers, rule } = ctx;
|
|
53
61
|
|
|
@@ -84,4 +92,4 @@ function runInPage(ctx) {
|
|
|
84
92
|
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'critical', occurrences };
|
|
85
93
|
}
|
|
86
94
|
|
|
87
|
-
module.exports = { id, meta, runInPage };
|
|
95
|
+
module.exports = { id, meta, runInPage, applicability };
|