@diegovelasquezweb/a11y-engine 0.1.1 → 0.1.3

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 ADDED
@@ -0,0 +1,58 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ---
11
+
12
+ ## [0.1.2] — 2026-03-13
13
+
14
+ ### Fixed
15
+
16
+ - `bin` field in `package.json` — removed leading `./` from the entry path (`scripts/audit.mjs`) to satisfy npm bin resolution
17
+ - `repository.url` normalized to `git+https://` prefix as required by npm registry validation
18
+ - Missing shebang (`#!/usr/bin/env node`) added to `scripts/audit.mjs` so the `a11y-audit` binary executes correctly when installed globally or via `npx`
19
+
20
+ ---
21
+
22
+ ## [0.1.1] — 2026-03-13
23
+
24
+ ### Added
25
+
26
+ - Engine scripts published as a standalone npm package:
27
+ - `scripts/audit.mjs` — orchestrator for the full audit pipeline
28
+ - `scripts/core/utils.mjs` — shared logging, path utilities, and defaults
29
+ - `scripts/core/toolchain.mjs` — dependency and Playwright browser verification
30
+ - `scripts/core/asset-loader.mjs` — JSON asset loading with error boundaries
31
+ - `scripts/engine/dom-scanner.mjs` — Playwright + axe-core WCAG 2.2 AA scanner
32
+ - `scripts/engine/analyzer.mjs` — finding enrichment with fix intelligence
33
+ - `scripts/engine/source-scanner.mjs` — static source code pattern scanner
34
+ - `scripts/reports/builders/` — orchestrators for each report format
35
+ - `scripts/reports/renderers/` — rendering logic for HTML, PDF, Markdown, and checklist
36
+ - Asset files bundled under `assets/`:
37
+ - `assets/reporting/compliance-config.json` — scoring weights, grade thresholds, and legal regulation mapping
38
+ - `assets/reporting/wcag-reference.json` — WCAG criterion map, persona config, and persona–rule mapping
39
+ - `assets/reporting/manual-checks.json` — 41 manual WCAG checks for the interactive checklist
40
+ - `assets/discovery/crawler-config.json` — BFS crawl configuration defaults
41
+ - `assets/discovery/stack-detection.json` — framework and CMS fingerprint signatures
42
+ - `assets/remediation/intelligence.json` — per-rule fix intelligence (106 axe-core rules)
43
+ - `assets/remediation/code-patterns.json` — source code pattern definitions
44
+ - `assets/remediation/guardrails.json` — agent fix guardrails and scope rules
45
+ - `assets/remediation/axe-check-maps.json` — axe check-to-rule mapping
46
+ - `assets/remediation/source-boundaries.json` — framework-specific file location patterns
47
+ - `a11y-audit` binary registered in `bin` field — invocable via `npx a11y-audit` after install
48
+ - `LICENSE` (MIT)
49
+
50
+ ---
51
+
52
+ ## [0.1.0] — 2026-03-13
53
+
54
+ ### Added
55
+
56
+ - Initial package scaffold: `package.json` for `@diegovelasquezweb/a11y-engine` with correct `name`, `version`, `type: module`, `engines`, `files`, and `scripts` fields
57
+ - `devDependencies`: `vitest` for test runner
58
+ - `dependencies`: `playwright`, `@axe-core/playwright`, `axe-core`, `pa11y`
package/README.md CHANGED
@@ -1,20 +1,162 @@
1
1
  # @diegovelasquezweb/a11y-engine
2
2
 
3
- WCAG 2.2 AA accessibility audit engine. Scanner, analyzer, and report builders.
3
+ WCAG 2.2 AA accessibility audit engine. Runs Playwright + axe-core scans, enriches findings with fix intelligence, and produces structured artifacts for developers, agents, and stakeholders.
4
4
 
5
- ## Install
5
+ ## What it is
6
+
7
+ A Node.js CLI and programmatic engine that:
8
+
9
+ 1. Crawls a target URL and discovers routes automatically
10
+ 2. Runs axe-core WCAG 2.2 AA checks across all discovered pages
11
+ 3. Optionally scans project source code for patterns axe cannot detect at runtime
12
+ 4. Enriches each finding with stack-aware fix guidance, selectors, and verification commands
13
+ 5. Produces a full artifact set: JSON data, Markdown remediation guide, HTML dashboard, PDF compliance report, and manual testing checklist
14
+
15
+ ## Why use this engine
16
+
17
+ | Capability | With this engine | Without |
18
+ | :--- | :--- | :--- |
19
+ | **Full WCAG 2.2 Coverage** | axe-core runtime scan + source code pattern scanner | Runtime scan only — misses CSS/source-level issues |
20
+ | **Fix Intelligence** | Stack-aware patches with code snippets tailored to detected framework | Raw rule violations with no remediation context |
21
+ | **Structured Artifacts** | JSON + Markdown + HTML + PDF + Checklist — ready to consume or forward | Findings exist only in the terminal session |
22
+ | **CI/Agent Integration** | Deterministic exit codes, stdout-parseable output paths, JSON schema | Requires wrapper scripting |
23
+
24
+ ## Installation
6
25
 
7
26
  ```bash
8
- npm i @diegovelasquezweb/a11y-engine
27
+ npm install @diegovelasquezweb/a11y-engine
9
28
  npx playwright install chromium
10
29
  ```
11
30
 
12
- ## Usage
31
+ ```bash
32
+ pnpm add @diegovelasquezweb/a11y-engine
33
+ pnpm exec playwright install chromium
34
+ ```
35
+
36
+ > Chromium must be installed separately. The engine uses Playwright's bundled browser — not a system Chrome.
37
+
38
+ ## Quick start
39
+
40
+ ```bash
41
+ # Minimal scan — produces remediation.md in .audit/
42
+ npx a11y-audit --base-url https://example.com
43
+
44
+ # Full audit with all reports
45
+ npx a11y-audit --base-url https://example.com --with-reports --output ./audit/report.html
46
+
47
+ # Scan with source code intelligence (for stack-aware fix guidance)
48
+ npx a11y-audit --base-url http://localhost:3000 --project-dir . --with-reports --output ./audit/report.html
49
+ ```
50
+
51
+ ## CLI usage
52
+
53
+ ```
54
+ a11y-audit --base-url <url> [options]
55
+ ```
56
+
57
+ ### Targeting & scope
58
+
59
+ | Flag | Argument | Default | Description |
60
+ | :--- | :--- | :--- | :--- |
61
+ | `--base-url` | `<url>` | (Required) | Starting URL for the audit. |
62
+ | `--max-routes` | `<num>` | `10` | Max routes to discover and scan. |
63
+ | `--crawl-depth` | `<num>` | `2` | BFS link-follow depth during discovery (1–3). |
64
+ | `--routes` | `<csv>` | — | Explicit path list, bypasses auto-discovery. |
65
+ | `--project-dir` | `<path>` | — | Path to project source. Enables source pattern scanner and framework auto-detection. |
66
+
67
+ ### Audit intelligence
68
+
69
+ | Flag | Argument | Default | Description |
70
+ | :--- | :--- | :--- | :--- |
71
+ | `--target` | `<text>` | `WCAG 2.2 AA` | Compliance target label in reports. |
72
+ | `--only-rule` | `<id>` | — | Run a single axe rule (e.g. `color-contrast`). |
73
+ | `--ignore-findings` | `<csv>` | — | Rule IDs to exclude from output. |
74
+ | `--exclude-selectors` | `<csv>` | — | CSS selectors to skip during DOM scan. |
75
+ | `--framework` | `<name>` | — | Override auto-detected stack. Supported: `nextjs`, `gatsby`, `react`, `nuxt`, `vue`, `angular`, `astro`, `svelte`, `shopify`, `wordpress`, `drupal`. |
76
+
77
+ ### Execution & emulation
78
+
79
+ | Flag | Argument | Default | Description |
80
+ | :--- | :--- | :--- | :--- |
81
+ | `--color-scheme` | `light\|dark` | `light` | Emulate `prefers-color-scheme`. |
82
+ | `--wait-until` | `domcontentloaded\|load\|networkidle` | `domcontentloaded` | Playwright page load strategy. Use `networkidle` for SPAs. |
83
+ | `--viewport` | `<WxH>` | — | Viewport size (e.g. `375x812`, `1440x900`). |
84
+ | `--wait-ms` | `<num>` | `2000` | Delay after page load before running axe (ms). |
85
+ | `--timeout-ms` | `<num>` | `30000` | Network timeout per page (ms). |
86
+ | `--headed` | — | `false` | Run browser in visible mode. |
87
+ | `--affected-only` | — | `false` | Re-scan only routes with previous violations. Requires a prior scan in `.audit/`. |
88
+
89
+ ### Output generation
90
+
91
+ | Flag | Argument | Default | Description |
92
+ | :--- | :--- | :--- | :--- |
93
+ | `--with-reports` | — | `false` | Generate HTML + PDF + Checklist reports. Requires `--output`. |
94
+ | `--skip-reports` | — | `true` | Skip visual report generation (default). |
95
+ | `--output` | `<path>` | — | Output path for `report.html` (PDF and checklist derive from it). |
96
+ | `--skip-patterns` | — | `false` | Disable source code pattern scanner even when `--project-dir` is set. |
97
+
98
+ ## Common command patterns
13
99
 
14
100
  ```bash
15
- npx a11y-audit --base-url https://example.com --max-routes 5
101
+ # Focused audit one rule, one route
102
+ a11y-audit --base-url https://example.com --only-rule color-contrast --routes /checkout --max-routes 1
103
+
104
+ # Dark mode audit
105
+ a11y-audit --base-url https://example.com --color-scheme dark
106
+
107
+ # SPA with deferred rendering
108
+ a11y-audit --base-url https://example.com --wait-until networkidle --wait-ms 3000
109
+
110
+ # Mobile viewport
111
+ a11y-audit --base-url https://example.com --viewport 375x812
112
+
113
+ # Fast re-audit after fixes (skips clean pages)
114
+ a11y-audit --base-url https://example.com --affected-only
115
+
116
+ # Ignore known false positives
117
+ a11y-audit --base-url https://example.com --ignore-findings color-contrast,frame-title
16
118
  ```
17
119
 
18
- ## Options
120
+ ## Output artifacts
121
+
122
+ All artifacts are written to `.audit/` relative to the package root.
123
+
124
+ | File | Always generated | Description |
125
+ | :--- | :--- | :--- |
126
+ | `a11y-scan-results.json` | Yes | Raw axe-core results per route |
127
+ | `a11y-findings.json` | Yes | Enriched findings with fix intelligence |
128
+ | `remediation.md` | Yes | AI-agent-optimized remediation roadmap |
129
+ | `report.html` | With `--with-reports` | Interactive HTML dashboard |
130
+ | `report.pdf` | With `--with-reports` | Formal compliance PDF |
131
+ | `checklist.html` | With `--with-reports` | Manual WCAG testing checklist |
132
+
133
+ See [Output Artifacts](docs/outputs.md) for full schema reference.
134
+
135
+ ## Troubleshooting
136
+
137
+ **`Error: browserType.launch: Executable doesn't exist`**
138
+ Run `npx playwright install chromium` (or `pnpm exec playwright install chromium`).
139
+
140
+ **`Missing required argument: --base-url`**
141
+ The flag is required. Provide a full URL including protocol: `--base-url https://example.com`.
142
+
143
+ **Scan returns 0 findings on an SPA**
144
+ Use `--wait-until networkidle --wait-ms 3000` to let async content render before axe runs.
145
+
146
+ **`--with-reports` exits without generating PDF**
147
+ Ensure `--output` is also set and points to an `.html` file path: `--output ./audit/report.html`.
148
+
149
+ **Chromium crashes in CI**
150
+ Add `--no-sandbox` via the `PLAYWRIGHT_CHROMIUM_LAUNCH_OPTIONS` env var, or run Playwright with the `--with-deps` flag during browser installation.
151
+
152
+ ## Documentation
153
+
154
+ | Resource | Description |
155
+ | :--- | :--- |
156
+ | [Architecture](https://github.com/diegovelasquezweb/a11y-engine/blob/main/docs/architecture.md) | How the scanner → analyzer → report pipeline works |
157
+ | [CLI Handbook](https://github.com/diegovelasquezweb/a11y-engine/blob/main/docs/cli-handbook.md) | Full flag reference and usage patterns |
158
+ | [Output Artifacts](https://github.com/diegovelasquezweb/a11y-engine/blob/main/docs/outputs.md) | Schema and structure of every generated file |
159
+
160
+ ## License
19
161
 
20
- See `a11y-audit --help` for full CLI reference.
162
+ MIT
@@ -0,0 +1,139 @@
1
+ # Engine Architecture
2
+
3
+ **Navigation**: [Home](../README.md) • [Architecture](architecture.md) • [CLI Handbook](cli-handbook.md) • [Output Artifacts](outputs.md)
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ - [Pipeline overview](#pipeline-overview)
10
+ - [Stage 1: DOM scanner](#stage-1-dom-scanner)
11
+ - [Stage 2: Analyzer](#stage-2-analyzer)
12
+ - [Stage 3: Report builders](#stage-3-report-builders)
13
+ - [Assets and rule intelligence](#assets-and-rule-intelligence)
14
+ - [Execution model and timeouts](#execution-model-and-timeouts)
15
+
16
+ ---
17
+
18
+ The engine operates as a three-stage pipeline. Each stage is an independent Node.js process spawned by `audit.mjs`. Stages communicate through JSON files written to `.audit/`.
19
+
20
+ ## Pipeline overview
21
+
22
+ ```
23
+ Target URL
24
+
25
+
26
+ ┌─────────────────────────────┐
27
+ │ Stage 1: DOM Scanner │ Playwright + axe-core
28
+ │ dom-scanner.mjs │ Route discovery + WCAG scan
29
+ └──────────────┬──────────────┘
30
+ │ a11y-scan-results.json
31
+
32
+ ┌─────────────────────────────┐
33
+ │ Stage 1b: Source Scanner │ Static regex analysis
34
+ │ source-scanner.mjs │ (optional — requires --project-dir)
35
+ └──────────────┬──────────────┘
36
+ │ merges into a11y-findings.json
37
+
38
+ ┌─────────────────────────────┐
39
+ │ Stage 2: Analyzer │ Fix intelligence enrichment
40
+ │ analyzer.mjs │ intelligence.json + guardrails
41
+ └──────────────┬──────────────┘
42
+ │ a11y-findings.json
43
+
44
+ ┌─────────────────────────────┐
45
+ │ Stage 3: Report Builders │ Parallel rendering
46
+ │ md / html / pdf / checklist│
47
+ └──────────────┬──────────────┘
48
+
49
+ ┌──────────┼──────────┬──────────────┐
50
+ ▼ ▼ ▼ ▼
51
+ remediation report report checklist
52
+ .md .html .pdf .html
53
+ ```
54
+
55
+ ## Stage 1: DOM scanner
56
+
57
+ **Script**: `scripts/engine/dom-scanner.mjs`
58
+
59
+ Launches a Playwright-controlled Chromium browser and runs axe-core against each discovered route.
60
+
61
+ **Route discovery**:
62
+ - If the site exposes a `sitemap.xml`, all listed URLs are scanned (up to `--max-routes`).
63
+ - Otherwise, BFS crawl starting from `--base-url`, following same-origin `<a href>` links up to `--crawl-depth` levels deep.
64
+ - Routes are deduplicated and normalized before scanning.
65
+
66
+ **Scanning**:
67
+ - 3 parallel browser tabs scan routes concurrently (~2–3× faster than sequential).
68
+ - axe-core 4.11+ runs WCAG 2.2 A, AA, and best-practice tag sets.
69
+ - Screenshots of affected elements are captured for each violation.
70
+ - `--color-scheme`, `--viewport`, `--wait-until`, and `--wait-ms` control the browser environment.
71
+
72
+ **Output**: `a11y-scan-results.json` — raw axe results per route with DOM snapshots.
73
+
74
+ ### Optional: Source scanner
75
+
76
+ **Script**: `scripts/engine/source-scanner.mjs` — runs when `--project-dir` is set and `--skip-patterns` is not.
77
+
78
+ Performs static analysis of source files for accessibility issues axe cannot detect at runtime (e.g. focus outline suppression, missing alt text in templates). Uses regex patterns from `assets/remediation/code-patterns.json` scoped to framework-specific file boundaries from `assets/remediation/source-boundaries.json`.
79
+
80
+ Findings are classified as `confirmed` (pattern unambiguously matches) or `potential` (requires human verification).
81
+
82
+ ## Stage 2: Analyzer
83
+
84
+ **Script**: `scripts/engine/analyzer.mjs`
85
+
86
+ Reads `a11y-scan-results.json` and enriches each violation with:
87
+
88
+ - **Fix intelligence** from `assets/remediation/intelligence.json` — 106 axe-core rules with code snippets, MDN links, framework-specific notes, and WCAG criterion mapping.
89
+ - **Selector scoring** — picks the most stable selector from axe's `nodes` list. Priority: `#id` > `[data-*]` > `[aria-*]` > `[type=]`, with penalty for Tailwind utility classes.
90
+ - **Framework context** — `assets/discovery/stack-detection.json` fingerprints the DOM to detect framework and CMS. Per-finding `framework_notes` and `cms_notes` are filtered to the detected stack.
91
+ - **Guardrails** — `assets/remediation/guardrails.json` defines scope rules that prevent agents from touching backend code, third-party scripts, or minified files.
92
+ - **Compliance scoring** — `assets/reporting/compliance-config.json` weights findings by severity to produce a 0–100 score with grade thresholds.
93
+ - **Persona impact groups** — `assets/reporting/wcag-reference.json` maps findings to disability personas (visual, motor, cognitive, etc.).
94
+
95
+ **Output**: `a11y-findings.json` — enriched findings array with all intelligence fields.
96
+
97
+ ## Stage 3: Report builders
98
+
99
+ All builders run in parallel when `--with-reports` is set. Each reads `a11y-findings.json` independently.
100
+
101
+ | Builder | Script | Output | Audience |
102
+ | :--- | :--- | :--- | :--- |
103
+ | Markdown | `reports/builders/md.mjs` | `remediation.md` | AI agents |
104
+ | HTML | `reports/builders/html.mjs` | `report.html` | Developers |
105
+ | PDF | `reports/builders/pdf.mjs` | `report.pdf` | Stakeholders |
106
+ | Checklist | `reports/builders/checklist.mjs` | `checklist.html` | QA / Developers |
107
+
108
+ The `remediation.md` builder always runs (even without `--with-reports`) since it is the primary output for AI agent consumption.
109
+
110
+ Renderers in `scripts/reports/renderers/` contain the actual rendering logic — builders are thin orchestrators that call renderers and write output files.
111
+
112
+ ## Assets and rule intelligence
113
+
114
+ Assets are static JSON files bundled with the package under `assets/`. They are read at runtime by the analyzer and report builders.
115
+
116
+ | Asset | Purpose |
117
+ | :--- | :--- |
118
+ | `reporting/compliance-config.json` | Score weights, grade thresholds, legal regulation list |
119
+ | `reporting/wcag-reference.json` | WCAG criterion map, persona config, persona–rule mapping |
120
+ | `reporting/manual-checks.json` | 41 manual checks for the WCAG checklist |
121
+ | `discovery/crawler-config.json` | BFS crawl defaults (timeouts, concurrency) |
122
+ | `discovery/stack-detection.json` | Framework/CMS DOM fingerprints |
123
+ | `remediation/intelligence.json` | Per-rule fix intelligence for 106 axe-core rules |
124
+ | `remediation/code-patterns.json` | Source code pattern definitions |
125
+ | `remediation/guardrails.json` | Agent fix scope guardrails |
126
+ | `remediation/axe-check-maps.json` | axe check-to-rule mapping |
127
+ | `remediation/source-boundaries.json` | Framework-specific source file locations |
128
+
129
+ ## Execution model and timeouts
130
+
131
+ `audit.mjs` spawns each stage as a child process via `node:child_process`. All child processes:
132
+
133
+ - Inherit the parent's environment
134
+ - Run with `cwd` set to the package root (`SKILL_ROOT`)
135
+ - Have a hard timeout of **15 minutes** (configurable via the `SCRIPT_TIMEOUT_MS` constant)
136
+
137
+ The orchestrator exits with code `1` if any stage fails. Individual stage timeouts are also enforced per page via `--timeout-ms` (default: 30s).
138
+
139
+ If `node_modules/` is absent on first run, the orchestrator automatically installs dependencies via `pnpm install` (falls back to `npm install`).
@@ -0,0 +1,209 @@
1
+ # CLI Handbook
2
+
3
+ **Navigation**: [Home](../README.md) • [Architecture](architecture.md) • [CLI Handbook](cli-handbook.md) • [Output Artifacts](outputs.md)
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ - [Basic usage](#basic-usage)
10
+ - [Flag groups](#flag-groups)
11
+ - [Targeting & scope](#targeting--scope)
12
+ - [Audit intelligence](#audit-intelligence)
13
+ - [Execution & emulation](#execution--emulation)
14
+ - [Output generation](#output-generation)
15
+ - [Examples](#examples)
16
+ - [Exit codes](#exit-codes)
17
+
18
+ ---
19
+
20
+ ## Basic usage
21
+
22
+ ```bash
23
+ npx a11y-audit --base-url <url> [options]
24
+ ```
25
+
26
+ Or if installed locally:
27
+
28
+ ```bash
29
+ node node_modules/@diegovelasquezweb/a11y-engine/scripts/audit.mjs --base-url <url> [options]
30
+ ```
31
+
32
+ The only required flag is `--base-url`. All other flags are optional.
33
+
34
+ ---
35
+
36
+ ## Flag groups
37
+
38
+ ### Targeting & scope
39
+
40
+ Controls what gets scanned.
41
+
42
+ | Flag | Argument | Default | Description |
43
+ | :--- | :--- | :--- | :--- |
44
+ | `--base-url` | `<url>` | (Required) | Starting URL. Must include protocol (`https://` or `http://`). |
45
+ | `--max-routes` | `<num>` | `10` | Maximum unique same-origin paths to discover and scan. |
46
+ | `--crawl-depth` | `<num>` | `2` | How deep to follow links during BFS discovery (1–3). Has no effect when `--routes` is set. |
47
+ | `--routes` | `<csv>` | — | Explicit paths to scan (e.g. `/,/about,/contact`). Overrides auto-discovery entirely. |
48
+ | `--project-dir` | `<path>` | — | Path to the audited project source. Enables the source code pattern scanner and framework auto-detection from `package.json`. |
49
+
50
+ **Route discovery logic**:
51
+ 1. If the target has a `sitemap.xml`, all listed URLs are used (up to `--max-routes`).
52
+ 2. Otherwise, BFS crawl from `--base-url`, following same-origin `<a href>` links.
53
+ 3. `--routes` always takes precedence over both.
54
+
55
+ ---
56
+
57
+ ### Audit intelligence
58
+
59
+ Controls how findings are interpreted and filtered.
60
+
61
+ | Flag | Argument | Default | Description |
62
+ | :--- | :--- | :--- | :--- |
63
+ | `--target` | `<text>` | `WCAG 2.2 AA` | Compliance target label rendered in reports. Does not change which rules run. |
64
+ | `--only-rule` | `<id>` | — | Run a single axe rule ID only. Useful for focused re-audits after fixing a specific issue. |
65
+ | `--ignore-findings` | `<csv>` | — | Comma-separated list of axe rule IDs to suppress from output entirely. |
66
+ | `--exclude-selectors` | `<csv>` | — | CSS selectors to skip. Elements matching these selectors are excluded from axe scanning. |
67
+ | `--framework` | `<name>` | — | Override auto-detected framework. Affects which fix notes and source boundaries are applied. |
68
+
69
+ **Supported `--framework` values**: `nextjs`, `gatsby`, `react`, `nuxt`, `vue`, `angular`, `astro`, `svelte`, `shopify`, `wordpress`, `drupal`.
70
+
71
+ ---
72
+
73
+ ### Execution & emulation
74
+
75
+ Controls browser behavior during scanning.
76
+
77
+ | Flag | Argument | Default | Description |
78
+ | :--- | :--- | :--- | :--- |
79
+ | `--color-scheme` | `light\|dark` | `light` | Emulates `prefers-color-scheme` media query. |
80
+ | `--wait-until` | `domcontentloaded\|load\|networkidle` | `domcontentloaded` | Playwright page load strategy. Use `networkidle` for SPAs with async rendering. |
81
+ | `--viewport` | `<WxH>` | `1280x800` | Browser viewport in pixels (e.g. `375x812` for mobile, `1440x900` for desktop). |
82
+ | `--wait-ms` | `<num>` | `2000` | Fixed delay (ms) after page load before axe runs. Useful when JS renders content after `DOMContentLoaded`. |
83
+ | `--timeout-ms` | `<num>` | `30000` | Network timeout per page load (ms). |
84
+ | `--headed` | — | `false` | Launch browser in visible mode. Useful for debugging page rendering issues. |
85
+ | `--affected-only` | — | `false` | Re-scan only routes that had violations in the previous scan. Reads `.audit/a11y-scan-results.json` to determine affected routes. Falls back to full scan if no prior results exist. |
86
+
87
+ ---
88
+
89
+ ### Output generation
90
+
91
+ Controls what artifacts are written.
92
+
93
+ | Flag | Argument | Default | Description |
94
+ | :--- | :--- | :--- | :--- |
95
+ | `--with-reports` | — | `false` | Generate full artifact set: `report.html`, `report.pdf`, `checklist.html`, and `remediation.md`. Requires `--output`. |
96
+ | `--skip-reports` | — | `true` | Default behavior. Only `remediation.md` is generated. |
97
+ | `--output` | `<path>` | — | Absolute or relative path for `report.html`. PDF and checklist are derived from the same directory. |
98
+ | `--skip-patterns` | — | `false` | Disable source code pattern scanner even when `--project-dir` is set. Use this for DOM-only results without static analysis. |
99
+
100
+ ---
101
+
102
+ ## Examples
103
+
104
+ ### Minimal scan
105
+
106
+ ```bash
107
+ # Produces only remediation.md in .audit/
108
+ a11y-audit --base-url https://example.com
109
+ ```
110
+
111
+ ### Full audit with all reports
112
+
113
+ ```bash
114
+ a11y-audit \
115
+ --base-url https://example.com \
116
+ --with-reports \
117
+ --output ./audit/report.html
118
+ ```
119
+
120
+ ### Include source code intelligence
121
+
122
+ ```bash
123
+ a11y-audit \
124
+ --base-url http://localhost:3000 \
125
+ --project-dir . \
126
+ --with-reports \
127
+ --output ./audit/report.html
128
+ ```
129
+
130
+ ### Focused re-audit — single rule, single route
131
+
132
+ ```bash
133
+ a11y-audit \
134
+ --base-url https://example.com \
135
+ --only-rule color-contrast \
136
+ --routes /checkout \
137
+ --max-routes 1
138
+ ```
139
+
140
+ ### Fast re-audit after applying fixes
141
+
142
+ ```bash
143
+ # Only re-scans routes that had violations in the last run
144
+ a11y-audit --base-url https://example.com --affected-only
145
+ ```
146
+
147
+ ### SPA with deferred rendering
148
+
149
+ ```bash
150
+ a11y-audit \
151
+ --base-url https://example.com \
152
+ --wait-until networkidle \
153
+ --wait-ms 3000
154
+ ```
155
+
156
+ ### Dark mode audit
157
+
158
+ ```bash
159
+ a11y-audit --base-url https://example.com --color-scheme dark
160
+ ```
161
+
162
+ ### Mobile viewport
163
+
164
+ ```bash
165
+ a11y-audit --base-url https://example.com --viewport 375x812
166
+ ```
167
+
168
+ ### Suppress known false positives
169
+
170
+ ```bash
171
+ a11y-audit \
172
+ --base-url https://example.com \
173
+ --ignore-findings color-contrast,frame-title
174
+ ```
175
+
176
+ ### Explicit route list
177
+
178
+ ```bash
179
+ a11y-audit \
180
+ --base-url https://example.com \
181
+ --routes /,/pricing,/blog,/contact
182
+ ```
183
+
184
+ ### Override framework detection
185
+
186
+ ```bash
187
+ a11y-audit \
188
+ --base-url http://localhost:3000 \
189
+ --framework nextjs \
190
+ --project-dir .
191
+ ```
192
+
193
+ ---
194
+
195
+ ## Exit codes
196
+
197
+ | Code | Meaning |
198
+ | :--- | :--- |
199
+ | `0` | Audit completed successfully (regardless of findings count) |
200
+ | `1` | Runtime error — invalid URL, missing required flag, stage failure, or timeout |
201
+
202
+ The engine never exits `1` just because findings were found. Exit `1` only indicates a pipeline or configuration error.
203
+
204
+ **Stdout markers** (parseable by scripts and CI):
205
+
206
+ ```
207
+ REMEDIATION_PATH=<abs-path> # always printed on success
208
+ REPORT_PATH=<abs-path> # only printed when --with-reports is set
209
+ ```
@@ -0,0 +1,245 @@
1
+ # Output Artifacts
2
+
3
+ **Navigation**: [Home](../README.md) • [Architecture](architecture.md) • [CLI Handbook](cli-handbook.md) • [Output Artifacts](outputs.md)
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ - [Default output directory](#default-output-directory)
10
+ - [a11y-scan-results.json](#a11y-scan-resultsjson)
11
+ - [a11y-findings.json](#a11y-findingsjson)
12
+ - [remediation.md](#remediationmd)
13
+ - [report.html](#reporthtml)
14
+ - [report.pdf](#reportpdf)
15
+ - [checklist.html](#checklisthtml)
16
+ - [Consuming outputs programmatically](#consuming-outputs-programmatically)
17
+
18
+ ---
19
+
20
+ ## Default output directory
21
+
22
+ All artifacts are written to `.audit/` relative to the package root (`SKILL_ROOT`). When consumed as an npm package, this resolves to the real package path inside `node_modules/`.
23
+
24
+ ```
25
+ .audit/
26
+ ├── a11y-scan-results.json # raw axe-core scan data
27
+ ├── a11y-findings.json # enriched findings (primary data artifact)
28
+ ├── remediation.md # AI agent remediation guide
29
+ ├── report.html # interactive dashboard (--with-reports)
30
+ ├── report.pdf # compliance report (--with-reports)
31
+ ├── checklist.html # manual testing checklist (--with-reports)
32
+ └── screenshots/ # element screenshots per violation
33
+ ```
34
+
35
+ > When integrating the engine as a dependency (e.g. in `a11y-scanner`), use `fs.realpathSync` on the symlink path to resolve the real `.audit/` location — pnpm uses a deep `.pnpm/` directory structure, not the `node_modules/@scope/pkg` symlink.
36
+
37
+ ---
38
+
39
+ ## a11y-scan-results.json
40
+
41
+ Raw axe-core output per route. Written by `scripts/engine/dom-scanner.mjs`.
42
+
43
+ ```json
44
+ {
45
+ "routes": [
46
+ {
47
+ "path": "/",
48
+ "url": "https://example.com/",
49
+ "violations": [...],
50
+ "incomplete": [...],
51
+ "passes": [...],
52
+ "inapplicable": [...]
53
+ }
54
+ ]
55
+ }
56
+ ```
57
+
58
+ This file is consumed by `analyzer.mjs` and also used by `--affected-only` to determine which routes to re-scan on subsequent runs.
59
+
60
+ ---
61
+
62
+ ## a11y-findings.json
63
+
64
+ The primary enriched data artifact. Written by `scripts/engine/analyzer.mjs`. This is the file consumed by all report builders.
65
+
66
+ ### Top-level structure
67
+
68
+ ```json
69
+ {
70
+ "metadata": { ... },
71
+ "findings": [ ... ]
72
+ }
73
+ ```
74
+
75
+ ### `metadata` fields
76
+
77
+ | Field | Type | Description |
78
+ | :--- | :--- | :--- |
79
+ | `scanDate` | `string` (ISO 8601) | Timestamp of when the scan ran |
80
+ | `checklist` | `object` | Manual check results if checklist was run |
81
+ | `projectContext` | `object` | Auto-detected framework, CMS, and UI libraries |
82
+ | `overallAssessment` | `string` | `"Pass"`, `"Conditional Pass"`, or `"Fail"` |
83
+ | `passedCriteria` | `string[]` | WCAG criterion IDs with no active violations |
84
+ | `outOfScope` | `object[]` | Findings excluded due to guardrails |
85
+ | `recommendations` | `object[]` | Grouped fix recommendations by component |
86
+ | `testingMethodology` | `object` | Scan scope and methodology summary |
87
+ | `fpFiltered` | `number` | Count of findings filtered as likely false positives |
88
+ | `deduplicatedCount` | `number` | Count of duplicate findings removed |
89
+
90
+ ### `findings` — per-finding fields
91
+
92
+ | Field | Type | Description |
93
+ | :--- | :--- | :--- |
94
+ | `id` | `string` | Deterministic finding ID (e.g. `A11Y-001`) |
95
+ | `rule_id` | `string` | axe-core rule ID (e.g. `color-contrast`) |
96
+ | `title` | `string` | Human-readable finding title |
97
+ | `severity` | `string` | `Critical`, `Serious`, `Moderate`, or `Minor` |
98
+ | `wcag` | `string` | WCAG success criterion (e.g. `1.4.3`) |
99
+ | `wcag_criterion_id` | `string` | Full WCAG criterion ID (e.g. `1.4.3`) |
100
+ | `wcag_classification` | `string` | `A`, `AA`, `AAA`, or `Best Practice` |
101
+ | `area` | `string` | Affected page area (e.g. `Navigation`, `Forms`) |
102
+ | `url` | `string` | URL where the violation was found |
103
+ | `selector` | `string` | CSS selector for the affected element |
104
+ | `primary_selector` | `string` | Most stable selector chosen by the analyzer |
105
+ | `impacted_users` | `string` | Disability groups affected |
106
+ | `actual` | `string` | Observed violation description |
107
+ | `expected` | `string` | What the correct behavior should be |
108
+ | `category` | `string` | Violation category (e.g. `Color & Contrast`) |
109
+ | `primary_failure_mode` | `string\|null` | Root cause classification |
110
+ | `relationship_hint` | `string\|null` | Label/input relationship context |
111
+ | `failure_checks` | `object[]` | axe check-level failure details |
112
+ | `related_context` | `object[]` | Surrounding DOM context |
113
+ | `fix_description` | `string\|null` | Plain-language fix explanation |
114
+ | `fix_code` | `string\|null` | Ready-to-apply code snippet |
115
+ | `fix_code_lang` | `string` | Language for code block (e.g. `html`, `jsx`) |
116
+ | `recommended_fix` | `string` | Link to canonical fix reference (APG, MDN) |
117
+ | `mdn` | `string\|null` | MDN documentation URL |
118
+ | `effort` | `string\|null` | Fix effort estimate (`low`, `medium`, `high`) |
119
+ | `related_rules` | `string[]` | Related axe rule IDs |
120
+ | `guardrails` | `object\|null` | Agent scope guardrails for this finding |
121
+ | `false_positive_risk` | `string\|null` | Known false positive patterns |
122
+ | `fix_difficulty_notes` | `string\|null` | Edge cases and pitfalls for this fix |
123
+ | `framework_notes` | `string\|null` | Framework-specific fix guidance |
124
+ | `cms_notes` | `string\|null` | CMS-specific fix guidance |
125
+ | `check_data` | `object\|null` | Raw axe check data |
126
+ | `total_instances` | `number` | Count of affected elements across all pages |
127
+ | `evidence` | `object[]` | DOM HTML snippets for each affected element |
128
+ | `screenshot_path` | `string\|null` | Path to element screenshot |
129
+ | `file_search_pattern` | `string\|null` | Regex/glob pattern to find the source file |
130
+ | `managed_by_library` | `string\|null` | UI library managing this element (if any) |
131
+ | `ownership_status` | `string` | `confirmed`, `potential`, or `unknown` |
132
+ | `ownership_reason` | `string\|null` | Why this ownership status was assigned |
133
+ | `primary_source_scope` | `string[]` | Source file paths likely containing the issue |
134
+ | `search_strategy` | `string` | Agent search strategy recommendation |
135
+ | `component_hint` | `string\|null` | Component name hint for source search |
136
+ | `verification_command` | `string\|null` | CLI command to verify fix was applied |
137
+ | `verification_command_fallback` | `string\|null` | Fallback verify command |
138
+ | `pages_affected` | `number\|null` | Number of pages with this violation |
139
+ | `affected_urls` | `string[]\|null` | All URLs where this violation appears |
140
+
141
+ ---
142
+
143
+ ## remediation.md
144
+
145
+ AI agent-optimized remediation guide. Always generated (even without `--with-reports`). Written to `.audit/remediation.md`.
146
+
147
+ Content:
148
+
149
+ - Audit summary header with score, URL, and date
150
+ - Per-finding sections with: violation description, DOM evidence, fix description, code snippet, verify command, and WCAG criterion
151
+ - "Passed WCAG 2.2 Criteria" section listing clean criteria
152
+ - "Source Code Patterns" section (if source scanner ran)
153
+ - Component grouping table for batching fixes
154
+
155
+ The path is printed to stdout on completion as `REMEDIATION_PATH=<path>`.
156
+
157
+ ---
158
+
159
+ ## report.html
160
+
161
+ Interactive HTML dashboard. Generated only with `--with-reports --output <path>`.
162
+
163
+ Features:
164
+
165
+ - Severity-grouped findings with expandable detail cards
166
+ - DOM evidence with syntax-highlighted code
167
+ - Screenshot thumbnails per finding
168
+ - Search and filter by severity, category, and page
169
+ - Compliance score gauge with grade label
170
+ - Persona impact breakdown (visual, motor, cognitive, etc.)
171
+ - Quick wins section (Critical/Serious findings with ready code)
172
+
173
+ Written to the path specified by `--output`. The path is printed to stdout as `REPORT_PATH=<path>`.
174
+
175
+ ---
176
+
177
+ ## report.pdf
178
+
179
+ Formal PDF compliance report for stakeholders. Generated alongside `report.html` when `--with-reports` is set.
180
+
181
+ Sections:
182
+
183
+ 1. Cover page with score, date, and target URL
184
+ 2. Table of Contents
185
+ 3. Executive Summary — score, overall assessment, finding counts by severity
186
+ 4. Legal Risk Summary — applicable regulations based on score tier
187
+ 5. Methodology — scan scope, tools, and WCAG version
188
+ 6. Findings Breakdown — severity table and issue summary
189
+ 7. Recommended Next Steps — dynamically generated from findings
190
+
191
+ Written to the same directory as `--output` with `.pdf` extension.
192
+
193
+ ---
194
+
195
+ ## checklist.html
196
+
197
+ Interactive manual testing checklist. Generated alongside `report.html` when `--with-reports` is set.
198
+
199
+ Contains the 41 WCAG 2.2 AA manual checks that automated tools cannot detect, including:
200
+
201
+ - Keyboard navigation and focus order
202
+ - Screen reader announcements
203
+ - Motion and animation
204
+ - Zoom and reflow (400%)
205
+ - Cognitive load and reading level
206
+
207
+ Each item is checkable and includes testing instructions. State is not persisted between sessions.
208
+
209
+ Written to the same directory as `--output` as `checklist.html`.
210
+
211
+ ---
212
+
213
+ ## Consuming outputs programmatically
214
+
215
+ ### Reading `a11y-findings.json` from an integration
216
+
217
+ ```js
218
+ import fs from "node:fs";
219
+ import path from "node:path";
220
+ import { createRequire } from "node:module";
221
+
222
+ // Resolve real path (handles pnpm symlinks)
223
+ const req = createRequire(import.meta.url);
224
+ const auditScript = req.resolve("@diegovelasquezweb/a11y-engine/scripts/audit.mjs");
225
+ const engineRoot = path.dirname(path.dirname(auditScript));
226
+ const findingsPath = path.join(engineRoot, ".audit", "a11y-findings.json");
227
+
228
+ const { findings, metadata } = JSON.parse(fs.readFileSync(findingsPath, "utf-8"));
229
+ ```
230
+
231
+ > Note: `import.meta.url` may be mangled by bundlers (e.g. Next.js). In that case, use `fs.realpathSync` on the known symlink instead:
232
+
233
+ ```js
234
+ const symlinkBase = path.join(process.cwd(), "node_modules", "@diegovelasquezweb", "a11y-engine");
235
+ const engineRoot = fs.realpathSync(symlinkBase);
236
+ const findingsPath = path.join(engineRoot, ".audit", "a11y-findings.json");
237
+ ```
238
+
239
+ ### Parsing stdout markers
240
+
241
+ ```bash
242
+ OUTPUT=$(node scripts/audit.mjs --base-url https://example.com --with-reports --output ./audit/report.html)
243
+ REMEDIATION_PATH=$(echo "$OUTPUT" | grep REMEDIATION_PATH | cut -d= -f2)
244
+ REPORT_PATH=$(echo "$OUTPUT" | grep REPORT_PATH | cut -d= -f2)
245
+ ```
package/package.json CHANGED
@@ -1,24 +1,26 @@
1
1
  {
2
2
  "name": "@diegovelasquezweb/a11y-engine",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "WCAG 2.2 AA accessibility audit engine — scanner, analyzer, and report builders",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/diegovelasquezweb/a11y-engine.git"
9
+ "url": "git+https://github.com/diegovelasquezweb/a11y-engine.git"
10
10
  },
11
11
  "homepage": "https://github.com/diegovelasquezweb/a11y-engine#readme",
12
12
  "engines": {
13
13
  "node": ">=18"
14
14
  },
15
15
  "bin": {
16
- "a11y-audit": "./scripts/audit.mjs"
16
+ "a11y-audit": "scripts/audit.mjs"
17
17
  },
18
18
  "files": [
19
19
  "scripts/**",
20
20
  "assets/**",
21
+ "docs/**",
21
22
  "README.md",
23
+ "CHANGELOG.md",
22
24
  "LICENSE"
23
25
  ],
24
26
  "scripts": {
@@ -33,5 +35,6 @@
33
35
  },
34
36
  "devDependencies": {
35
37
  "vitest": "^4.0.18"
36
- }
38
+ },
39
+ "packageManager": "pnpm@10.22.0+sha512.bf049efe995b28f527fd2b41ae0474ce29186f7edcb3bf545087bd61fbbebb2bf75362d1307fda09c2d288e1e499787ac12d4fcb617a974718a6051f2eee741c"
37
40
  }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { chromium } from "playwright";
10
10
  import AxeBuilder from "@axe-core/playwright";
11
+ import pa11y from "pa11y";
11
12
  import { log, DEFAULTS, writeJson, getInternalPath } from "../core/utils.mjs";
12
13
  import { ASSET_PATHS, loadAssetJson } from "../core/asset-loader.mjs";
13
14
  import path from "node:path";
@@ -85,6 +86,7 @@ function parseArgs(argv) {
85
86
  onlyRule: null,
86
87
  crawlDepth: DEFAULTS.crawlDepth,
87
88
  viewport: null,
89
+ axeTags: null,
88
90
  };
89
91
 
90
92
  for (let i = 0; i < argv.length; i += 1) {
@@ -110,6 +112,7 @@ function parseArgs(argv) {
110
112
  args.excludeSelectors = value.split(",").map((s) => s.trim());
111
113
  if (key === "--color-scheme") args.colorScheme = value;
112
114
  if (key === "--screenshots-dir") args.screenshotsDir = value;
115
+ if (key === "--axe-tags") args.axeTags = value.split(",").map((s) => s.trim());
113
116
  if (key === "--viewport") {
114
117
  const [w, h] = value.split("x").map(Number);
115
118
  if (w && h) args.viewport = { width: w, height: h };
@@ -398,6 +401,7 @@ async function analyzeRoute(
398
401
  timeoutMs = 30000,
399
402
  maxRetries = 2,
400
403
  waitUntil = "domcontentloaded",
404
+ axeTags = null,
401
405
  ) {
402
406
  let lastError;
403
407
 
@@ -417,7 +421,8 @@ async function analyzeRoute(
417
421
  log.info(`Targeted Audit: Only checking rule "${onlyRule}"`);
418
422
  builder.withRules([onlyRule]);
419
423
  } else {
420
- builder.withTags(AXE_TAGS);
424
+ const tagsToUse = axeTags || AXE_TAGS;
425
+ builder.withTags(tagsToUse);
421
426
  }
422
427
 
423
428
  if (Array.isArray(excludeSelectors)) {
@@ -470,6 +475,262 @@ async function analyzeRoute(
470
475
  };
471
476
  }
472
477
 
478
+ /**
479
+ * Writes scan progress to a JSON file for real-time UI updates.
480
+ * @param {string} step - Current step identifier.
481
+ * @param {"pending"|"running"|"done"|"error"} status - Step status.
482
+ * @param {Object} [extra={}] - Additional metadata.
483
+ */
484
+ function writeProgress(step, status, extra = {}) {
485
+ const progressPath = getInternalPath("progress.json");
486
+ let progress = {};
487
+ try {
488
+ if (fs.existsSync(progressPath)) {
489
+ progress = JSON.parse(fs.readFileSync(progressPath, "utf-8"));
490
+ }
491
+ } catch { /* ignore */ }
492
+ progress.steps = progress.steps || {};
493
+ progress.steps[step] = { status, updatedAt: new Date().toISOString(), ...extra };
494
+ progress.currentStep = step;
495
+ fs.mkdirSync(path.dirname(progressPath), { recursive: true });
496
+ fs.writeFileSync(progressPath, JSON.stringify(progress, null, 2));
497
+ }
498
+
499
+ /**
500
+ * Runs CDP (Chrome DevTools Protocol) accessibility checks using Playwright's CDP session.
501
+ * Catches issues axe-core may miss: missing accessible names, broken focus order,
502
+ * aria-hidden on focusable elements, and missing form labels.
503
+ * @param {import("playwright").Page} page - The Playwright page object.
504
+ * @returns {Promise<Object[]>} Array of CDP-sourced violations in axe-compatible format.
505
+ */
506
+ async function runCdpChecks(page) {
507
+ const violations = [];
508
+ try {
509
+ const cdp = await page.context().newCDPSession(page);
510
+
511
+ const { nodes } = await cdp.send("Accessibility.getFullAXTree");
512
+
513
+ for (const node of nodes) {
514
+ const role = node.role?.value || "";
515
+ const name = node.name?.value || "";
516
+ const properties = node.properties || [];
517
+ const ignored = node.ignored || false;
518
+
519
+ if (ignored) continue;
520
+
521
+ const focusable = properties.find((p) => p.name === "focusable")?.value?.value === true;
522
+ const hidden = properties.find((p) => p.name === "hidden")?.value?.value === true;
523
+
524
+ const interactiveRoles = ["button", "link", "textbox", "combobox", "listbox", "menuitem", "tab", "checkbox", "radio", "switch", "slider"];
525
+ if (interactiveRoles.includes(role) && !name.trim()) {
526
+ const backendId = node.backendDOMNodeId;
527
+ let selector = "";
528
+ try {
529
+ if (backendId) {
530
+ const { object } = await cdp.send("DOM.resolveNode", { backendNodeId: backendId });
531
+ if (object?.objectId) {
532
+ const result = await cdp.send("Runtime.callFunctionOn", {
533
+ objectId: object.objectId,
534
+ functionDeclaration: `function() {
535
+ if (this.id) return '#' + this.id;
536
+ if (this.className && typeof this.className === 'string') return this.tagName.toLowerCase() + '.' + this.className.trim().split(/\\s+/).join('.');
537
+ return this.tagName.toLowerCase();
538
+ }`,
539
+ returnByValue: true,
540
+ });
541
+ selector = result.result?.value || "";
542
+ }
543
+ }
544
+ } catch { /* fallback: no selector */ }
545
+
546
+ violations.push({
547
+ id: "cdp-missing-accessible-name",
548
+ impact: "serious",
549
+ tags: ["wcag2a", "wcag412", "cdp-check"],
550
+ description: `Interactive element with role "${role}" has no accessible name`,
551
+ help: "Interactive elements must have an accessible name",
552
+ helpUrl: "https://dequeuniversity.com/rules/axe/4.11/button-name",
553
+ source: "cdp",
554
+ nodes: [{
555
+ any: [],
556
+ all: [{
557
+ id: "cdp-accessible-name",
558
+ data: { role, name: "(empty)" },
559
+ relatedNodes: [],
560
+ impact: "serious",
561
+ message: `Element with role "${role}" has no accessible name in the accessibility tree`,
562
+ }],
563
+ none: [],
564
+ impact: "serious",
565
+ html: `<${role} aria-role="${role}">`,
566
+ target: selector ? [selector] : [`[role="${role}"]`],
567
+ failureSummary: `Fix all of the following:\n Element with role "${role}" has no accessible name`,
568
+ }],
569
+ });
570
+ }
571
+
572
+ if (hidden && focusable) {
573
+ violations.push({
574
+ id: "cdp-aria-hidden-focusable",
575
+ impact: "serious",
576
+ tags: ["wcag2a", "wcag412", "cdp-check"],
577
+ description: `Focusable element with role "${role}" is aria-hidden`,
578
+ help: "aria-hidden elements must not be focusable",
579
+ helpUrl: "https://dequeuniversity.com/rules/axe/4.11/aria-hidden-focus",
580
+ source: "cdp",
581
+ nodes: [{
582
+ any: [],
583
+ all: [{
584
+ id: "cdp-hidden-focusable",
585
+ data: { role },
586
+ relatedNodes: [],
587
+ impact: "serious",
588
+ message: `Focusable element with role "${role}" is hidden from the accessibility tree`,
589
+ }],
590
+ none: [],
591
+ impact: "serious",
592
+ html: `<element role="${role}" aria-hidden="true">`,
593
+ target: [`[role="${role}"]`],
594
+ failureSummary: `Fix all of the following:\n Focusable element is hidden from the accessibility tree`,
595
+ }],
596
+ });
597
+ }
598
+ }
599
+
600
+ await cdp.detach();
601
+ } catch (err) {
602
+ log.warn(`CDP checks failed (non-fatal): ${err.message}`);
603
+ }
604
+ return violations;
605
+ }
606
+
607
+ /**
608
+ * Runs pa11y (HTML CodeSniffer) against the already-loaded page URL.
609
+ * Catches WCAG violations that axe-core may miss, particularly around
610
+ * heading hierarchy, link purpose, and form associations.
611
+ * @param {string} routeUrl - The URL to scan.
612
+ * @param {string[]} [axeTags] - WCAG level tags for standard filtering.
613
+ * @returns {Promise<Object[]>} Array of pa11y-sourced violations in axe-compatible format.
614
+ */
615
+ async function runPa11yChecks(routeUrl, axeTags) {
616
+ const violations = [];
617
+ try {
618
+ let standard = "WCAG2AA";
619
+ if (axeTags) {
620
+ if (axeTags.includes("wcag2aaa")) standard = "WCAG2AAA";
621
+ else if (axeTags.includes("wcag2aa") || axeTags.includes("wcag21aa") || axeTags.includes("wcag22aa")) standard = "WCAG2AA";
622
+ else if (axeTags.includes("wcag2a")) standard = "WCAG2A";
623
+ }
624
+
625
+ const results = await pa11y(routeUrl, {
626
+ standard,
627
+ timeout: 30000,
628
+ wait: 2000,
629
+ chromeLaunchConfig: {
630
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
631
+ },
632
+ ignore: [
633
+ "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail",
634
+ "WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent",
635
+ ],
636
+ });
637
+
638
+ const impactMap = { 1: "serious", 2: "moderate", 3: "minor" };
639
+
640
+ for (const issue of results.issues || []) {
641
+ if (issue.type === "notice") continue;
642
+
643
+ const impact = impactMap[issue.typeCode] || "moderate";
644
+
645
+ let wcagCriterion = "";
646
+ const wcagMatch = issue.code?.match(/Guideline(\d+)_(\d+)\.(\d+)_(\d+)_(\d+)/);
647
+ if (wcagMatch) {
648
+ wcagCriterion = `${wcagMatch[3]}.${wcagMatch[4]}.${wcagMatch[5]}`;
649
+ }
650
+
651
+ const ruleId = `pa11y-${(issue.code || "unknown").replace(/\./g, "-").toLowerCase().slice(0, 60)}`;
652
+
653
+ violations.push({
654
+ id: ruleId,
655
+ impact,
656
+ tags: ["pa11y-check", ...(wcagCriterion ? [`wcag${wcagCriterion.replace(/\./g, "")}`] : [])],
657
+ description: issue.message || "pa11y detected an accessibility issue",
658
+ help: issue.message?.split(".")[0] || "Accessibility issue detected by HTML CodeSniffer",
659
+ helpUrl: wcagCriterion
660
+ ? `https://www.w3.org/WAI/WCAG21/Understanding/${wcagCriterion.replace(/\./g, "")}`
661
+ : "https://squizlabs.github.io/HTML_CodeSniffer/",
662
+ source: "pa11y",
663
+ nodes: [{
664
+ any: [],
665
+ all: [{
666
+ id: "pa11y-check",
667
+ data: { code: issue.code, context: issue.context?.slice(0, 200) },
668
+ relatedNodes: [],
669
+ impact,
670
+ message: issue.message || "",
671
+ }],
672
+ none: [],
673
+ impact,
674
+ html: issue.context || "",
675
+ target: issue.selector ? [issue.selector] : [],
676
+ failureSummary: `Fix all of the following:\n ${issue.message || "Accessibility issue"}`,
677
+ }],
678
+ });
679
+ }
680
+ } catch (err) {
681
+ log.warn(`pa11y checks failed (non-fatal): ${err.message}`);
682
+ }
683
+ return violations;
684
+ }
685
+
686
+ /**
687
+ * Merges violations from multiple sources (axe-core, CDP, pa11y) and deduplicates.
688
+ * Deduplication is based on rule ID + first target selector combination.
689
+ * @param {Object[]} axeViolations - Violations from axe-core.
690
+ * @param {Object[]} cdpViolations - Violations from CDP checks.
691
+ * @param {Object[]} pa11yViolations - Violations from pa11y.
692
+ * @returns {Object[]} Merged and deduplicated violations array.
693
+ */
694
+ function mergeViolations(axeViolations, cdpViolations, pa11yViolations) {
695
+ const seen = new Set();
696
+ const merged = [];
697
+
698
+ for (const v of axeViolations) {
699
+ const key = `${v.id}::${v.nodes?.[0]?.target?.[0] || ""}`;
700
+ seen.add(key);
701
+ merged.push(v);
702
+ }
703
+
704
+ for (const v of cdpViolations) {
705
+ const axeEquiv = {
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] || [];
710
+ const target = v.nodes?.[0]?.target?.[0] || "";
711
+ const isDuplicate = equivRules.some((r) => seen.has(`${r}::${target}`));
712
+ if (!isDuplicate) {
713
+ const key = `${v.id}::${target}`;
714
+ if (!seen.has(key)) {
715
+ seen.add(key);
716
+ merged.push(v);
717
+ }
718
+ }
719
+ }
720
+
721
+ for (const v of pa11yViolations) {
722
+ const target = v.nodes?.[0]?.target?.[0] || "";
723
+ const key = `${v.id}::${target}`;
724
+ const selectorCovered = [...seen].some((k) => k.endsWith(`::${target}`) && target);
725
+ if (!seen.has(key) && (!selectorCovered || !target)) {
726
+ seen.add(key);
727
+ merged.push(v);
728
+ }
729
+ }
730
+
731
+ return merged;
732
+ }
733
+
473
734
  /**
474
735
  * The main execution function for the accessibility scanner.
475
736
  * Coordinates browser setup, crawling/discovery, parallel scanning, and result saving.
@@ -630,6 +891,8 @@ async function main() {
630
891
  tabPages.push(await context.newPage());
631
892
  }
632
893
 
894
+ writeProgress("page", "running");
895
+
633
896
  for (let i = 0; i < routes.length; i += tabPages.length) {
634
897
  const batch = [];
635
898
  for (let j = 0; j < tabPages.length && i + j < routes.length; j++) {
@@ -640,6 +903,11 @@ async function main() {
640
903
  const routePath = routes[idx];
641
904
  log.info(`[${idx + 1}/${total}] Scanning: ${routePath}`);
642
905
  const targetUrl = new URL(routePath, baseUrl).toString();
906
+
907
+ writeProgress("page", "done");
908
+
909
+ // Step 1: axe-core
910
+ writeProgress("axe", "running");
643
911
  const result = await analyzeRoute(
644
912
  tabPage,
645
913
  targetUrl,
@@ -649,13 +917,51 @@ async function main() {
649
917
  args.timeoutMs,
650
918
  2,
651
919
  args.waitUntil,
920
+ args.axeTags,
921
+ );
922
+ const axeViolationCount = result.violations?.length || 0;
923
+ writeProgress("axe", "done", { found: axeViolationCount });
924
+ log.info(`axe-core: ${axeViolationCount} violation(s) found`);
925
+
926
+ // Step 2: CDP checks
927
+ writeProgress("cdp", "running");
928
+ const cdpViolations = await runCdpChecks(tabPage);
929
+ writeProgress("cdp", "done", { found: cdpViolations.length });
930
+ log.info(`CDP checks: ${cdpViolations.length} issue(s) found`);
931
+
932
+ // Step 3: pa11y
933
+ writeProgress("pa11y", "running");
934
+ const pa11yViolations = await runPa11yChecks(targetUrl, args.axeTags);
935
+ writeProgress("pa11y", "done", { found: pa11yViolations.length });
936
+ log.info(`pa11y: ${pa11yViolations.length} issue(s) found`);
937
+
938
+ // Step 4: Merge results
939
+ writeProgress("merge", "running");
940
+ const mergedViolations = mergeViolations(
941
+ result.violations || [],
942
+ cdpViolations,
943
+ pa11yViolations,
652
944
  );
653
- if (args.screenshotsDir && result.violations) {
654
- for (const violation of result.violations) {
945
+ writeProgress("merge", "done", {
946
+ axe: axeViolationCount,
947
+ cdp: cdpViolations.length,
948
+ pa11y: pa11yViolations.length,
949
+ merged: mergedViolations.length,
950
+ });
951
+ log.info(`Merged: ${mergedViolations.length} total unique violations (axe: ${axeViolationCount}, cdp: ${cdpViolations.length}, pa11y: ${pa11yViolations.length})`);
952
+
953
+ // Screenshots for merged violations
954
+ if (args.screenshotsDir && mergedViolations) {
955
+ for (const violation of mergedViolations) {
655
956
  await captureElementScreenshot(tabPage, violation, idx);
656
957
  }
657
958
  }
658
- results[idx] = { path: routePath, ...result };
959
+ results[idx] = {
960
+ path: routePath,
961
+ ...result,
962
+ violations: mergedViolations,
963
+ incomplete: result.incomplete || [],
964
+ };
659
965
  })(),
660
966
  );
661
967
  }