@diegovelasquezweb/a11y-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1022 @@
1
+ /**
2
+ * @file analyzer.mjs
3
+ * @description Post-scan data processor.
4
+ * Transforms raw axe-core results into enriched findings by applying
5
+ * framework-specific logic, remediation intelligence, and WCAG mapping
6
+ * to generate a structured audit overview.
7
+ */
8
+
9
+ import { log, readJson, writeJson, getInternalPath } from "../core/utils.mjs";
10
+ import { ASSET_PATHS, loadAssetJson } from "../core/asset-loader.mjs";
11
+ import { createHash } from "node:crypto";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+
17
+ /**
18
+ * Path to the core remediation intelligence database.
19
+ * @type {string}
20
+ */
21
+ const intelligencePath = ASSET_PATHS.remediation.intelligence;
22
+
23
+ /**
24
+ * Path to the WCAG reference database: criterion maps, APG patterns, MDN links, personas.
25
+ * @type {string}
26
+ */
27
+ const wcagReferencePath = ASSET_PATHS.reporting.wcagReference;
28
+
29
+ const complianceConfigPath = ASSET_PATHS.reporting.complianceConfig;
30
+ const axeCheckMapsPath = ASSET_PATHS.remediation.axeCheckMaps;
31
+ const sourceBoundariesPath = ASSET_PATHS.remediation.sourceBoundaries;
32
+
33
+ let INTELLIGENCE;
34
+ let WCAG_REFERENCE;
35
+ let COMPLIANCE_CONFIG;
36
+ let AXE_CHECK_MAPS;
37
+ let SOURCE_BOUNDARIES;
38
+ INTELLIGENCE = loadAssetJson(
39
+ intelligencePath,
40
+ "assets/remediation/intelligence.json",
41
+ );
42
+
43
+ WCAG_REFERENCE = loadAssetJson(
44
+ wcagReferencePath,
45
+ "assets/reporting/wcag-reference.json",
46
+ );
47
+
48
+ COMPLIANCE_CONFIG = loadAssetJson(
49
+ complianceConfigPath,
50
+ "assets/reporting/compliance-config.json",
51
+ );
52
+
53
+ AXE_CHECK_MAPS = loadAssetJson(
54
+ axeCheckMapsPath,
55
+ "assets/remediation/axe-check-maps.json",
56
+ );
57
+
58
+ SOURCE_BOUNDARIES = loadAssetJson(
59
+ sourceBoundariesPath,
60
+ "assets/remediation/source-boundaries.json",
61
+ );
62
+
63
+ /**
64
+ * Generates a stable unique ID for a finding based on the rule, URL, and selector.
65
+ * @param {string} ruleId - The ID of the accessibility rule.
66
+ * @param {string} url - The URL of the page where the violation was found.
67
+ * @param {string} selector - The CSS selector of the violating element.
68
+ * @returns {string} A short unique hash in the format A11Y-xxxxxx.
69
+ */
70
+ function makeFindingId(ruleId, url, selector) {
71
+ const stableSelector = selector.split(",")[0].trim();
72
+ const key = `${ruleId}||${url}||${stableSelector}`;
73
+ return `A11Y-${createHash("sha256").update(key).digest("hex").slice(0, 6)}`;
74
+ }
75
+
76
+ const RULES = INTELLIGENCE.rules || {};
77
+ const APG_PATTERNS = WCAG_REFERENCE.apgPatterns;
78
+ const MDN = WCAG_REFERENCE.mdn || {};
79
+ const WCAG_CRITERION_MAP = WCAG_REFERENCE.wcagCriterionMap || {};
80
+
81
+ function mergeUnique(first = [], second = []) {
82
+ return [...new Set([...(first || []), ...(second || [])])];
83
+ }
84
+
85
+ function resolveGuardrails(target, shared) {
86
+ if (target?.guardrails) return target.guardrails;
87
+ if (!target?.guardrails_overrides && !shared) return null;
88
+ return {
89
+ must: mergeUnique(shared?.must, target?.guardrails_overrides?.must),
90
+ must_not: mergeUnique(shared?.must_not, target?.guardrails_overrides?.must_not),
91
+ verify: mergeUnique(shared?.verify, target?.guardrails_overrides?.verify),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Detects the programming language of a code snippet for syntax highlighting.
97
+ * @param {string} code - The code snippet to analyze.
98
+ * @returns {string} The detected language (html, jsx, or css).
99
+ */
100
+ function detectCodeLang(code) {
101
+ if (!code) return "html";
102
+ if (/\.(tsx?|jsx?)\b|className=|useState|useRef|<>\s*<\/>/i.test(code))
103
+ return "jsx";
104
+ if (/^\s*[.#][\w-]+\s*\{|:\s*var\(|@media|display\s*:/m.test(code))
105
+ return "css";
106
+ return "html";
107
+ }
108
+
109
+ const IMPACTED_USERS = WCAG_REFERENCE.impactedUsers || {};
110
+ const EXPECTED = WCAG_REFERENCE.expected || {};
111
+
112
+ /**
113
+ * Returns a description of the primary user groups impacted by a rule violation.
114
+ * @param {string} ruleId - The ID of the accessibility rule.
115
+ * @param {string[]} tags - The tags associated with the rule.
116
+ * @returns {string} A description of the impacted users.
117
+ */
118
+ function getImpactedUsers(ruleId, tags) {
119
+ if (IMPACTED_USERS[ruleId]) return IMPACTED_USERS[ruleId];
120
+ const tagFallbacks = WCAG_REFERENCE.tagImpactedUsers || {};
121
+ for (const tag of tags) {
122
+ if (tagFallbacks[tag]) return tagFallbacks[tag];
123
+ }
124
+ return "Users relying on assistive technology";
125
+ }
126
+
127
+ /**
128
+ * Returns the expected accessibility behavior for a given rule.
129
+ * @param {string} ruleId - The ID of the accessibility rule.
130
+ * @returns {string} A description of the expected state for compliance.
131
+ */
132
+ function getExpected(ruleId) {
133
+ return EXPECTED[ruleId] || "WCAG accessibility check must pass.";
134
+ }
135
+
136
+ /**
137
+ * Framework-specific file search glob patterns.
138
+ * Used to help developers locate the source of an accessibility violation.
139
+ * @type {Object<string, Object>}
140
+ */
141
+ const FRAMEWORK_GLOBS = SOURCE_BOUNDARIES || {};
142
+
143
+ /**
144
+ * Rules with managed_by_libraries in intelligence.json — derived at load time.
145
+ * @type {Map<string, string[]>}
146
+ */
147
+ const MANAGED_RULES = new Map(
148
+ Object.entries(INTELLIGENCE.rules)
149
+ .filter(([, rule]) => Array.isArray(rule.managed_by_libraries))
150
+ .map(([id, rule]) => [id, rule.managed_by_libraries]),
151
+ );
152
+
153
+ const FRAMEWORK_NOTE_ALIASES = {
154
+ nextjs: "react",
155
+ gatsby: "react",
156
+ nuxt: "vue",
157
+ };
158
+
159
+ function resolveFrameworkNotesKey(framework) {
160
+ if (!framework) return null;
161
+ return FRAMEWORK_NOTE_ALIASES[framework] || framework;
162
+ }
163
+
164
+ function resolveCmsNotesKey(framework) {
165
+ if (!framework) return null;
166
+ return framework;
167
+ }
168
+
169
+ /**
170
+ * Filters the intelligence notes to only include those relevant to the detected framework.
171
+ * @param {Object} notes - The notes object from intelligence.json.
172
+ * @param {string} framework - The detected framework ID.
173
+ * @returns {Object|null} A filtered notes object or null.
174
+ */
175
+ function filterNotes(notes, framework) {
176
+ if (!notes || typeof notes !== "object") return null;
177
+ const intelKey = resolveFrameworkNotesKey(framework);
178
+ if (intelKey && notes[intelKey]) return { [intelKey]: notes[intelKey] };
179
+ const cmsKey = resolveCmsNotesKey(framework);
180
+ if (cmsKey && notes[cmsKey]) return { [cmsKey]: notes[cmsKey] };
181
+ return null;
182
+ }
183
+
184
+ /**
185
+ * Returns the file search glob pattern for the given framework and code language.
186
+ * @param {string} framework - The detected framework ID.
187
+ * @param {string} codeLang - The detected code language (jsx, css, etc.).
188
+ * @returns {string|null} The glob pattern or null if not found.
189
+ */
190
+ function getFileSearchPattern(framework, codeLang) {
191
+ const globs = FRAMEWORK_GLOBS[framework];
192
+ if (!globs) return null;
193
+ return codeLang === "css" ? globs.styles : globs.components;
194
+ }
195
+
196
+ /**
197
+ * Checks if a rule is typically managed by a UI library like Radix or Headless UI.
198
+ * Uses managed_by_libraries from intelligence.json instead of hardcoded lists.
199
+ * @param {string} ruleId - The ID of the accessibility rule.
200
+ * @param {string[]} uiLibraries - List of detected UI libraries.
201
+ * @returns {string|null} The name of the managing library or null.
202
+ */
203
+ function getManagedByLibrary(ruleId, uiLibraries) {
204
+ const allowedLibs = MANAGED_RULES.get(ruleId);
205
+ if (!allowedLibs) return null;
206
+ const managed = uiLibraries.filter((lib) => allowedLibs.includes(lib));
207
+ if (managed.length === 0) return null;
208
+ return managed.join(", ");
209
+ }
210
+
211
+ /**
212
+ * Extracts a component name hint from a CSS selector (e.g., from BEM classes).
213
+ * @param {string} selector - The CSS selector to analyze.
214
+ * @returns {string|null} A potential component name or null.
215
+ */
216
+ const TAILWIND_UTILITY_RE =
217
+ /^(?:p|px|py|pt|pr|pb|pl|m|mx|my|mt|mr|mb|ml|w|h|min|max|gap|space|text|bg|border|rounded|shadow|opacity|z|top|right|bottom|left|flex|grid|items|justify|content|self|col|row|sr|group|peer|focus|hover|active|disabled|lg|md|sm|xl|font|leading|tracking|uppercase|lowercase|capitalize|truncate|overflow|relative|absolute|fixed|sticky|inset|block|inline|hidden|cursor|pointer|select|resize|transition|duration|ease|delay|animate|fill|stroke|ring|outline|divide|placeholder|caret|accent|appearance|list|break|whitespace|order|grow|shrink|basis|aspect|container|prose|line)-/i;
218
+
219
+ function isSemanticClass(value) {
220
+ if (value.length <= 3) return false;
221
+ if (/^\d/.test(value)) return false;
222
+ if (TAILWIND_UTILITY_RE.test(value)) return false;
223
+ return true;
224
+ }
225
+
226
+ function extractComponentHint(selector) {
227
+ if (!selector || selector === "N/A") return null;
228
+ // Strip attribute selector values to prevent false class matches (e.g. href$="facebook.com/")
229
+ const stripped = selector.replace(/\[[^\]]*\]/g, "[]");
230
+ // IDs are most semantic — check first
231
+ const idMatch = stripped.match(/#([\w-]+)/);
232
+ if (idMatch && idMatch[1].length > 3 && !/^\d/.test(idMatch[1])) return idMatch[1];
233
+ // Scan all classes in the full selector chain, return first non-utility class
234
+ const classes = [...stripped.matchAll(/\.([\w-]+)/g)].map((m) => m[1]);
235
+ for (const cls of classes) {
236
+ if (isSemanticClass(cls)) return cls;
237
+ }
238
+ return null;
239
+ }
240
+
241
+ function derivePageHint(routePath) {
242
+ if (!routePath || routePath === "/") return "homepage";
243
+ const segment = routePath.replace(/^\//, "").split("/")[0];
244
+ if (segment === "homepage") return "homepage";
245
+ if (segment === "products") return "product-page";
246
+ // Strip trailing long alphanumeric slugs (e.g. account/login → account-login)
247
+ return routePath.replace(/^\//, "").replace(/\//g, "-").replace(/-[a-z0-9]{8,}(-[a-z0-9]+)*$/i, "") || segment;
248
+ }
249
+
250
+ /**
251
+ * Prints the CLI usage instructions and available options for the analyzer.
252
+ */
253
+ function printUsage() {
254
+ log.info(`Usage:
255
+ node analyzer.mjs --input <route-checks.json> [options]
256
+
257
+ Options:
258
+ --output <path> Output findings JSON path (default: .audit/a11y-findings.json)
259
+ --ignore-findings <csv> Ignore specific rule IDs (overrides config)
260
+ -h, --help Show this help
261
+ `);
262
+ }
263
+
264
+ /**
265
+ * Parses command-line arguments into a structured object for the analyzer.
266
+ * @param {string[]} argv - Array of command-line arguments.
267
+ * @returns {Object} A configuration object for the analyzer.
268
+ */
269
+ function parseArgs(argv) {
270
+ if (argv.includes("--help") || argv.includes("-h")) {
271
+ printUsage();
272
+ process.exit(0);
273
+ }
274
+
275
+ const args = {
276
+ input: getInternalPath("a11y-scan-results.json"),
277
+ output: getInternalPath("a11y-findings.json"),
278
+ ignoreFindings: [],
279
+ };
280
+
281
+ for (let i = 0; i < argv.length; i += 1) {
282
+ const key = argv[i];
283
+ const value = argv[i + 1];
284
+ if (!key.startsWith("--") || value === undefined) continue;
285
+
286
+ if (key === "--input") args.input = value;
287
+ if (key === "--output") args.output = value;
288
+ if (key === "--ignore-findings")
289
+ args.ignoreFindings = value.split(",").map((v) => v.trim());
290
+ if (key === "--framework") args.framework = value;
291
+ i += 1;
292
+ }
293
+
294
+ if (!args.input) throw new Error("Missing required --input");
295
+ return args;
296
+ }
297
+
298
+ const IMPACT_MAP = COMPLIANCE_CONFIG.impactMap;
299
+
300
+ /**
301
+ * Maps axe-core rule tags to a human-readable WCAG level string.
302
+ * @param {string[]} tags - The tags associated with a violation.
303
+ * @returns {string} The WCAG level (e.g., "WCAG 2.1 AA").
304
+ */
305
+ function mapWcag(tags) {
306
+ const labels = WCAG_REFERENCE.wcagTagLabels || {};
307
+ const precedence = WCAG_REFERENCE.wcagTagPrecedence || Object.keys(labels);
308
+ for (const key of precedence) {
309
+ if (tags.includes(key) && labels[key]) return labels[key];
310
+ }
311
+ return "WCAG";
312
+ }
313
+
314
+ /**
315
+ * Detects the implicit ARIA role of an HTML element if not explicitly specified.
316
+ * @param {string} tag - The HTML tag name.
317
+ * @param {string} html - The raw HTML of the element.
318
+ * @returns {string|null} The implicit role or null.
319
+ */
320
+ export function detectImplicitRole(tag, html) {
321
+ if (!tag) return null;
322
+ const roles = WCAG_REFERENCE.implicitRoles || {};
323
+ if (tag === "input") {
324
+ const type = html?.match(/type=["']([^"']+)["']/i)?.[1]?.toLowerCase();
325
+ return (roles.inputTypeMap || {})[type] ?? null;
326
+ }
327
+ return (roles.tagMap || {})[tag] ?? null;
328
+ }
329
+
330
+ /**
331
+ * Extracts a simplified search target from a complex CSS selector to help locate it in code.
332
+ * @param {string} selector - The complex CSS selector.
333
+ * @returns {string} A simplified search string (e.g., "#my-id" or ".my-class").
334
+ */
335
+ export function extractSearchHint(selector) {
336
+ if (!selector || selector === "N/A") return selector;
337
+ const specific =
338
+ selector
339
+ .split(/[\s>+~]+/)
340
+ .filter(Boolean)
341
+ .pop() || selector;
342
+ const id = specific.match(/#([\w-]+)/)?.[1];
343
+ if (id) return `id="${id}"`;
344
+ const cls = specific.match(/\.([\w-]+)/)?.[1];
345
+ if (cls) return `.${cls}`;
346
+ const tag = specific.match(/^([a-z][a-z0-9-]*)/i)?.[1];
347
+ if (tag) return `<${tag}`;
348
+ return specific;
349
+ }
350
+
351
+ const FAILURE_MODE_MAP = AXE_CHECK_MAPS.failureModes || {};
352
+
353
+ const RELATIONSHIP_HINT_MAP = AXE_CHECK_MAPS.relationshipHints || {};
354
+
355
+ function normalizeFailureMessage(message) {
356
+ if (!message) return null;
357
+ return String(message).trim().replace(/\.$/, "");
358
+ }
359
+
360
+ function deriveRelationshipHint(checks = [], preferredChecks = []) {
361
+ const preferredOrder = Array.isArray(preferredChecks)
362
+ ? preferredChecks.filter(Boolean)
363
+ : [];
364
+ for (const checkId of preferredOrder) {
365
+ if (checks.some((check) => check?.id === checkId) && RELATIONSHIP_HINT_MAP[checkId]) {
366
+ return RELATIONSHIP_HINT_MAP[checkId];
367
+ }
368
+ }
369
+ for (const check of checks) {
370
+ if (check?.id && RELATIONSHIP_HINT_MAP[check.id]) {
371
+ return RELATIONSHIP_HINT_MAP[check.id];
372
+ }
373
+ }
374
+ return null;
375
+ }
376
+
377
+ /**
378
+ * Extracts a compact explanation of why axe flagged a node.
379
+ * @param {Object} node
380
+ * @param {{ preferredRelationshipChecks?: string[] }} [options]
381
+ * @returns {{ primaryFailureMode: string|null, relationshipHint: string|null, failureChecks: string[], relatedContext: string[] }}
382
+ */
383
+ export function extractFailureInsights(node = {}, options = {}) {
384
+ const checks = ["any", "all", "none"]
385
+ .flatMap((bucket) => (Array.isArray(node[bucket]) ? node[bucket] : []))
386
+ .filter(Boolean);
387
+
388
+ if (checks.length === 0) {
389
+ return {
390
+ primaryFailureMode: null,
391
+ relationshipHint: null,
392
+ failureChecks: [],
393
+ relatedContext: [],
394
+ checkData: null,
395
+ };
396
+ }
397
+
398
+ const primaryCheckId = checks[0]?.id || null;
399
+ const primaryFailureMode = primaryCheckId
400
+ ? FAILURE_MODE_MAP[primaryCheckId] || primaryCheckId.replace(/-/g, "_")
401
+ : null;
402
+ const relationshipHint = deriveRelationshipHint(
403
+ checks,
404
+ options.preferredRelationshipChecks || [],
405
+ );
406
+
407
+ const failureChecks = [
408
+ ...new Set(
409
+ checks
410
+ .map((check) => normalizeFailureMessage(check.message))
411
+ .filter(Boolean),
412
+ ),
413
+ ];
414
+
415
+ const relatedContext = [
416
+ ...new Set(
417
+ checks
418
+ .flatMap((check) =>
419
+ Array.isArray(check.relatedNodes) ? check.relatedNodes : [],
420
+ )
421
+ .map((related) =>
422
+ normalizeFailureMessage(
423
+ related?.html ||
424
+ (Array.isArray(related?.target) ? related.target.join(" ") : null),
425
+ ),
426
+ )
427
+ .filter(Boolean),
428
+ ),
429
+ ];
430
+
431
+ const checkData = (() => {
432
+ const raw = checks[0]?.data ?? null;
433
+ if (raw === null || raw === undefined) return null;
434
+ if (typeof raw === "object" && "isIframe" in raw) return null;
435
+ return raw;
436
+ })();
437
+
438
+ return {
439
+ primaryFailureMode,
440
+ relationshipHint,
441
+ failureChecks,
442
+ relatedContext,
443
+ checkData,
444
+ };
445
+ }
446
+
447
+ function deriveSourceRoots(fileSearchPattern) {
448
+ if (!fileSearchPattern) return [];
449
+ return fileSearchPattern
450
+ .split(",")
451
+ .map((entry) => entry.trim())
452
+ .filter(Boolean)
453
+ .map((entry) => entry.split("/**")[0].replace(/\*.*$/, "").replace(/\/$/, ""))
454
+ .filter(Boolean);
455
+ }
456
+
457
+ function findCrossOriginFrameHost(html, pageUrl) {
458
+ if (!html || !/<iframe\b/i.test(html)) return null;
459
+ const src = html.match(/<iframe\b[^>]*\bsrc=["']([^"']+)["']/i)?.[1];
460
+ if (!src) return null;
461
+ try {
462
+ const frameUrl = new URL(src, pageUrl);
463
+ const pageOrigin = new URL(pageUrl).origin;
464
+ return frameUrl.origin !== pageOrigin ? frameUrl.hostname : null;
465
+ } catch {
466
+ return null;
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Classifies whether a finding appears to belong to the primary editable source.
472
+ * @param {Object} input
473
+ * @param {string[]} [input.evidenceHtml=[]]
474
+ * @param {string} [input.selector=""]
475
+ * @param {string} [input.pageUrl=""]
476
+ * @param {string|null} [input.fileSearchPattern=null]
477
+ * @returns {{ status: "primary" | "outside_primary_source" | "unknown", reason: string, searchStrategy: string, primarySourceScope: string[] }}
478
+ */
479
+ export function classifyFindingOwnership({
480
+ evidenceHtml = [],
481
+ selector = "",
482
+ pageUrl = "",
483
+ fileSearchPattern = null,
484
+ } = {}) {
485
+ const primarySourceScope = deriveSourceRoots(fileSearchPattern);
486
+ const html = evidenceHtml.filter(Boolean).join(" ");
487
+ const selectorText = String(selector || "");
488
+
489
+ if (/wp-content\/plugins\//i.test(html)) {
490
+ return {
491
+ status: "outside_primary_source",
492
+ reason: "The captured DOM references a WordPress plugin asset path, not the primary source tree.",
493
+ searchStrategy: "verify_ownership_before_search",
494
+ primarySourceScope,
495
+ };
496
+ }
497
+
498
+ const crossOriginFrameHost = findCrossOriginFrameHost(html, pageUrl);
499
+ if (crossOriginFrameHost) {
500
+ return {
501
+ status: "outside_primary_source",
502
+ reason: `The element is rendered inside a cross-origin iframe from ${crossOriginFrameHost}.`,
503
+ searchStrategy: "verify_ownership_before_search",
504
+ primarySourceScope,
505
+ };
506
+ }
507
+
508
+ if (
509
+ /(^|[\s,>+~])iframe\b/i.test(selectorText) &&
510
+ /src=["']https?:\/\//i.test(html)
511
+ ) {
512
+ return {
513
+ status: "outside_primary_source",
514
+ reason: "The finding targets an externally sourced iframe, which is usually outside the primary source tree.",
515
+ searchStrategy: "verify_ownership_before_search",
516
+ primarySourceScope,
517
+ };
518
+ }
519
+
520
+ if (primarySourceScope.length === 0) {
521
+ return {
522
+ status: "unknown",
523
+ reason: "The primary editable source could not be resolved for this project context.",
524
+ searchStrategy: "verify_ownership_before_search",
525
+ primarySourceScope,
526
+ };
527
+ }
528
+
529
+ return {
530
+ status: "primary",
531
+ reason: `The finding should be addressed in the primary source tree (${primarySourceScope.join(", ")}).`,
532
+ searchStrategy: "direct_source_patch",
533
+ primarySourceScope,
534
+ };
535
+ }
536
+
537
+ /**
538
+ * Checks whether a single finding is a confirmed false positive based on evidence HTML patterns.
539
+ * Only covers cases where the violation is provably impossible given the axe evidence.
540
+ * @param {Object} finding - An enriched finding object.
541
+ * @returns {boolean}
542
+ */
543
+ function isFalsePositive(finding) {
544
+ const htmls = finding.evidence.map((e) => e.html || "").join(" ");
545
+ if (finding.rule_id === "color-contrast") {
546
+ if (/background(?:-image)?\s*:\s*(?:linear|radial|conic)-gradient/i.test(htmls)) return true;
547
+ if (/visibility\s*:\s*hidden|display\s*:\s*none/i.test(htmls)) return true;
548
+ }
549
+ return false;
550
+ }
551
+
552
+ /**
553
+ * Removes confirmed false positives from findings.
554
+ * @param {Object[]} findings
555
+ * @returns {{ filtered: Object[], removedCount: number }}
556
+ */
557
+ function filterFalsePositives(findings) {
558
+ const filtered = [];
559
+ let removedCount = 0;
560
+ for (const finding of findings) {
561
+ if (isFalsePositive(finding)) {
562
+ removedCount++;
563
+ } else {
564
+ filtered.push(finding);
565
+ }
566
+ }
567
+ return { filtered, removedCount };
568
+ }
569
+
570
+ /**
571
+ * Deduplicates findings that fire on the same rule and element pattern across multiple pages.
572
+ * Groups them into one representative finding with pages_affected and affected_urls.
573
+ * @param {Object[]} findings
574
+ * @returns {{ findings: Object[], deduplicatedCount: number }}
575
+ */
576
+ function deduplicateFindings(findings) {
577
+ function normalizeSelector(selector) {
578
+ if (!selector) return "";
579
+ return selector
580
+ .replace(/:nth-child\(\d+\)/g, "")
581
+ .replace(/:nth-of-type\(\d+\)/g, "")
582
+ .replace(/\[\d+\]/g, "")
583
+ .trim();
584
+ }
585
+
586
+ const UNGROUPABLE = new Set(["N/A", "", "html", "body", ":root", "document"]);
587
+
588
+ const groups = new Map();
589
+ for (const finding of findings) {
590
+ const normalized = normalizeSelector(finding.primary_selector);
591
+ const key = UNGROUPABLE.has(normalized)
592
+ ? `__ungroupable__${finding.id}`
593
+ : `${finding.rule_id}||${normalized}`;
594
+ if (!groups.has(key)) groups.set(key, []);
595
+ groups.get(key).push(finding);
596
+ }
597
+
598
+ const result = [];
599
+ let deduplicatedCount = 0;
600
+ for (const [, group] of groups) {
601
+ if (group.length === 1) {
602
+ result.push(group[0]);
603
+ } else {
604
+ const representative = group.reduce((best, f) =>
605
+ (f.total_instances || 1) >= (best.total_instances || 1) ? f : best,
606
+ );
607
+ const affectedUrls = [...new Set(group.map((f) => f.url))];
608
+ const totalInstances = group.reduce((sum, f) => sum + (f.total_instances || 1), 0);
609
+ result.push({
610
+ ...representative,
611
+ total_instances: totalInstances,
612
+ pages_affected: affectedUrls.length,
613
+ affected_urls: affectedUrls,
614
+ });
615
+ deduplicatedCount++;
616
+ }
617
+ }
618
+ return { findings: result, deduplicatedCount };
619
+ }
620
+
621
+ /**
622
+ * Computes the overall WCAG compliance assessment.
623
+ * @param {Object[]} findings
624
+ * @returns {'Fail' | 'Conditional Pass' | 'Pass'}
625
+ */
626
+ function computeOverallAssessment(findings) {
627
+ const wcagFindings = findings.filter(
628
+ (f) => f.wcag_classification !== "Best Practice" && f.wcag_classification !== "AAA",
629
+ );
630
+ if (wcagFindings.some((f) => f.severity === "Critical" || f.severity === "Serious")) return "Fail";
631
+ if (wcagFindings.length > 0) return "Conditional Pass";
632
+ return "Pass";
633
+ }
634
+
635
+ /**
636
+ * Aggregates WCAG 2.2 AA criteria that passed across all scanned routes.
637
+ * @param {Object[]} routes - Raw scan routes with a passes array of rule IDs.
638
+ * @param {Object<string, string>} wcagCriterionMap - Map from rule_id to WCAG criterion ID.
639
+ * @returns {string[]} Sorted unique WCAG criterion IDs that passed.
640
+ */
641
+ function computePassedCriteria(routes, wcagCriterionMap, activeFindings = []) {
642
+ const passed = new Set();
643
+ for (const route of routes) {
644
+ for (const ruleId of route.passes || []) {
645
+ const criterion = wcagCriterionMap[ruleId];
646
+ if (criterion) passed.add(criterion);
647
+ }
648
+ }
649
+ for (const finding of activeFindings) {
650
+ const criterion = wcagCriterionMap[finding.rule_id];
651
+ if (criterion) passed.delete(criterion);
652
+ }
653
+ return [...passed].sort();
654
+ }
655
+
656
+ /**
657
+ * Computes out-of-scope information: errored routes and AAA exclusion flag.
658
+ * @param {Object[]} routes - Raw scan routes.
659
+ * @returns {Object}
660
+ */
661
+ function computeOutOfScope(routes) {
662
+ const authBlockedRoutes = routes
663
+ .filter((r) => r.error)
664
+ .map((r) => r.url || r.path)
665
+ .filter(Boolean);
666
+ return {
667
+ auth_blocked_routes: authBlockedRoutes,
668
+ aaa_excluded: true,
669
+ };
670
+ }
671
+
672
+ /**
673
+ * Classifies a finding's WCAG status based on axe rule tags.
674
+ * @param {string[]} tags - The violation's axe tags.
675
+ * @param {string|null} wcagCriterionId - The mapped WCAG criterion ID.
676
+ * @returns {'Best Practice' | 'AAA' | null} null = standard AA/A finding.
677
+ */
678
+ function classifyWcag(tags, wcagCriterionId) {
679
+ const isWcagAorAA = tags.some((t) => /^wcag\d+(a|aa)$/.test(t));
680
+ if (isWcagAorAA) return null;
681
+ if (tags.includes("best-practice")) return "Best Practice";
682
+ if (tags.some((t) => /aaa/i.test(t))) return "AAA";
683
+ if (!wcagCriterionId) return "Best Practice";
684
+ return null;
685
+ }
686
+
687
+ /**
688
+ * Identifies single-point-fix opportunities and systemic WCAG patterns from findings.
689
+ * @param {Object[]} findings - Deduplicated enriched findings.
690
+ * @returns {{ single_point_fixes: Object[], systemic_patterns: Object[] }}
691
+ */
692
+ function computeRecommendations(findings) {
693
+ const componentGroups = new Map();
694
+ for (const f of findings) {
695
+ if (!f.component_hint) continue;
696
+ if (!componentGroups.has(f.component_hint)) componentGroups.set(f.component_hint, []);
697
+ componentGroups.get(f.component_hint).push(f);
698
+ }
699
+
700
+ const singlePointFixes = [];
701
+ for (const [component, group] of componentGroups) {
702
+ const maxPages = Math.max(...group.map((f) => f.pages_affected || 1));
703
+ if (group.length < 2 && maxPages < 3) continue;
704
+ singlePointFixes.push({
705
+ component,
706
+ total_issues: group.length,
707
+ total_pages: maxPages,
708
+ rules: [...new Set(group.map((f) => f.rule_id))],
709
+ severities: [...new Set(group.map((f) => f.severity))],
710
+ });
711
+ }
712
+ singlePointFixes.sort((a, b) => (b.total_issues * b.total_pages) - (a.total_issues * a.total_pages));
713
+
714
+ const criterionGroups = new Map();
715
+ for (const f of findings) {
716
+ if (!f.wcag_criterion_id) continue;
717
+ if (!criterionGroups.has(f.wcag_criterion_id)) criterionGroups.set(f.wcag_criterion_id, []);
718
+ criterionGroups.get(f.wcag_criterion_id).push(f);
719
+ }
720
+
721
+ const systemicPatterns = [];
722
+ for (const [criterion, group] of criterionGroups) {
723
+ if (group.length < 3) continue;
724
+ const components = [...new Set(group.map((f) => f.component_hint).filter(Boolean))];
725
+ if (components.length < 2) continue;
726
+ systemicPatterns.push({
727
+ wcag_criterion: criterion,
728
+ total_issues: group.length,
729
+ affected_components: components,
730
+ rules: [...new Set(group.map((f) => f.rule_id))],
731
+ });
732
+ }
733
+ systemicPatterns.sort((a, b) => b.total_issues - a.total_issues);
734
+
735
+ return { single_point_fixes: singlePointFixes, systemic_patterns: systemicPatterns };
736
+ }
737
+
738
+ /**
739
+ * Auto-generates testing methodology metadata from the raw scan payload.
740
+ * @param {Object} payload - Raw scan payload from dom-scanner.mjs.
741
+ * @returns {Object}
742
+ */
743
+ function computeTestingMethodology(payload) {
744
+ const routes = payload.routes || [];
745
+ const scanned = routes.filter((r) => !r.error).length;
746
+ const errored = routes.filter((r) => r.error).length;
747
+ return {
748
+ automated_tools: ["axe-core (via @axe-core/playwright)", "Playwright + Chromium"],
749
+ compliance_target: "WCAG 2.2 AA",
750
+ pages_scanned: scanned,
751
+ pages_errored: errored,
752
+ framework_detected: payload.projectContext?.framework || "Not detected",
753
+ manual_testing: "Not performed (automated scan only)",
754
+ assistive_tech_tested: "None (automated scan only)",
755
+ };
756
+ }
757
+
758
+ /**
759
+ * Processes scan results into a high-level auditing findings object.
760
+ * Enriches findings with intelligence metadata, framework notes, and fix recommendations.
761
+ * @param {Object} inputPayload - The raw JSON payload from dom-scanner.mjs.
762
+ * @param {Object} cliArgs - Arguments passed to the analyzer.
763
+ * @returns {Object} A structured object containing findings and metadata.
764
+ */
765
+ function buildFindings(inputPayload, cliArgs) {
766
+ const onlyRule = inputPayload.onlyRule;
767
+ const routes = inputPayload.routes || [];
768
+ const ctx = inputPayload.projectContext || {
769
+ framework: null,
770
+ uiLibraries: [],
771
+ };
772
+ if (cliArgs?.framework) ctx.framework = cliArgs.framework;
773
+ const findings = [];
774
+
775
+ for (const route of routes) {
776
+ if (route.violations) {
777
+ for (const v of route.violations) {
778
+ const nodes = v.nodes || [];
779
+ const selectors = nodes.map((n) => n.target.join(" ")).slice(0, 5);
780
+ const scoreSelectorSpecificity = (s) => {
781
+ if (!s || s === "N/A") return -1;
782
+ let score = 0;
783
+ if (/#[\w-]+/.test(s)) score += 100;
784
+ if (/\[data-[\w-]+/.test(s)) score += 80;
785
+ if (/\[aria-[\w-]+/.test(s)) score += 60;
786
+ if (/\[(name|role|type)=/.test(s)) score += 40;
787
+ const tailwindPattern = /(^|\s|\.)(sm:|md:|lg:|xl:|2xl:|focus:|hover:|active:|disabled:|group-|peer-|\w+-\d+|[pmwh][xytblr]?-|text-(xs|sm|base|lg|xl|\d)|bg-|border-|rounded|shadow|opacity-|flex-?|grid-?|items-|justify-|gap-|space-|z-\d|leading-|tracking-|font-|w-|h-|min-|max-|top-|right-|bottom-|left-|translate-|rotate-|scale-|skew-)/;
788
+ if (tailwindPattern.test(s)) score -= 40;
789
+ score -= s.length * 0.1;
790
+ return score;
791
+ };
792
+ const bestSelector = selectors.reduce((best, s) =>
793
+ scoreSelectorSpecificity(s) > scoreSelectorSpecificity(best) ? s : best,
794
+ selectors[0] || "N/A");
795
+ const firstNode = nodes[0];
796
+ const explicitRole =
797
+ firstNode?.html?.match(/role=["']([^"']+)["']/)?.[1] ?? null;
798
+ const tag =
799
+ firstNode?.html?.match(/^<([a-z][a-z0-9-]*)/i)?.[1]?.toLowerCase() ??
800
+ null;
801
+ const role = explicitRole ?? detectImplicitRole(tag, firstNode?.html);
802
+ const apgUrl = role ? APG_PATTERNS[role] : null;
803
+
804
+ const ruleInfo = RULES[v.id] || {};
805
+ const fixInfo = ruleInfo.fix || {};
806
+ const resolvedGuardrails = resolveGuardrails(ruleInfo, null);
807
+
808
+ let recFix = apgUrl
809
+ ? `Reference: ${apgUrl}`
810
+ : v.helpUrl
811
+ ? `See ${v.helpUrl}`
812
+ : "Fix the violation.";
813
+
814
+ const codeLang = detectCodeLang(fixInfo.code);
815
+ const fileSearchPattern = getFileSearchPattern(ctx.framework, codeLang);
816
+ const ownership = classifyFindingOwnership({
817
+ evidenceHtml: nodes.map((n) => n.html || ""),
818
+ selector: bestSelector,
819
+ pageUrl: route.url,
820
+ fileSearchPattern,
821
+ });
822
+ const failureInsights = extractFailureInsights(firstNode, {
823
+ preferredRelationshipChecks: ruleInfo.preferred_relationship_checks || [],
824
+ });
825
+
826
+ findings.push({
827
+ id: "",
828
+ rule_id: v.id,
829
+ title: v.help,
830
+ severity: IMPACT_MAP[v.impact] || "Medium",
831
+ wcag: mapWcag(v.tags),
832
+ wcag_criterion_id: WCAG_CRITERION_MAP[v.id] ?? null,
833
+ wcag_classification: classifyWcag(v.tags, WCAG_CRITERION_MAP[v.id] ?? null),
834
+ area: `${route.path}`,
835
+ url: route.url,
836
+ selector: selectors.join(", "),
837
+ impacted_users: getImpactedUsers(v.id, v.tags),
838
+ primary_selector: bestSelector,
839
+ actual:
840
+ firstNode?.failureSummary || `Found ${nodes.length} instance(s).`,
841
+ primary_failure_mode: failureInsights.primaryFailureMode,
842
+ relationship_hint: failureInsights.relationshipHint,
843
+ failure_checks: failureInsights.failureChecks,
844
+ related_context: failureInsights.relatedContext,
845
+ expected: getExpected(v.id),
846
+ category: ruleInfo.category ?? null,
847
+ fix_description: fixInfo.description ?? null,
848
+ fix_code: fixInfo.code ?? null,
849
+ fix_code_lang: codeLang,
850
+ recommended_fix: recFix.trim(),
851
+ mdn: MDN[v.id] ?? null,
852
+ effort: null,
853
+ related_rules: Array.isArray(ruleInfo.related_rules)
854
+ ? ruleInfo.related_rules
855
+ : [],
856
+ guardrails: resolvedGuardrails,
857
+ false_positive_risk: ruleInfo.false_positive_risk ?? null,
858
+ fix_difficulty_notes: ruleInfo.fix_difficulty_notes ?? null,
859
+ framework_notes: filterNotes(ruleInfo.framework_notes, ctx.framework),
860
+ cms_notes: filterNotes(ruleInfo.cms_notes, ctx.framework),
861
+ check_data: failureInsights.checkData,
862
+ total_instances: nodes.length,
863
+ evidence: nodes.slice(0, 3).map((n) => ({
864
+ html: n.html,
865
+ target: n.target,
866
+ failureSummary: n.failureSummary,
867
+ ancestry: n.ancestry?.[0] ?? null,
868
+ })),
869
+ screenshot_path: v.screenshot_path || null,
870
+ file_search_pattern:
871
+ ownership.status === "primary" ? fileSearchPattern : null,
872
+ managed_by_library: getManagedByLibrary(v.id, ctx.uiLibraries),
873
+ ownership_status: ownership.status,
874
+ ownership_reason: ownership.reason,
875
+ primary_source_scope: ownership.primarySourceScope,
876
+ search_strategy: ownership.searchStrategy,
877
+ component_hint: extractComponentHint(bestSelector) ?? derivePageHint(route.path),
878
+ verification_command: `pnpm a11y --base-url ${route.url} --routes ${route.path} --only-rule ${v.id} --max-routes 1`,
879
+ verification_command_fallback: `node scripts/audit.mjs --base-url ${route.url} --routes ${route.path} --only-rule ${v.id} --max-routes 1`,
880
+ });
881
+ }
882
+ }
883
+
884
+ }
885
+
886
+ return {
887
+ findings: findings.map((f) => ({
888
+ ...f,
889
+ id: makeFindingId(f.rule_id || f.title, f.url, f.selector),
890
+ })),
891
+ metadata: {
892
+ scanDate: new Date().toISOString(),
893
+ checklist: "https://www.a11yproject.com/checklist/",
894
+ projectContext: ctx,
895
+ },
896
+ };
897
+ }
898
+
899
+ /**
900
+ * Collects and deduplicates incomplete (needs-review) violations from raw scan routes.
901
+ * @param {Object[]} routes
902
+ * @returns {Object[]}
903
+ */
904
+ export function collectIncompleteFindings(routes) {
905
+ const groups = new Map();
906
+ for (const route of routes) {
907
+ for (const v of (route.incomplete || [])) {
908
+ const firstNode = v.nodes?.[0];
909
+ const message =
910
+ firstNode?.any?.[0]?.message ??
911
+ firstNode?.all?.[0]?.message ??
912
+ firstNode?.none?.[0]?.message ??
913
+ null;
914
+ const key = `${v.id}||${message ?? v.description ?? ""}`;
915
+ if (!groups.has(key)) {
916
+ const selector = Array.isArray(firstNode?.target)
917
+ ? firstNode.target.join(" ")
918
+ : "N/A";
919
+ groups.set(key, {
920
+ rule_id: v.id,
921
+ title: v.help,
922
+ description: v.description,
923
+ impact: v.impact ?? null,
924
+ selector,
925
+ message,
926
+ areas: new Set(),
927
+ });
928
+ }
929
+ groups.get(key).areas.add(route.path);
930
+ }
931
+ }
932
+ return [...groups.values()].map((g) => ({
933
+ rule_id: g.rule_id,
934
+ title: g.title,
935
+ description: g.description,
936
+ impact: g.impact,
937
+ selector: g.selector,
938
+ message: g.message,
939
+ pages_affected: g.areas.size,
940
+ areas: [...g.areas],
941
+ }));
942
+ }
943
+
944
+ /**
945
+ * The main execution function for the analyzer script.
946
+ * Reads scan results, processes findings, and writes the final findings JSON.
947
+ */
948
+ function main() {
949
+ const args = parseArgs(process.argv.slice(2));
950
+ const ignoredRules = new Set(args.ignoreFindings);
951
+
952
+ const payload = readJson(args.input);
953
+ if (!payload) throw new Error(`Input not found or invalid: ${args.input}`);
954
+
955
+ const result = buildFindings(payload, args);
956
+
957
+ if (ignoredRules.size > 0) {
958
+ const knownIds = new Set(
959
+ result.findings.map((f) => f.rule_id).filter(Boolean),
960
+ );
961
+ for (const r of ignoredRules) {
962
+ if (!knownIds.has(r))
963
+ log.warn(`ignoreFindings: rule "${r}" not found in this scan — typo?`);
964
+ }
965
+ }
966
+
967
+ let findings =
968
+ ignoredRules.size > 0
969
+ ? result.findings.filter((f) => !ignoredRules.has(f.rule_id))
970
+ : result.findings;
971
+
972
+ if (ignoredRules.size > 0 && result.findings.length !== findings.length) {
973
+ log.info(
974
+ `Ignored ${result.findings.length - findings.length} finding(s) via ignoreFindings config.`,
975
+ );
976
+ }
977
+
978
+ const { filtered: fpFiltered, removedCount: fpRemovedCount } = filterFalsePositives(findings);
979
+ if (fpRemovedCount > 0) log.info(`Removed ${fpRemovedCount} confirmed false positive(s).`);
980
+
981
+ const { findings: dedupedFindings, deduplicatedCount } = deduplicateFindings(fpFiltered);
982
+ if (deduplicatedCount > 0) log.info(`Deduplicated ${deduplicatedCount} cross-page finding group(s).`);
983
+
984
+ const overallAssessment = computeOverallAssessment(dedupedFindings);
985
+ const passedCriteria = computePassedCriteria(payload.routes || [], WCAG_CRITERION_MAP, dedupedFindings);
986
+ const outOfScope = computeOutOfScope(payload.routes || []);
987
+ const recommendations = computeRecommendations(dedupedFindings);
988
+ const testingMethodology = computeTestingMethodology(payload);
989
+ const incompleteFindings = collectIncompleteFindings(payload.routes || []);
990
+ if (incompleteFindings.length > 0)
991
+ log.info(`${incompleteFindings.length} incomplete finding(s) require manual review.`);
992
+
993
+ writeJson(args.output, {
994
+ ...result,
995
+ findings: dedupedFindings,
996
+ incomplete_findings: incompleteFindings,
997
+ metadata: {
998
+ ...result.metadata,
999
+ overallAssessment,
1000
+ passedCriteria,
1001
+ outOfScope,
1002
+ recommendations,
1003
+ testingMethodology,
1004
+ fpFiltered: fpRemovedCount,
1005
+ deduplicatedCount,
1006
+ },
1007
+ });
1008
+
1009
+ if (dedupedFindings.length === 0) {
1010
+ log.info("Congratulations, no issues found.");
1011
+ }
1012
+ log.success(`Findings processed and saved to ${args.output}`);
1013
+ }
1014
+
1015
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
1016
+ try {
1017
+ main();
1018
+ } catch (error) {
1019
+ log.error(error.message);
1020
+ process.exit(1);
1021
+ }
1022
+ }