@diegovelasquezweb/a11y-engine 0.1.3 → 0.1.4
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 +26 -0
- package/README.md +83 -12
- package/assets/engine/cdp-checks.json +30 -0
- package/assets/engine/pa11y-config.json +53 -0
- package/docs/architecture.md +119 -40
- package/docs/cli-handbook.md +30 -2
- package/docs/outputs.md +67 -9
- package/package.json +5 -1
- package/scripts/audit.mjs +3 -0
- package/scripts/core/asset-loader.mjs +4 -0
- package/scripts/engine/analyzer.mjs +8 -1
- package/scripts/engine/dom-scanner.mjs +94 -39
- package/scripts/index.mjs +262 -0
package/docs/outputs.md
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
## Table of Contents
|
|
8
8
|
|
|
9
9
|
- [Default output directory](#default-output-directory)
|
|
10
|
+
- [progress.json](#progressjson)
|
|
10
11
|
- [a11y-scan-results.json](#a11y-scan-resultsjson)
|
|
11
12
|
- [a11y-findings.json](#a11y-findingsjson)
|
|
12
13
|
- [remediation.md](#remediationmd)
|
|
@@ -23,7 +24,8 @@ All artifacts are written to `.audit/` relative to the package root (`SKILL_ROOT
|
|
|
23
24
|
|
|
24
25
|
```
|
|
25
26
|
.audit/
|
|
26
|
-
├──
|
|
27
|
+
├── progress.json # real-time scan progress (per-engine steps + counts)
|
|
28
|
+
├── a11y-scan-results.json # merged raw results from axe + CDP + pa11y
|
|
27
29
|
├── a11y-findings.json # enriched findings (primary data artifact)
|
|
28
30
|
├── remediation.md # AI agent remediation guide
|
|
29
31
|
├── report.html # interactive dashboard (--with-reports)
|
|
@@ -36,25 +38,62 @@ All artifacts are written to `.audit/` relative to the package root (`SKILL_ROOT
|
|
|
36
38
|
|
|
37
39
|
---
|
|
38
40
|
|
|
41
|
+
## progress.json
|
|
42
|
+
|
|
43
|
+
Real-time scan progress written by `scripts/engine/dom-scanner.mjs` as each engine runs. Used by integrations for live progress UI.
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"steps": {
|
|
48
|
+
"page": { "status": "done", "updatedAt": "2026-03-14T14:02:50.609Z" },
|
|
49
|
+
"axe": { "status": "done", "updatedAt": "2026-03-14T14:02:51.389Z", "found": 8 },
|
|
50
|
+
"cdp": { "status": "done", "updatedAt": "2026-03-14T14:02:51.401Z", "found": 3 },
|
|
51
|
+
"pa11y": { "status": "done", "updatedAt": "2026-03-14T14:02:55.667Z", "found": 2 },
|
|
52
|
+
"merge": { "status": "done", "updatedAt": "2026-03-14T14:02:55.668Z", "axe": 8, "cdp": 3, "pa11y": 2, "merged": 11 }
|
|
53
|
+
},
|
|
54
|
+
"currentStep": "merge"
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Step keys
|
|
59
|
+
|
|
60
|
+
| Key | Engine | Description |
|
|
61
|
+
| :--- | :--- | :--- |
|
|
62
|
+
| `page` | — | Page navigation and load |
|
|
63
|
+
| `axe` | axe-core | axe-core WCAG rule scan. `found` = violation count. |
|
|
64
|
+
| `cdp` | CDP | Chrome DevTools Protocol accessibility tree check. `found` = issue count. |
|
|
65
|
+
| `pa11y` | pa11y | HTML CodeSniffer scan. `found` = issue count. |
|
|
66
|
+
| `merge` | — | Cross-engine merge and deduplication. `merged` = final unique count. |
|
|
67
|
+
|
|
68
|
+
### Step statuses
|
|
69
|
+
|
|
70
|
+
`pending` → `running` → `done` (or `error`)
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
39
74
|
## a11y-scan-results.json
|
|
40
75
|
|
|
41
|
-
|
|
76
|
+
Merged results from all three engines (axe-core + CDP + pa11y) per route. Written by `scripts/engine/dom-scanner.mjs`.
|
|
42
77
|
|
|
43
78
|
```json
|
|
44
79
|
{
|
|
80
|
+
"generated_at": "2026-03-14T14:02:55.668Z",
|
|
81
|
+
"base_url": "https://example.com",
|
|
82
|
+
"projectContext": { "framework": "nextjs", "uiLibraries": ["radix-ui"] },
|
|
45
83
|
"routes": [
|
|
46
84
|
{
|
|
47
85
|
"path": "/",
|
|
48
86
|
"url": "https://example.com/",
|
|
49
87
|
"violations": [...],
|
|
50
88
|
"incomplete": [...],
|
|
51
|
-
"passes": [...]
|
|
52
|
-
"inapplicable": [...]
|
|
89
|
+
"passes": [...]
|
|
53
90
|
}
|
|
54
91
|
]
|
|
55
92
|
}
|
|
56
93
|
```
|
|
57
94
|
|
|
95
|
+
Each violation in the `violations` array includes a `source` field indicating which engine produced it (`undefined` for axe-core, `"cdp"` for CDP checks, `"pa11y"` for pa11y).
|
|
96
|
+
|
|
58
97
|
This file is consumed by `analyzer.mjs` and also used by `--affected-only` to determine which routes to re-scan on subsequent runs.
|
|
59
98
|
|
|
60
99
|
---
|
|
@@ -68,7 +107,8 @@ The primary enriched data artifact. Written by `scripts/engine/analyzer.mjs`. Th
|
|
|
68
107
|
```json
|
|
69
108
|
{
|
|
70
109
|
"metadata": { ... },
|
|
71
|
-
"findings": [ ... ]
|
|
110
|
+
"findings": [ ... ],
|
|
111
|
+
"incomplete_findings": [ ... ]
|
|
72
112
|
}
|
|
73
113
|
```
|
|
74
114
|
|
|
@@ -92,7 +132,8 @@ The primary enriched data artifact. Written by `scripts/engine/analyzer.mjs`. Th
|
|
|
92
132
|
| Field | Type | Description |
|
|
93
133
|
| :--- | :--- | :--- |
|
|
94
134
|
| `id` | `string` | Deterministic finding ID (e.g. `A11Y-001`) |
|
|
95
|
-
| `rule_id` | `string` |
|
|
135
|
+
| `rule_id` | `string` | Rule ID from the source engine (e.g. `color-contrast`, `cdp-missing-accessible-name`, `pa11y-wcag2aa-...`) |
|
|
136
|
+
| `source_rule_id` | `string\|null` | Original rule ID when mapped from CDP/pa11y to axe equivalent |
|
|
96
137
|
| `title` | `string` | Human-readable finding title |
|
|
97
138
|
| `severity` | `string` | `Critical`, `Serious`, `Moderate`, or `Minor` |
|
|
98
139
|
| `wcag` | `string` | WCAG success criterion (e.g. `1.4.3`) |
|
|
@@ -108,7 +149,7 @@ The primary enriched data artifact. Written by `scripts/engine/analyzer.mjs`. Th
|
|
|
108
149
|
| `category` | `string` | Violation category (e.g. `Color & Contrast`) |
|
|
109
150
|
| `primary_failure_mode` | `string\|null` | Root cause classification |
|
|
110
151
|
| `relationship_hint` | `string\|null` | Label/input relationship context |
|
|
111
|
-
| `failure_checks` | `object[]` |
|
|
152
|
+
| `failure_checks` | `object[]` | Engine check-level failure details |
|
|
112
153
|
| `related_context` | `object[]` | Surrounding DOM context |
|
|
113
154
|
| `fix_description` | `string\|null` | Plain-language fix explanation |
|
|
114
155
|
| `fix_code` | `string\|null` | Ready-to-apply code snippet |
|
|
@@ -116,13 +157,13 @@ The primary enriched data artifact. Written by `scripts/engine/analyzer.mjs`. Th
|
|
|
116
157
|
| `recommended_fix` | `string` | Link to canonical fix reference (APG, MDN) |
|
|
117
158
|
| `mdn` | `string\|null` | MDN documentation URL |
|
|
118
159
|
| `effort` | `string\|null` | Fix effort estimate (`low`, `medium`, `high`) |
|
|
119
|
-
| `related_rules` | `string[]` | Related
|
|
160
|
+
| `related_rules` | `string[]` | Related rule IDs |
|
|
120
161
|
| `guardrails` | `object\|null` | Agent scope guardrails for this finding |
|
|
121
162
|
| `false_positive_risk` | `string\|null` | Known false positive patterns |
|
|
122
163
|
| `fix_difficulty_notes` | `string\|null` | Edge cases and pitfalls for this fix |
|
|
123
164
|
| `framework_notes` | `string\|null` | Framework-specific fix guidance |
|
|
124
165
|
| `cms_notes` | `string\|null` | CMS-specific fix guidance |
|
|
125
|
-
| `check_data` | `object\|null` | Raw
|
|
166
|
+
| `check_data` | `object\|null` | Raw engine check data |
|
|
126
167
|
| `total_instances` | `number` | Count of affected elements across all pages |
|
|
127
168
|
| `evidence` | `object[]` | DOM HTML snippets for each affected element |
|
|
128
169
|
| `screenshot_path` | `string\|null` | Path to element screenshot |
|
|
@@ -138,6 +179,10 @@ The primary enriched data artifact. Written by `scripts/engine/analyzer.mjs`. Th
|
|
|
138
179
|
| `pages_affected` | `number\|null` | Number of pages with this violation |
|
|
139
180
|
| `affected_urls` | `string[]\|null` | All URLs where this violation appears |
|
|
140
181
|
|
|
182
|
+
### `incomplete_findings`
|
|
183
|
+
|
|
184
|
+
Violations that axe-core flagged as "needs review" (not confirmed pass or fail). Included for manual verification but not counted in the compliance score.
|
|
185
|
+
|
|
141
186
|
---
|
|
142
187
|
|
|
143
188
|
## remediation.md
|
|
@@ -236,6 +281,19 @@ const engineRoot = fs.realpathSync(symlinkBase);
|
|
|
236
281
|
const findingsPath = path.join(engineRoot, ".audit", "a11y-findings.json");
|
|
237
282
|
```
|
|
238
283
|
|
|
284
|
+
### Reading `progress.json` for live UI updates
|
|
285
|
+
|
|
286
|
+
```js
|
|
287
|
+
const progressPath = path.join(engineRoot, ".audit", "progress.json");
|
|
288
|
+
|
|
289
|
+
// Poll this file during scan execution
|
|
290
|
+
if (fs.existsSync(progressPath)) {
|
|
291
|
+
const progress = JSON.parse(fs.readFileSync(progressPath, "utf-8"));
|
|
292
|
+
console.log(`Current step: ${progress.currentStep}`);
|
|
293
|
+
console.log(`axe found: ${progress.steps?.axe?.found ?? "pending"}`);
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
239
297
|
### Parsing stdout markers
|
|
240
298
|
|
|
241
299
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diegovelasquezweb/a11y-engine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "WCAG 2.2 AA accessibility audit engine — scanner, analyzer, and report builders",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=18"
|
|
14
14
|
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./scripts/index.mjs"
|
|
17
|
+
},
|
|
18
|
+
"main": "scripts/index.mjs",
|
|
15
19
|
"bin": {
|
|
16
20
|
"a11y-audit": "scripts/audit.mjs"
|
|
17
21
|
},
|
package/scripts/audit.mjs
CHANGED
|
@@ -31,6 +31,7 @@ Targeting & Scope:
|
|
|
31
31
|
|
|
32
32
|
Audit Intelligence:
|
|
33
33
|
--target <text> Compliance target label (default: "WCAG 2.2 AA").
|
|
34
|
+
--axe-tags <csv> Comma-separated axe tags (e.g., wcag2a,wcag2aa).
|
|
34
35
|
--only-rule <id> Only check for this specific rule ID.
|
|
35
36
|
--ignore-findings <csv> Ignore specific rule IDs.
|
|
36
37
|
--exclude-selectors <csv> Exclude CSS selectors from scan.
|
|
@@ -136,6 +137,7 @@ async function main() {
|
|
|
136
137
|
const routes = getArgValue("routes");
|
|
137
138
|
const waitMs = getArgValue("wait-ms") || DEFAULTS.waitMs;
|
|
138
139
|
const timeoutMs = getArgValue("timeout-ms") || DEFAULTS.timeoutMs;
|
|
140
|
+
const axeTags = getArgValue("axe-tags");
|
|
139
141
|
|
|
140
142
|
const sessionFile = getInternalPath("a11y-session.json");
|
|
141
143
|
let projectDir = getArgValue("project-dir");
|
|
@@ -254,6 +256,7 @@ async function main() {
|
|
|
254
256
|
if (viewport) {
|
|
255
257
|
scanArgs.push("--viewport", `${viewport.width}x${viewport.height}`);
|
|
256
258
|
}
|
|
259
|
+
if (axeTags) scanArgs.push("--axe-tags", axeTags);
|
|
257
260
|
|
|
258
261
|
await runScript("engine/dom-scanner.mjs", scanArgs, childEnv);
|
|
259
262
|
|
|
@@ -34,6 +34,10 @@ export const ASSET_PATHS = {
|
|
|
34
34
|
),
|
|
35
35
|
codePatterns: path.join(ASSET_ROOT, "remediation", "code-patterns.json"),
|
|
36
36
|
},
|
|
37
|
+
engine: {
|
|
38
|
+
cdpChecks: path.join(ASSET_ROOT, "engine", "cdp-checks.json"),
|
|
39
|
+
pa11yConfig: path.join(ASSET_ROOT, "engine", "pa11y-config.json"),
|
|
40
|
+
},
|
|
37
41
|
reporting: {
|
|
38
42
|
wcagReference: path.join(ASSET_ROOT, "reporting", "wcag-reference.json"),
|
|
39
43
|
complianceConfig: path.join(
|
|
@@ -745,7 +745,12 @@ function computeTestingMethodology(payload) {
|
|
|
745
745
|
const scanned = routes.filter((r) => !r.error).length;
|
|
746
746
|
const errored = routes.filter((r) => r.error).length;
|
|
747
747
|
return {
|
|
748
|
-
automated_tools: [
|
|
748
|
+
automated_tools: [
|
|
749
|
+
"axe-core (via @axe-core/playwright)",
|
|
750
|
+
"CDP accessibility tree checks (Playwright)",
|
|
751
|
+
"pa11y (HTML CodeSniffer via Puppeteer)",
|
|
752
|
+
"Playwright + Chromium",
|
|
753
|
+
],
|
|
749
754
|
compliance_target: "WCAG 2.2 AA",
|
|
750
755
|
pages_scanned: scanned,
|
|
751
756
|
pages_errored: errored,
|
|
@@ -826,6 +831,8 @@ function buildFindings(inputPayload, cliArgs) {
|
|
|
826
831
|
findings.push({
|
|
827
832
|
id: "",
|
|
828
833
|
rule_id: v.id,
|
|
834
|
+
source_rule_id: v.source_rule_id || null,
|
|
835
|
+
source: v.source || "axe",
|
|
829
836
|
title: v.help,
|
|
830
837
|
severity: IMPACT_MAP[v.impact] || "Medium",
|
|
831
838
|
wcag: mapWcag(v.tags),
|
|
@@ -25,6 +25,14 @@ const STACK_DETECTION = loadAssetJson(
|
|
|
25
25
|
ASSET_PATHS.discovery.stackDetection,
|
|
26
26
|
"assets/discovery/stack-detection.json",
|
|
27
27
|
);
|
|
28
|
+
const CDP_CHECKS = loadAssetJson(
|
|
29
|
+
ASSET_PATHS.engine.cdpChecks,
|
|
30
|
+
"assets/engine/cdp-checks.json",
|
|
31
|
+
);
|
|
32
|
+
const PA11Y_CONFIG = loadAssetJson(
|
|
33
|
+
ASSET_PATHS.engine.pa11yConfig,
|
|
34
|
+
"assets/engine/pa11y-config.json",
|
|
35
|
+
);
|
|
28
36
|
const AXE_TAGS = [
|
|
29
37
|
"wcag2a",
|
|
30
38
|
"wcag2aa",
|
|
@@ -505,9 +513,14 @@ function writeProgress(step, status, extra = {}) {
|
|
|
505
513
|
*/
|
|
506
514
|
async function runCdpChecks(page) {
|
|
507
515
|
const violations = [];
|
|
516
|
+
const interactiveRoles = CDP_CHECKS.interactiveRoles || [];
|
|
517
|
+
const rulesById = {};
|
|
518
|
+
for (const rule of CDP_CHECKS.rules || []) {
|
|
519
|
+
rulesById[rule.condition] = rule;
|
|
520
|
+
}
|
|
521
|
+
|
|
508
522
|
try {
|
|
509
523
|
const cdp = await page.context().newCDPSession(page);
|
|
510
|
-
|
|
511
524
|
const { nodes } = await cdp.send("Accessibility.getFullAXTree");
|
|
512
525
|
|
|
513
526
|
for (const node of nodes) {
|
|
@@ -521,8 +534,9 @@ async function runCdpChecks(page) {
|
|
|
521
534
|
const focusable = properties.find((p) => p.name === "focusable")?.value?.value === true;
|
|
522
535
|
const hidden = properties.find((p) => p.name === "hidden")?.value?.value === true;
|
|
523
536
|
|
|
524
|
-
const interactiveRoles = ["button", "link", "textbox", "combobox", "listbox", "menuitem", "tab", "checkbox", "radio", "switch", "slider"];
|
|
525
537
|
if (interactiveRoles.includes(role) && !name.trim()) {
|
|
538
|
+
const rule = rulesById["interactive-no-name"];
|
|
539
|
+
if (!rule) continue;
|
|
526
540
|
const backendId = node.backendDOMNodeId;
|
|
527
541
|
let selector = "";
|
|
528
542
|
try {
|
|
@@ -543,13 +557,15 @@ async function runCdpChecks(page) {
|
|
|
543
557
|
}
|
|
544
558
|
} catch { /* fallback: no selector */ }
|
|
545
559
|
|
|
560
|
+
const desc = (rule.description || "").replace(/\{\{role\}\}/g, role);
|
|
561
|
+
const msg = (rule.failureMessage || "").replace(/\{\{role\}\}/g, role);
|
|
546
562
|
violations.push({
|
|
547
|
-
id:
|
|
548
|
-
impact:
|
|
549
|
-
tags:
|
|
550
|
-
description:
|
|
551
|
-
help:
|
|
552
|
-
helpUrl:
|
|
563
|
+
id: rule.id,
|
|
564
|
+
impact: rule.impact,
|
|
565
|
+
tags: rule.tags,
|
|
566
|
+
description: desc,
|
|
567
|
+
help: rule.help,
|
|
568
|
+
helpUrl: rule.helpUrl,
|
|
553
569
|
source: "cdp",
|
|
554
570
|
nodes: [{
|
|
555
571
|
any: [],
|
|
@@ -557,26 +573,30 @@ async function runCdpChecks(page) {
|
|
|
557
573
|
id: "cdp-accessible-name",
|
|
558
574
|
data: { role, name: "(empty)" },
|
|
559
575
|
relatedNodes: [],
|
|
560
|
-
impact:
|
|
561
|
-
message:
|
|
576
|
+
impact: rule.impact,
|
|
577
|
+
message: msg,
|
|
562
578
|
}],
|
|
563
579
|
none: [],
|
|
564
|
-
impact:
|
|
580
|
+
impact: rule.impact,
|
|
565
581
|
html: `<${role} aria-role="${role}">`,
|
|
566
582
|
target: selector ? [selector] : [`[role="${role}"]`],
|
|
567
|
-
failureSummary: `Fix all of the following:\n
|
|
583
|
+
failureSummary: `Fix all of the following:\n ${msg}`,
|
|
568
584
|
}],
|
|
569
585
|
});
|
|
570
586
|
}
|
|
571
587
|
|
|
572
588
|
if (hidden && focusable) {
|
|
589
|
+
const rule = rulesById["hidden-focusable"];
|
|
590
|
+
if (!rule) continue;
|
|
591
|
+
const desc = (rule.description || "").replace(/\{\{role\}\}/g, role);
|
|
592
|
+
const msg = (rule.failureMessage || "").replace(/\{\{role\}\}/g, role);
|
|
573
593
|
violations.push({
|
|
574
|
-
id:
|
|
575
|
-
impact:
|
|
576
|
-
tags:
|
|
577
|
-
description:
|
|
578
|
-
help:
|
|
579
|
-
helpUrl:
|
|
594
|
+
id: rule.id,
|
|
595
|
+
impact: rule.impact,
|
|
596
|
+
tags: rule.tags,
|
|
597
|
+
description: desc,
|
|
598
|
+
help: rule.help,
|
|
599
|
+
helpUrl: rule.helpUrl,
|
|
580
600
|
source: "cdp",
|
|
581
601
|
nodes: [{
|
|
582
602
|
any: [],
|
|
@@ -584,14 +604,14 @@ async function runCdpChecks(page) {
|
|
|
584
604
|
id: "cdp-hidden-focusable",
|
|
585
605
|
data: { role },
|
|
586
606
|
relatedNodes: [],
|
|
587
|
-
impact:
|
|
588
|
-
message:
|
|
607
|
+
impact: rule.impact,
|
|
608
|
+
message: msg,
|
|
589
609
|
}],
|
|
590
610
|
none: [],
|
|
591
|
-
impact:
|
|
611
|
+
impact: rule.impact,
|
|
592
612
|
html: `<element role="${role}" aria-hidden="true">`,
|
|
593
613
|
target: [`[role="${role}"]`],
|
|
594
|
-
failureSummary: `Fix all of the following:\n
|
|
614
|
+
failureSummary: `Fix all of the following:\n ${msg}`,
|
|
595
615
|
}],
|
|
596
616
|
});
|
|
597
617
|
}
|
|
@@ -614,6 +634,12 @@ async function runCdpChecks(page) {
|
|
|
614
634
|
*/
|
|
615
635
|
async function runPa11yChecks(routeUrl, axeTags) {
|
|
616
636
|
const violations = [];
|
|
637
|
+
const equivalenceMap = PA11Y_CONFIG.equivalenceMap || {};
|
|
638
|
+
const impactMap = {};
|
|
639
|
+
for (const [k, v] of Object.entries(PA11Y_CONFIG.impactMap || {})) {
|
|
640
|
+
impactMap[Number(k)] = v;
|
|
641
|
+
}
|
|
642
|
+
|
|
617
643
|
try {
|
|
618
644
|
let standard = "WCAG2AA";
|
|
619
645
|
if (axeTags) {
|
|
@@ -622,6 +648,9 @@ async function runPa11yChecks(routeUrl, axeTags) {
|
|
|
622
648
|
else if (axeTags.includes("wcag2a")) standard = "WCAG2A";
|
|
623
649
|
}
|
|
624
650
|
|
|
651
|
+
// Build ignore list with dynamic standard prefix
|
|
652
|
+
const ignoreList = (PA11Y_CONFIG.ignoreByPrinciple || []).map((r) => `${standard}.${r}`);
|
|
653
|
+
|
|
625
654
|
const results = await pa11y(routeUrl, {
|
|
626
655
|
standard,
|
|
627
656
|
timeout: 30000,
|
|
@@ -629,14 +658,9 @@ async function runPa11yChecks(routeUrl, axeTags) {
|
|
|
629
658
|
chromeLaunchConfig: {
|
|
630
659
|
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
631
660
|
},
|
|
632
|
-
ignore:
|
|
633
|
-
"WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail",
|
|
634
|
-
"WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent",
|
|
635
|
-
],
|
|
661
|
+
ignore: ignoreList,
|
|
636
662
|
});
|
|
637
663
|
|
|
638
|
-
const impactMap = { 1: "serious", 2: "moderate", 3: "minor" };
|
|
639
|
-
|
|
640
664
|
for (const issue of results.issues || []) {
|
|
641
665
|
if (issue.type === "notice") continue;
|
|
642
666
|
|
|
@@ -648,7 +672,18 @@ async function runPa11yChecks(routeUrl, axeTags) {
|
|
|
648
672
|
wcagCriterion = `${wcagMatch[3]}.${wcagMatch[4]}.${wcagMatch[5]}`;
|
|
649
673
|
}
|
|
650
674
|
|
|
651
|
-
|
|
675
|
+
// Resolve axe-equivalent rule ID from equivalence map
|
|
676
|
+
const codeWithoutStandard = (issue.code || "").replace(/^WCAG2(A{1,3})\./, "");
|
|
677
|
+
let axeEquivId = null;
|
|
678
|
+
for (const [pattern, axeId] of Object.entries(equivalenceMap)) {
|
|
679
|
+
if (codeWithoutStandard.startsWith(pattern)) {
|
|
680
|
+
axeEquivId = axeId;
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const ruleId = axeEquivId || `pa11y-${(issue.code || "unknown").replace(/\./g, "-").toLowerCase().slice(0, 60)}`;
|
|
686
|
+
const originalCode = issue.code || "unknown";
|
|
652
687
|
|
|
653
688
|
violations.push({
|
|
654
689
|
id: ruleId,
|
|
@@ -660,11 +695,12 @@ async function runPa11yChecks(routeUrl, axeTags) {
|
|
|
660
695
|
? `https://www.w3.org/WAI/WCAG21/Understanding/${wcagCriterion.replace(/\./g, "")}`
|
|
661
696
|
: "https://squizlabs.github.io/HTML_CodeSniffer/",
|
|
662
697
|
source: "pa11y",
|
|
698
|
+
source_rule_id: originalCode,
|
|
663
699
|
nodes: [{
|
|
664
700
|
any: [],
|
|
665
701
|
all: [{
|
|
666
702
|
id: "pa11y-check",
|
|
667
|
-
data: { code:
|
|
703
|
+
data: { code: originalCode, context: issue.context?.slice(0, 200) },
|
|
668
704
|
relatedNodes: [],
|
|
669
705
|
impact,
|
|
670
706
|
message: issue.message || "",
|
|
@@ -693,20 +729,28 @@ async function runPa11yChecks(routeUrl, axeTags) {
|
|
|
693
729
|
*/
|
|
694
730
|
function mergeViolations(axeViolations, cdpViolations, pa11yViolations) {
|
|
695
731
|
const seen = new Set();
|
|
732
|
+
const seenRuleTargets = new Map(); // rule -> Set<target> for cross-engine dedup
|
|
696
733
|
const merged = [];
|
|
697
734
|
|
|
735
|
+
// Build CDP equivalence map from JSON config
|
|
736
|
+
const cdpAxeEquiv = {};
|
|
737
|
+
for (const rule of CDP_CHECKS.rules || []) {
|
|
738
|
+
cdpAxeEquiv[rule.id] = rule.axeEquivalents || [];
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Step 1: axe findings (baseline)
|
|
698
742
|
for (const v of axeViolations) {
|
|
699
|
-
const
|
|
743
|
+
const target = v.nodes?.[0]?.target?.[0] || "";
|
|
744
|
+
const key = `${v.id}::${target}`;
|
|
700
745
|
seen.add(key);
|
|
746
|
+
if (!seenRuleTargets.has(v.id)) seenRuleTargets.set(v.id, new Set());
|
|
747
|
+
seenRuleTargets.get(v.id).add(target);
|
|
701
748
|
merged.push(v);
|
|
702
749
|
}
|
|
703
750
|
|
|
751
|
+
// Step 2: CDP findings — check against axe equivalents from JSON
|
|
704
752
|
for (const v of cdpViolations) {
|
|
705
|
-
const
|
|
706
|
-
"cdp-missing-accessible-name": ["button-name", "link-name", "input-name", "aria-command-name"],
|
|
707
|
-
"cdp-aria-hidden-focusable": ["aria-hidden-focus"],
|
|
708
|
-
};
|
|
709
|
-
const equivRules = axeEquiv[v.id] || [];
|
|
753
|
+
const equivRules = cdpAxeEquiv[v.id] || [];
|
|
710
754
|
const target = v.nodes?.[0]?.target?.[0] || "";
|
|
711
755
|
const isDuplicate = equivRules.some((r) => seen.has(`${r}::${target}`));
|
|
712
756
|
if (!isDuplicate) {
|
|
@@ -718,12 +762,20 @@ function mergeViolations(axeViolations, cdpViolations, pa11yViolations) {
|
|
|
718
762
|
}
|
|
719
763
|
}
|
|
720
764
|
|
|
765
|
+
// Step 3: pa11y findings — check via canonical rule ID (axe-equivalent) + selector
|
|
721
766
|
for (const v of pa11yViolations) {
|
|
722
767
|
const target = v.nodes?.[0]?.target?.[0] || "";
|
|
723
768
|
const key = `${v.id}::${target}`;
|
|
724
|
-
|
|
725
|
-
if
|
|
769
|
+
|
|
770
|
+
// If pa11y was mapped to an axe rule ID, check if that rule already covers this target
|
|
771
|
+
const isAxeEquivDuplicate = v.id && seenRuleTargets.has(v.id) && target && seenRuleTargets.get(v.id).has(target);
|
|
772
|
+
// Also check if any existing finding covers this exact target (broader dedup)
|
|
773
|
+
const selectorCovered = target && [...seen].some((k) => k.endsWith(`::${target}`));
|
|
774
|
+
|
|
775
|
+
if (!seen.has(key) && !isAxeEquivDuplicate && (!selectorCovered || !target)) {
|
|
726
776
|
seen.add(key);
|
|
777
|
+
if (!seenRuleTargets.has(v.id)) seenRuleTargets.set(v.id, new Set());
|
|
778
|
+
seenRuleTargets.get(v.id).add(target);
|
|
727
779
|
merged.push(v);
|
|
728
780
|
}
|
|
729
781
|
}
|
|
@@ -806,6 +858,7 @@ async function main() {
|
|
|
806
858
|
* @type {Set<string>}
|
|
807
859
|
*/
|
|
808
860
|
const SKIP_SELECTORS = new Set(["html", "body", "head", ":root", "document"]);
|
|
861
|
+
const SKIP_SELECTOR_PREFIXES = ["meta", "link", "style", "script", "title", "base"];
|
|
809
862
|
|
|
810
863
|
/**
|
|
811
864
|
* Captures a screenshot of an element associated with an accessibility violation.
|
|
@@ -818,7 +871,9 @@ async function main() {
|
|
|
818
871
|
const firstNode = violation.nodes?.[0];
|
|
819
872
|
if (!firstNode || firstNode.target.length > 1) return;
|
|
820
873
|
const selector = firstNode.target[0];
|
|
821
|
-
|
|
874
|
+
const lowerSelector = (selector || "").toLowerCase();
|
|
875
|
+
if (!selector || SKIP_SELECTORS.has(lowerSelector)) return;
|
|
876
|
+
if (SKIP_SELECTOR_PREFIXES.some((p) => lowerSelector.startsWith(p))) return;
|
|
822
877
|
try {
|
|
823
878
|
fs.mkdirSync(args.screenshotsDir, { recursive: true });
|
|
824
879
|
const safeRuleId = violation.id.replace(/[^a-z0-9-]/g, "-");
|