@diegovelasquezweb/a11y-engine 0.6.5 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@diegovelasquezweb/a11y-engine",
3
- "version": "0.6.5",
4
- "description": "WCAG 2.2 AA accessibility audit engine — scanner, analyzer, and report builders",
3
+ "version": "0.7.0",
4
+ "description": "WCAG 2.2 accessibility audit engine — scanner, analyzer, and report builders",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -0,0 +1,249 @@
1
+ /**
2
+ * @file claude.mjs
3
+ * @description Claude AI enrichment layer for the a11y-engine.
4
+ * Enhances Critical/Serious findings with context-aware fix suggestions,
5
+ * leveraging repository source code when available via GitHub API.
6
+ *
7
+ * This module is a passthrough when:
8
+ * - options.enabled is false
9
+ * - no apiKey is provided
10
+ *
11
+ * Requires: ANTHROPIC_API_KEY in options.apiKey
12
+ * Optional: githubToken for reading private repo files
13
+ */
14
+
15
+ const ANTHROPIC_API = "https://api.anthropic.com/v1/messages";
16
+ const DEFAULT_MODEL = "claude-sonnet-4-5";
17
+ const MAX_AI_FINDINGS = 20; // cap to control cost
18
+
19
+ /**
20
+ * @typedef {import('../index.d.mts').EnrichedFinding} EnrichedFinding
21
+ */
22
+
23
+ /**
24
+ * Builds the system prompt for the AI enrichment task.
25
+ * @param {object} context
26
+ * @returns {string}
27
+ */
28
+ function buildSystemPrompt(context) {
29
+ const { framework, cms, uiLibraries } = context.stack || {};
30
+
31
+ let stackInfo = "";
32
+ if (framework) stackInfo += `Framework: ${framework}\n`;
33
+ if (cms) stackInfo += `CMS: ${cms}\n`;
34
+ if (uiLibraries?.length) stackInfo += `UI Libraries: ${uiLibraries.join(", ")}\n`;
35
+
36
+ return `You are an expert web accessibility engineer specializing in WCAG 2.2 AA remediation.
37
+
38
+ Your task is to improve the fix guidance for accessibility findings from an automated scan.
39
+ ${stackInfo ? `\nProject context:\n${stackInfo}` : ""}
40
+ For each finding you receive, provide:
41
+ 1. A clear, specific fix description (1-2 sentences, actionable, no jargon)
42
+ 2. Ready-to-use fix code in the correct language for the stack${context.hasSourceCode ? "\n3. The exact line/component to change if you can identify it from the source code" : ""}
43
+
44
+ Rules:
45
+ - Keep fix code minimal and focused — only the changed element, not the whole file
46
+ - Use the detected framework syntax (JSX for React/Next.js, template syntax for Vue, etc.)
47
+ - Do not change component logic, only accessibility attributes
48
+ - If the fix requires multiple changes, show the most important one
49
+ - Respond in JSON only — no markdown, no explanation outside the JSON structure`;
50
+ }
51
+
52
+ /**
53
+ * Builds the user message for a batch of findings.
54
+ * @param {EnrichedFinding[]} findings
55
+ * @param {Record<string, string>} sourceFiles - map of filePath -> content
56
+ * @returns {string}
57
+ */
58
+ function buildUserMessage(findings, sourceFiles) {
59
+ const items = findings.map((f, i) => ({
60
+ index: i,
61
+ ruleId: f.ruleId,
62
+ severity: f.severity,
63
+ wcag: f.wcag,
64
+ title: f.title,
65
+ selector: f.primarySelector || f.selector,
66
+ actual: f.actual,
67
+ currentFix: f.fixDescription,
68
+ currentCode: f.fixCode,
69
+ fileSearchPattern: f.fileSearchPattern || null,
70
+ }));
71
+
72
+ let message = `Improve the fix guidance for these ${findings.length} accessibility finding(s).\n\n`;
73
+ message += `FINDINGS:\n${JSON.stringify(items, null, 2)}\n`;
74
+
75
+ if (Object.keys(sourceFiles).length > 0) {
76
+ message += `\nSOURCE FILES (for context):\n`;
77
+ for (const [filePath, content] of Object.entries(sourceFiles)) {
78
+ const truncated = content.length > 4000 ? content.slice(0, 4000) + "\n... (truncated)" : content;
79
+ message += `\n--- ${filePath} ---\n${truncated}\n`;
80
+ }
81
+ }
82
+
83
+ message += `\nRespond with a JSON array where each item has:
84
+ {
85
+ "index": <number>,
86
+ "fixDescription": "<improved description>",
87
+ "fixCode": "<improved code snippet>",
88
+ "fixCodeLang": "<language: html|jsx|tsx|vue|svelte|astro|liquid|php|css>"
89
+ }`;
90
+
91
+ return message;
92
+ }
93
+
94
+ /**
95
+ * Calls the Claude API with the given messages.
96
+ * @param {string} apiKey
97
+ * @param {string} model
98
+ * @param {string} systemPrompt
99
+ * @param {string} userMessage
100
+ * @returns {Promise<string>}
101
+ */
102
+ async function callClaude(apiKey, model, systemPrompt, userMessage) {
103
+ const res = await fetch(ANTHROPIC_API, {
104
+ method: "POST",
105
+ headers: {
106
+ "Content-Type": "application/json",
107
+ "x-api-key": apiKey,
108
+ "anthropic-version": "2023-06-01",
109
+ },
110
+ body: JSON.stringify({
111
+ model,
112
+ max_tokens: 4096,
113
+ system: systemPrompt,
114
+ messages: [{ role: "user", content: userMessage }],
115
+ }),
116
+ });
117
+
118
+ if (!res.ok) {
119
+ const text = await res.text();
120
+ throw new Error(`Claude API error ${res.status}: ${text}`);
121
+ }
122
+
123
+ const data = await res.json();
124
+ return data.content?.[0]?.text ?? "";
125
+ }
126
+
127
+ /**
128
+ * Tries to fetch source files for findings that have a fileSearchPattern.
129
+ * @param {EnrichedFinding[]} findings
130
+ * @param {string} repoUrl
131
+ * @param {string|undefined} githubToken
132
+ * @returns {Promise<Record<string, string>>}
133
+ */
134
+ async function fetchSourceFilesForFindings(findings, repoUrl, githubToken) {
135
+ const sourceFiles = {};
136
+ if (!repoUrl) return sourceFiles;
137
+
138
+ const { fetchRepoFile, listRepoFiles, parseRepoUrl } = await import("../core/github-api.mjs");
139
+ if (!parseRepoUrl(repoUrl)) return sourceFiles;
140
+
141
+ const patterns = new Set(
142
+ findings
143
+ .filter((f) => f.fileSearchPattern)
144
+ .map((f) => f.fileSearchPattern)
145
+ );
146
+
147
+ for (const pattern of patterns) {
148
+ try {
149
+ // Extract extension from pattern (e.g. "src/components/*.tsx" -> ".tsx")
150
+ const extMatch = pattern.match(/\*\.(\w+)$/);
151
+ if (!extMatch) continue;
152
+ const ext = `.${extMatch[1]}`;
153
+
154
+ const files = await listRepoFiles(repoUrl, [ext], githubToken);
155
+ // Pick up to 3 most relevant files per pattern
156
+ const relevant = files.slice(0, 3);
157
+ for (const filePath of relevant) {
158
+ if (!sourceFiles[filePath]) {
159
+ const content = await fetchRepoFile(repoUrl, filePath, githubToken);
160
+ if (content) sourceFiles[filePath] = content;
161
+ }
162
+ }
163
+ } catch {
164
+ // non-fatal
165
+ }
166
+ }
167
+
168
+ return sourceFiles;
169
+ }
170
+
171
+ /**
172
+ * Enriches Critical and Serious findings using Claude AI.
173
+ * Returns findings with improved fixDescription, fixCode, and fixCodeLang.
174
+ * Passthrough if AI is disabled or no apiKey is provided.
175
+ *
176
+ * @param {EnrichedFinding[]} findings
177
+ * @param {{
178
+ * stack?: { framework?: string, cms?: string, uiLibraries?: string[] },
179
+ * repoUrl?: string,
180
+ * }} context
181
+ * @param {{
182
+ * enabled?: boolean,
183
+ * apiKey?: string,
184
+ * githubToken?: string,
185
+ * model?: string,
186
+ * }} options
187
+ * @returns {Promise<EnrichedFinding[]>}
188
+ */
189
+ export async function enrichWithAI(findings, context = {}, options = {}) {
190
+ const enabled = options.enabled !== false && !!options.apiKey;
191
+ if (!enabled) return findings;
192
+
193
+ const model = options.model || DEFAULT_MODEL;
194
+
195
+ // Only enrich Critical and Serious findings, cap total
196
+ const targets = findings
197
+ .filter((f) => f.severity === "Critical" || f.severity === "Serious")
198
+ .slice(0, MAX_AI_FINDINGS);
199
+
200
+ if (targets.length === 0) return findings;
201
+
202
+ try {
203
+ // Fetch source files if repo is available
204
+ const sourceFiles = context.repoUrl
205
+ ? await fetchSourceFilesForFindings(targets, context.repoUrl, options.githubToken)
206
+ : {};
207
+
208
+ const systemPrompt = buildSystemPrompt({
209
+ stack: context.stack,
210
+ hasSourceCode: Object.keys(sourceFiles).length > 0,
211
+ });
212
+ const userMessage = buildUserMessage(targets, sourceFiles);
213
+
214
+ const responseText = await callClaude(options.apiKey, model, systemPrompt, userMessage);
215
+
216
+ // Parse Claude's JSON response
217
+ let improvements = [];
218
+ try {
219
+ const jsonMatch = responseText.match(/\[[\s\S]*\]/);
220
+ if (jsonMatch) improvements = JSON.parse(jsonMatch[0]);
221
+ } catch {
222
+ console.warn("[a11y-engine] Could not parse Claude response as JSON, skipping AI enrichment");
223
+ return findings;
224
+ }
225
+
226
+ // Build a map by target index for fast lookup
227
+ const targetIds = new Set(targets.map((f) => f.id));
228
+ const targetList = findings.filter((f) => targetIds.has(f.id));
229
+ const improvementMap = new Map(improvements.map((imp) => [imp.index, imp]));
230
+
231
+ // Apply improvements to findings
232
+ return findings.map((finding) => {
233
+ const targetIdx = targetList.findIndex((t) => t.id === finding.id);
234
+ if (targetIdx === -1) return finding;
235
+ const imp = improvementMap.get(targetIdx);
236
+ if (!imp) return finding;
237
+
238
+ return {
239
+ ...finding,
240
+ fixDescription: imp.fixDescription || finding.fixDescription,
241
+ fixCode: imp.fixCode || finding.fixCode,
242
+ fixCodeLang: imp.fixCodeLang || finding.fixCodeLang,
243
+ };
244
+ });
245
+ } catch (err) {
246
+ console.warn(`[a11y-engine] AI enrichment failed (non-fatal): ${err.message}`);
247
+ return findings;
248
+ }
249
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @file github-api.mjs
3
+ * @description Minimal GitHub API client for reading repository content without cloning.
4
+ * Used by the engine to fetch package.json for stack detection and source files for
5
+ * pattern scanning and AI enrichment.
6
+ */
7
+
8
+ const GITHUB_API = "https://api.github.com";
9
+ const GITHUB_RAW = "https://raw.githubusercontent.com";
10
+
11
+ /**
12
+ * Parses a GitHub URL into owner and repo components.
13
+ * @param {string} repoUrl
14
+ * @returns {{ owner: string, repo: string, branch: string } | null}
15
+ */
16
+ export function parseRepoUrl(repoUrl) {
17
+ try {
18
+ const url = new URL(repoUrl);
19
+ if (url.hostname !== "github.com") return null;
20
+ const parts = url.pathname.replace(/^\//, "").replace(/\.git$/, "").split("/");
21
+ if (parts.length < 2) return null;
22
+ const [owner, repo, , branch] = parts;
23
+ return { owner, repo, branch: branch || "main" };
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Builds Authorization header if token is present.
31
+ * @param {string|undefined} token
32
+ * @returns {Record<string, string>}
33
+ */
34
+ function authHeaders(token) {
35
+ const headers = {
36
+ Accept: "application/vnd.github+json",
37
+ "X-GitHub-Api-Version": "2022-11-28",
38
+ };
39
+ if (token) headers["Authorization"] = `Bearer ${token}`;
40
+ return headers;
41
+ }
42
+
43
+ /**
44
+ * Fetches and parses package.json from a GitHub repository.
45
+ * Tries the default branch first, then falls back to 'master'.
46
+ *
47
+ * @param {string} repoUrl
48
+ * @param {string|undefined} token
49
+ * @returns {Promise<Record<string, unknown> | null>}
50
+ */
51
+ export async function fetchPackageJson(repoUrl, token) {
52
+ const parsed = parseRepoUrl(repoUrl);
53
+ if (!parsed) return null;
54
+
55
+ const { owner, repo, branch } = parsed;
56
+ const branches = [branch, branch === "main" ? "master" : "main"];
57
+
58
+ for (const b of branches) {
59
+ try {
60
+ const url = `${GITHUB_RAW}/${owner}/${repo}/${b}/package.json`;
61
+ const res = await fetch(url, {
62
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
63
+ });
64
+ if (res.ok) {
65
+ const text = await res.text();
66
+ return JSON.parse(text);
67
+ }
68
+ } catch {
69
+ // try next branch
70
+ }
71
+ }
72
+
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Fetches a file from a GitHub repository.
78
+ * Returns the raw file content as a string.
79
+ *
80
+ * @param {string} repoUrl
81
+ * @param {string} filePath - Relative path within the repo (e.g. "src/components/Header.tsx")
82
+ * @param {string|undefined} token
83
+ * @returns {Promise<string | null>}
84
+ */
85
+ export async function fetchRepoFile(repoUrl, filePath, token) {
86
+ const parsed = parseRepoUrl(repoUrl);
87
+ if (!parsed) return null;
88
+
89
+ const { owner, repo, branch } = parsed;
90
+ const branches = [branch, branch === "main" ? "master" : "main"];
91
+
92
+ for (const b of branches) {
93
+ try {
94
+ const url = `${GITHUB_RAW}/${owner}/${repo}/${b}/${filePath}`;
95
+ const res = await fetch(url, {
96
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
97
+ });
98
+ if (res.ok) return await res.text();
99
+ } catch {
100
+ // try next branch
101
+ }
102
+ }
103
+
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Lists files in a repository directory using the GitHub Trees API.
109
+ * Returns file paths matching the given extensions.
110
+ *
111
+ * @param {string} repoUrl
112
+ * @param {string[]} extensions - e.g. [".tsx", ".jsx", ".html"]
113
+ * @param {string|undefined} token
114
+ * @returns {Promise<string[]>}
115
+ */
116
+ export async function listRepoFiles(repoUrl, extensions, token) {
117
+ const parsed = parseRepoUrl(repoUrl);
118
+ if (!parsed) return [];
119
+
120
+ const { owner, repo, branch } = parsed;
121
+
122
+ try {
123
+ const url = `${GITHUB_API}/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`;
124
+ const res = await fetch(url, { headers: authHeaders(token) });
125
+ if (!res.ok) return [];
126
+
127
+ const { tree } = await res.json();
128
+ if (!Array.isArray(tree)) return [];
129
+
130
+ const extSet = new Set(extensions.map((e) => e.toLowerCase()));
131
+ const skipDirs = new Set([
132
+ "node_modules", ".git", "dist", "build", ".next", ".nuxt",
133
+ "coverage", ".cache", "out", ".turbo", ".vercel", ".netlify",
134
+ "public", "static", "wp-includes", "wp-admin",
135
+ ]);
136
+
137
+ return tree
138
+ .filter((item) => {
139
+ if (item.type !== "blob") return false;
140
+ const path = item.path;
141
+ const parts = path.split("/");
142
+ if (parts.some((p) => skipDirs.has(p) || p.startsWith("."))) return false;
143
+ const ext = path.slice(path.lastIndexOf(".")).toLowerCase();
144
+ return extSet.has(ext);
145
+ })
146
+ .map((item) => item.path);
147
+ } catch {
148
+ return [];
149
+ }
150
+ }
@@ -744,6 +744,14 @@ function computeTestingMethodology(payload) {
744
744
  const routes = payload.routes || [];
745
745
  const scanned = routes.filter((r) => !r.error).length;
746
746
  const errored = routes.filter((r) => r.error).length;
747
+ const tags = payload.axeTags || [];
748
+ const conformanceLevel = tags.includes("wcag2aaa")
749
+ ? "AAA"
750
+ : tags.includes("wcag2aa") || tags.includes("wcag21aa") || tags.includes("wcag22aa")
751
+ ? "AA"
752
+ : tags.includes("wcag2a") || tags.includes("wcag21a") || tags.includes("wcag22a")
753
+ ? "A"
754
+ : null;
747
755
  return {
748
756
  automated_tools: [
749
757
  "axe-core (via @axe-core/playwright)",
@@ -751,7 +759,10 @@ function computeTestingMethodology(payload) {
751
759
  "pa11y (HTML CodeSniffer via Puppeteer)",
752
760
  "Playwright + Chromium",
753
761
  ],
754
- compliance_target: "WCAG 2.2 AA",
762
+ compliance_target: "WCAG 2.2",
763
+ conformance_level: conformanceLevel,
764
+ best_practices: tags.includes("best-practice"),
765
+ axe_tags: tags.length > 0 ? tags : null,
755
766
  pages_scanned: scanned,
756
767
  pages_errored: errored,
757
768
  framework_detected: payload.projectContext?.framework || "Not detected",
package/src/index.d.mts CHANGED
@@ -394,12 +394,22 @@ export interface RunAuditOptions {
394
394
  ignoreFindings?: string[];
395
395
  framework?: string;
396
396
  projectDir?: string;
397
+ repoUrl?: string;
398
+ githubToken?: string;
397
399
  skipPatterns?: boolean;
398
400
  screenshotsDir?: string;
399
401
  engines?: EngineSelection;
402
+ ai?: AiOptions;
400
403
  onProgress?: (step: string, status: string, extra?: Record<string, unknown>) => void;
401
404
  }
402
405
 
406
+ export interface AiOptions {
407
+ enabled?: boolean;
408
+ apiKey?: string;
409
+ githubToken?: string;
410
+ model?: string;
411
+ }
412
+
403
413
 
404
414
 
405
415
 
package/src/index.mjs CHANGED
@@ -188,6 +188,11 @@ export function getFindings(input, options = {}) {
188
188
  const { screenshotUrlBuilder = null } = options;
189
189
  const rules = getIntelligence().rules || {};
190
190
 
191
+ // If AI enrichment ran, return those findings directly (already normalized + enriched)
192
+ if (input?.ai_enriched_findings?.length > 0 && !screenshotUrlBuilder) {
193
+ return input.ai_enriched_findings;
194
+ }
195
+
191
196
  const rawFindings = input?.findings || [];
192
197
 
193
198
  // Normalize raw findings
@@ -652,6 +657,23 @@ export async function runAudit(options) {
652
657
  pa11y: options.engines?.pa11y !== false,
653
658
  };
654
659
 
660
+ // Fetch remote package.json via GitHub API if repoUrl is provided
661
+ let remotePackageJson = null;
662
+ if (options.repoUrl && !options.projectDir) {
663
+ if (onProgress) onProgress("repo", "running");
664
+ try {
665
+ const { fetchPackageJson } = await import("./core/github-api.mjs");
666
+ remotePackageJson = await fetchPackageJson(options.repoUrl, options.githubToken);
667
+ if (remotePackageJson) {
668
+ if (onProgress) onProgress("repo", "done", { packageJson: true });
669
+ } else {
670
+ if (onProgress) onProgress("repo", "skipped", { reason: "Could not read package.json" });
671
+ }
672
+ } catch (err) {
673
+ if (onProgress) onProgress("repo", "skipped", { reason: err.message });
674
+ }
675
+ }
676
+
655
677
  // Step 1: DOM scan (selected engines)
656
678
  if (onProgress) onProgress("page", "running");
657
679
 
@@ -672,6 +694,7 @@ export async function runAudit(options) {
672
694
  excludeSelectors: options.excludeSelectors,
673
695
  screenshotsDir: options.screenshotsDir,
674
696
  projectDir: options.projectDir,
697
+ remotePackageJson,
675
698
  engines,
676
699
  },
677
700
  { onProgress },
@@ -685,10 +708,11 @@ export async function runAudit(options) {
685
708
  framework: options.framework,
686
709
  });
687
710
 
688
- // Step 3: Source patterns (optional)
689
- if (options.projectDir && !options.skipPatterns) {
711
+ // Step 3: Source patterns (optional) — works with local projectDir or remote repoUrl
712
+ const hasSourceContext = (options.projectDir || options.repoUrl) && !options.skipPatterns;
713
+ if (hasSourceContext) {
714
+ if (onProgress) onProgress("patterns", "running");
690
715
  try {
691
- const { resolveScanDirs, scanPattern } = await import("./source-patterns/source-scanner.mjs");
692
716
  const { patterns } = loadAssetJson(ASSET_PATHS.remediation.codePatterns, "code-patterns.json");
693
717
 
694
718
  let resolvedFramework = options.framework;
@@ -696,35 +720,97 @@ export async function runAudit(options) {
696
720
  resolvedFramework = findingsPayload.metadata.projectContext.framework;
697
721
  }
698
722
 
699
- const scanDirs = resolveScanDirs(resolvedFramework || null, options.projectDir);
700
- const allPatternFindings = [];
701
- for (const pattern of patterns) {
702
- for (const scanDir of scanDirs) {
703
- allPatternFindings.push(...scanPattern(pattern, scanDir, options.projectDir));
723
+ let allPatternFindings = [];
724
+
725
+ if (options.projectDir) {
726
+ // Local filesystem scan
727
+ const { resolveScanDirs, scanPattern } = await import("./source-patterns/source-scanner.mjs");
728
+ const scanDirs = resolveScanDirs(resolvedFramework || null, options.projectDir);
729
+ for (const pattern of patterns) {
730
+ for (const scanDir of scanDirs) {
731
+ allPatternFindings.push(...scanPattern(pattern, scanDir, options.projectDir));
732
+ }
733
+ }
734
+ } else if (options.repoUrl) {
735
+ // Remote GitHub API scan
736
+ const { scanPatternRemote } = await import("./source-patterns/source-scanner.mjs");
737
+ for (const pattern of patterns) {
738
+ const remoteFindings = await scanPatternRemote(
739
+ pattern,
740
+ options.repoUrl,
741
+ options.githubToken,
742
+ resolvedFramework || null,
743
+ );
744
+ allPatternFindings.push(...remoteFindings);
704
745
  }
705
746
  }
706
747
 
748
+ const confirmed = allPatternFindings.filter((f) => f.status === "confirmed").length;
749
+ const potential = allPatternFindings.filter((f) => f.status === "potential").length;
750
+
707
751
  if (allPatternFindings.length > 0) {
708
752
  findingsPayload.patternFindings = {
709
753
  generated_at: new Date().toISOString(),
710
- project_dir: options.projectDir,
754
+ project_dir: options.projectDir || options.repoUrl,
711
755
  findings: allPatternFindings,
712
- summary: {
713
- total: allPatternFindings.length,
714
- confirmed: allPatternFindings.filter((f) => f.status === "confirmed").length,
715
- potential: allPatternFindings.filter((f) => f.status === "potential").length,
716
- },
756
+ summary: { total: allPatternFindings.length, confirmed, potential },
717
757
  };
718
758
  }
759
+
760
+ if (onProgress) onProgress("patterns", "done", {
761
+ total: allPatternFindings.length,
762
+ confirmed,
763
+ potential,
764
+ });
719
765
  } catch (err) {
720
- // Non-fatal: source scanning is optional
721
766
  const msg = err instanceof Error ? err.message : String(err);
767
+ if (onProgress) onProgress("patterns", "skipped", { reason: msg });
722
768
  console.warn(`Source pattern scan failed (non-fatal): ${msg}`);
723
769
  }
724
770
  }
725
771
 
726
772
  if (onProgress) onProgress("intelligence", "done");
727
773
 
774
+ // Step 4: AI enrichment (optional) — requires ANTHROPIC_API_KEY
775
+ const aiOptions = options.ai || {};
776
+ const aiEnabled = aiOptions.enabled !== false && !!aiOptions.apiKey;
777
+ if (!aiEnabled && onProgress && options.ai !== undefined) {
778
+ onProgress("ai", "skipped", { reason: "No API key configured" });
779
+ }
780
+ if (aiEnabled) {
781
+ try {
782
+ if (onProgress) onProgress("ai", "running");
783
+ const { enrichWithAI } = await import("./ai/claude.mjs");
784
+
785
+ const projectContext = findingsPayload.metadata?.projectContext || {};
786
+ const rawFindings = getFindings(findingsPayload);
787
+
788
+ const enrichedFindings = await enrichWithAI(
789
+ rawFindings,
790
+ {
791
+ stack: {
792
+ framework: projectContext.framework || null,
793
+ cms: projectContext.cms || null,
794
+ uiLibraries: projectContext.uiLibraries || [],
795
+ },
796
+ repoUrl: options.repoUrl,
797
+ },
798
+ {
799
+ enabled: true,
800
+ apiKey: aiOptions.apiKey,
801
+ githubToken: aiOptions.githubToken || options.githubToken,
802
+ model: aiOptions.model,
803
+ }
804
+ );
805
+
806
+ // Store enriched findings back into the payload
807
+ findingsPayload.ai_enriched_findings = enrichedFindings;
808
+ if (onProgress) onProgress("ai", "done");
809
+ } catch (err) {
810
+ console.warn(`[a11y-engine] AI step failed (non-fatal): ${err.message}`);
811
+ }
812
+ }
813
+
728
814
  // Attach active engines to metadata so consumers know which ran
729
815
  findingsPayload.metadata = findingsPayload.metadata || {};
730
816
  findingsPayload.metadata.engines = engines;
@@ -1029,7 +1115,7 @@ export async function getHTMLReport(payload, options = {}) {
1029
1115
  </div>
1030
1116
  <div class="bg-white rounded-xl p-4 border border-slate-200 shadow-sm">
1031
1117
  <div class="text-3xl font-black ${wcagStatus === 'Pass' ? 'text-emerald-600' : 'text-rose-600'}">${wcagStatus}</div>
1032
- <div class="text-xs font-bold text-slate-500 uppercase">WCAG 2.2 AA</div>
1118
+ <div class="text-xs font-bold text-slate-500 uppercase">WCAG 2.2</div>
1033
1119
  </div>
1034
1120
  <div class="bg-white rounded-xl p-4 border border-slate-200 shadow-sm">
1035
1121
  <div class="text-3xl font-black">${Object.keys(pageGroups).length}</div>
@@ -341,16 +341,62 @@ export async function discoverRoutes(page, baseUrl, maxRoutes, crawlDepth = 2) {
341
341
  return [...routes].slice(0, maxRoutes);
342
342
  }
343
343
 
344
+ /**
345
+ * Extracts framework and UI library info from a parsed package.json object.
346
+ * Used both for local file reads and remote GitHub API reads.
347
+ * @param {Record<string, unknown>} pkg
348
+ * @returns {{ framework: string|null, uiLibraries: string[] }}
349
+ */
350
+ function detectFromPackageJson(pkg) {
351
+ const uiLibraries = [];
352
+ let pkgFramework = null;
353
+
354
+ const allDeps = Object.keys({
355
+ ...(pkg.dependencies || {}),
356
+ ...(pkg.devDependencies || {}),
357
+ });
358
+
359
+ for (const [dep, fw] of STACK_DETECTION.frameworkPackageDetectors) {
360
+ if (allDeps.some((d) => d === dep || d.startsWith(`${dep}/`))) {
361
+ pkgFramework = fw;
362
+ break;
363
+ }
364
+ }
365
+ for (const [prefix, name] of STACK_DETECTION.uiLibraryPackageDetectors) {
366
+ if (allDeps.some((d) => d === prefix || d.startsWith(`${prefix}/`))) {
367
+ uiLibraries.push(name);
368
+ }
369
+ }
370
+
371
+ return { framework: pkgFramework, uiLibraries };
372
+ }
373
+
344
374
  /**
345
375
  * Detects the web framework and UI libraries used by analyzing package.json and file structure.
376
+ * Accepts either a local project directory path or a pre-parsed package.json object
377
+ * (useful when the package.json was fetched remotely via GitHub API).
378
+ *
346
379
  * @param {string|null} [explicitProjectDir=null] - Explicit project directory. Falls back to env/cwd.
380
+ * @param {Record<string, unknown>|null} [remotePackageJson=null] - Pre-parsed package.json from GitHub API.
347
381
  * @returns {Object} An object containing detected framework and UI libraries.
348
382
  */
349
- function detectProjectContext(explicitProjectDir = null) {
383
+ function detectProjectContext(explicitProjectDir = null, remotePackageJson = null) {
350
384
  const uiLibraries = [];
351
385
  let pkgFramework = null;
352
386
  let fileFramework = null;
353
387
 
388
+ // If a remote package.json was provided (from GitHub API), use it directly
389
+ if (remotePackageJson) {
390
+ const result = detectFromPackageJson(remotePackageJson);
391
+ if (result.framework) {
392
+ log.info(`Detected framework: ${result.framework} (from remote package.json)`);
393
+ }
394
+ if (result.uiLibraries.length) {
395
+ log.info(`Detected UI libraries: ${result.uiLibraries.join(", ")}`);
396
+ }
397
+ return result;
398
+ }
399
+
354
400
  const projectDir = explicitProjectDir || process.env.A11Y_PROJECT_DIR || null;
355
401
  if (!projectDir) {
356
402
  return { framework: null, uiLibraries: [] };
@@ -360,21 +406,9 @@ function detectProjectContext(explicitProjectDir = null) {
360
406
  const pkgPath = path.join(projectDir, "package.json");
361
407
  if (fs.existsSync(pkgPath)) {
362
408
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
363
- const allDeps = Object.keys({
364
- ...(pkg.dependencies || {}),
365
- ...(pkg.devDependencies || {}),
366
- });
367
- for (const [dep, fw] of STACK_DETECTION.frameworkPackageDetectors) {
368
- if (allDeps.some((d) => d === dep || d.startsWith(`${dep}/`))) {
369
- pkgFramework = fw;
370
- break;
371
- }
372
- }
373
- for (const [prefix, name] of STACK_DETECTION.uiLibraryPackageDetectors) {
374
- if (allDeps.some((d) => d === prefix || d.startsWith(`${prefix}/`))) {
375
- uiLibraries.push(name);
376
- }
377
- }
409
+ const result = detectFromPackageJson(pkg);
410
+ pkgFramework = result.framework;
411
+ uiLibraries.push(...result.uiLibraries);
378
412
  }
379
413
  } catch { /* package.json unreadable */ }
380
414
 
@@ -951,6 +985,7 @@ export async function runDomScanner(options = {}, callbacks = {}) {
951
985
  viewport: options.viewport || null,
952
986
  axeTags: options.axeTags || null,
953
987
  projectDir: options.projectDir || null,
988
+ remotePackageJson: options.remotePackageJson || null,
954
989
  engines: {
955
990
  axe: options.engines?.axe !== false,
956
991
  cdp: options.engines?.cdp !== false,
@@ -1002,8 +1037,8 @@ async function _runDomScannerInternal(args) {
1002
1037
  timeout: args.timeoutMs,
1003
1038
  });
1004
1039
 
1005
- // 1. File-system / package.json detection (works when projectDir is available)
1006
- const repoCtx = detectProjectContext(args.projectDir || null);
1040
+ // 1. File-system / package.json detection (local projectDir) or remote package.json
1041
+ const repoCtx = detectProjectContext(args.projectDir || null, args.remotePackageJson || null);
1007
1042
 
1008
1043
  // 2. DOM/runtime detection (always works for any remote URL)
1009
1044
  let domCtx = { framework: null, cms: null, uiLibraries: [] };
@@ -1253,6 +1288,7 @@ async function _runDomScannerInternal(args) {
1253
1288
  base_url: baseUrl,
1254
1289
  onlyRule: args.onlyRule || null,
1255
1290
  engines: args.engines,
1291
+ axeTags: args.axeTags || null,
1256
1292
  projectContext,
1257
1293
  routes: results,
1258
1294
  };
@@ -152,7 +152,7 @@ function buildHtml(args) {
152
152
  </div>
153
153
 
154
154
  <footer class="mt-12 py-6 border-t border-slate-200 text-center">
155
- <p class="text-slate-600 text-sm font-medium">Generated by <a href="https://github.com/diegovelasquezweb/a11y" target="_blank" class="text-slate-700 hover:text-amber-700 font-semibold transition-colors">a11y</a> &bull; <span class="text-slate-700">WCAG 2.2 AA</span></p>
155
+ <p class="text-slate-600 text-sm font-medium">Generated by <a href="https://github.com/diegovelasquezweb/a11y" target="_blank" class="text-slate-700 hover:text-amber-700 font-semibold transition-colors">a11y</a> &bull; <span class="text-slate-700">WCAG 2.2</span></p>
156
156
  </footer>
157
157
 
158
158
  </main>
@@ -564,7 +564,7 @@ ${rows.join("\n")}
564
564
  const sourceBoundariesSection = buildSourceBoundariesSection(framework);
565
565
 
566
566
  return (
567
- `# Accessibility Remediation Guide — WCAG 2.2 AA
567
+ `# Accessibility Remediation Guide — WCAG 2.2
568
568
  > **Base URL:** ${args.baseUrl || "N/A"}
569
569
 
570
570
  | Severity | Count |
@@ -230,6 +230,93 @@ export function scanPattern(pattern, scanDir, projectDir = scanDir) {
230
230
  return findings;
231
231
  }
232
232
 
233
+ /**
234
+ * Scans files in a remote GitHub repository for a pattern.
235
+ * Functionally equivalent to scanPattern but reads files via GitHub API.
236
+ *
237
+ * @param {Object} pattern
238
+ * @param {string} repoUrl
239
+ * @param {string|undefined} githubToken
240
+ * @param {string|null} framework
241
+ * @returns {Promise<Object[]>}
242
+ */
243
+ export async function scanPatternRemote(pattern, repoUrl, githubToken, framework = null) {
244
+ const { fetchRepoFile, listRepoFiles, parseRepoUrl } = await import("../core/github-api.mjs");
245
+ const parsed = parseRepoUrl(repoUrl);
246
+ if (!parsed) return [];
247
+
248
+ const extensions = [...parseExtensions(pattern.globs)];
249
+ if (extensions.length === 0) return [];
250
+
251
+ const allFiles = await listRepoFiles(repoUrl, extensions, githubToken);
252
+
253
+ // Scope to framework boundaries if known
254
+ const boundaries = framework ? SOURCE_BOUNDARIES?.[framework] : null;
255
+ let scopedFiles = allFiles;
256
+ if (boundaries) {
257
+ const allGlobs = [boundaries.components, boundaries.styles]
258
+ .filter(Boolean)
259
+ .flatMap((g) => g.split(",").map((s) => s.trim()));
260
+
261
+ const prefixes = new Set();
262
+ for (const glob of allGlobs) {
263
+ const prefix = glob.split(/[*?{]/)[0].replace(/\/$/, "");
264
+ if (prefix) prefixes.add(prefix);
265
+ }
266
+
267
+ if (prefixes.size > 0) {
268
+ scopedFiles = allFiles.filter((f) =>
269
+ [...prefixes].some((p) => f.startsWith(p))
270
+ );
271
+ if (scopedFiles.length === 0) scopedFiles = allFiles; // fallback
272
+ }
273
+ }
274
+
275
+ const regex = new RegExp(pattern.regex, "gi");
276
+ const findings = [];
277
+
278
+ for (const filePath of scopedFiles) {
279
+ const content = await fetchRepoFile(repoUrl, filePath, githubToken);
280
+ if (!content) continue;
281
+
282
+ const lines = content.split("\n");
283
+
284
+ for (let i = 0; i < lines.length; i++) {
285
+ regex.lastIndex = 0;
286
+ if (!regex.test(lines[i])) continue;
287
+
288
+ const contextStart = Math.max(0, i - 3);
289
+ const contextEnd = Math.min(lines.length - 1, i + 3);
290
+ const context = lines
291
+ .slice(contextStart, contextEnd + 1)
292
+ .map((l, idx) => `${contextStart + idx + 1} ${l}`)
293
+ .join("\n");
294
+
295
+ const confirmed = isConfirmedByContext(pattern, lines, i);
296
+
297
+ findings.push({
298
+ id: makeFindingId(pattern.id, filePath, i + 1),
299
+ pattern_id: pattern.id,
300
+ title: pattern.title,
301
+ severity: pattern.severity,
302
+ wcag: pattern.wcag,
303
+ wcag_criterion: pattern.wcag_criterion,
304
+ wcag_level: pattern.wcag_level,
305
+ type: pattern.type,
306
+ fix_description: pattern.fix_description ?? null,
307
+ status: confirmed ? "confirmed" : "potential",
308
+ file: filePath,
309
+ line: i + 1,
310
+ match: lines[i].trim(),
311
+ context,
312
+ source: "code-pattern",
313
+ });
314
+ }
315
+ }
316
+
317
+ return findings;
318
+ }
319
+
233
320
  /**
234
321
  * Main execution function for the pattern scanner.
235
322
  */