@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/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
- ├── a11y-scan-results.json # raw axe-core scan data
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
- Raw axe-core output per route. Written by `scripts/engine/dom-scanner.mjs`.
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` | axe-core rule ID (e.g. `color-contrast`) |
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[]` | axe check-level failure details |
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 axe rule IDs |
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 axe check data |
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",
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: ["axe-core (via @axe-core/playwright)", "Playwright + Chromium"],
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: "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",
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: "serious",
561
- message: `Element with role "${role}" has no accessible name in the accessibility tree`,
576
+ impact: rule.impact,
577
+ message: msg,
562
578
  }],
563
579
  none: [],
564
- impact: "serious",
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 Element with role "${role}" has no accessible name`,
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: "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",
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: "serious",
588
- message: `Focusable element with role "${role}" is hidden from the accessibility tree`,
607
+ impact: rule.impact,
608
+ message: msg,
589
609
  }],
590
610
  none: [],
591
- impact: "serious",
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 Focusable element is hidden from the accessibility tree`,
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
- const ruleId = `pa11y-${(issue.code || "unknown").replace(/\./g, "-").toLowerCase().slice(0, 60)}`;
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: issue.code, context: issue.context?.slice(0, 200) },
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 key = `${v.id}::${v.nodes?.[0]?.target?.[0] || ""}`;
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 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] || [];
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
- const selectorCovered = [...seen].some((k) => k.endsWith(`::${target}`) && target);
725
- if (!seen.has(key) && (!selectorCovered || !target)) {
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
- if (!selector || SKIP_SELECTORS.has(selector.toLowerCase())) return;
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, "-");