@diegovelasquezweb/a11y-engine 0.6.4 → 0.6.6
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/assets/knowledge/knowledge.mjs +9 -9
- package/package.json +2 -2
- package/src/ai/claude.mjs +249 -0
- package/src/core/github-api.mjs +150 -0
- package/src/enrichment/analyzer.mjs +1 -1
- package/src/index.d.mts +68 -30
- package/src/index.mjs +84 -10
- package/src/pipeline/dom-scanner.mjs +53 -18
- package/src/reports/checklist.mjs +1 -1
- package/src/reports/renderers/md.mjs +1 -1
- package/src/source-patterns/source-scanner.mjs +87 -0
|
@@ -107,7 +107,7 @@ export default {
|
|
|
107
107
|
{
|
|
108
108
|
id: "A",
|
|
109
109
|
label: "Level A",
|
|
110
|
-
|
|
110
|
+
tag: "Minimum",
|
|
111
111
|
description: "The baseline: essential requirements that remove the most severe barriers.",
|
|
112
112
|
shortDescription: "Minimum baseline",
|
|
113
113
|
hint: "Failing Level A means some users cannot access the content at all.",
|
|
@@ -116,7 +116,7 @@ export default {
|
|
|
116
116
|
{
|
|
117
117
|
id: "AA",
|
|
118
118
|
label: "Level AA",
|
|
119
|
-
|
|
119
|
+
tag: "Standard",
|
|
120
120
|
description: "The recommended target for most websites — required by most accessibility laws.",
|
|
121
121
|
shortDescription: "Recommended for most websites",
|
|
122
122
|
hint: "Referenced by ADA, Section 508, EN 301 549, and EAA.",
|
|
@@ -125,7 +125,7 @@ export default {
|
|
|
125
125
|
{
|
|
126
126
|
id: "AAA",
|
|
127
127
|
label: "Level AAA",
|
|
128
|
-
|
|
128
|
+
tag: "Enhanced",
|
|
129
129
|
description: "The highest conformance level — not required but beneficial for specialized audiences.",
|
|
130
130
|
shortDescription: "Strictest — not required by most regulations",
|
|
131
131
|
hint: "Full AAA conformance is not recommended as a general policy for entire sites.",
|
|
@@ -251,21 +251,21 @@ export default {
|
|
|
251
251
|
{
|
|
252
252
|
id: "wcag-2-0",
|
|
253
253
|
title: "WCAG 2.0",
|
|
254
|
-
|
|
254
|
+
tag: "2008",
|
|
255
255
|
summary: "The original W3C recommendation that established the foundation for web accessibility.",
|
|
256
256
|
body: "Introduced the four principles (Perceivable, Operable, Understandable, Robust) and three conformance levels (A, AA, AAA). Covers core requirements like text alternatives, keyboard access, color contrast, and form labels. Still widely referenced in legal frameworks worldwide.",
|
|
257
257
|
},
|
|
258
258
|
{
|
|
259
259
|
id: "wcag-2-1",
|
|
260
260
|
title: "WCAG 2.1",
|
|
261
|
-
|
|
261
|
+
tag: "2018",
|
|
262
262
|
summary: "Extended 2.0 with 17 new success criteria for mobile, low vision, and cognitive disabilities.",
|
|
263
263
|
body: "Added criteria for touch targets (2.5.5), text spacing (1.4.12), content reflow (1.4.10), orientation (1.3.4), and input purpose (1.3.5). Required by the European Accessibility Act (EAA) and referenced in updated ADA guidance. All 2.0 criteria remain \u2014 2.1 is a superset.",
|
|
264
264
|
},
|
|
265
265
|
{
|
|
266
266
|
id: "wcag-2-2",
|
|
267
267
|
title: "WCAG 2.2",
|
|
268
|
-
|
|
268
|
+
tag: "2023",
|
|
269
269
|
summary: "The latest version, adding 9 new criteria focused on cognitive accessibility and consistent help.",
|
|
270
270
|
body: "Key additions include consistent help (3.2.6), accessible authentication (3.3.8), dragging movements (2.5.7), and focus appearance (2.4.11/2.4.12). Removed criterion 4.1.1 (Parsing) as it\u2019s now handled by modern browsers. Supersedes both 2.0 and 2.1 \u2014 all prior criteria are included.",
|
|
271
271
|
},
|
|
@@ -278,21 +278,21 @@ export default {
|
|
|
278
278
|
{
|
|
279
279
|
id: "level-a",
|
|
280
280
|
title: "Level A",
|
|
281
|
-
|
|
281
|
+
tag: "Minimum",
|
|
282
282
|
summary: "The baseline: essential requirements that remove the most severe barriers.",
|
|
283
283
|
body: "Covers fundamentals like non-text content alternatives (1.1.1), keyboard operability (2.1.1), page titles (2.4.2), and language of the page (3.1.1). Failing Level A means some users cannot access the content at all. Every site should meet Level A at minimum.",
|
|
284
284
|
},
|
|
285
285
|
{
|
|
286
286
|
id: "level-aa",
|
|
287
287
|
title: "Level AA",
|
|
288
|
-
|
|
288
|
+
tag: "Standard",
|
|
289
289
|
summary: "The recommended target for most websites \u2014 required by most accessibility laws.",
|
|
290
290
|
body: "Includes all Level A criteria plus requirements for color contrast (1.4.3 \u2014 4.5:1 ratio), resize text (1.4.4), focus visible (2.4.7), error suggestion (3.3.3), and consistent navigation (3.2.3). Referenced by ADA, Section 508, EN 301 549, and EAA. This is the standard the scanner defaults to.",
|
|
291
291
|
},
|
|
292
292
|
{
|
|
293
293
|
id: "level-aaa",
|
|
294
294
|
title: "Level AAA",
|
|
295
|
-
|
|
295
|
+
tag: "Enhanced",
|
|
296
296
|
summary: "The highest conformance level \u2014 not required but beneficial for specialized audiences.",
|
|
297
297
|
body: "Adds stricter contrast (1.4.6 \u2014 7:1 ratio), sign language for audio (1.2.6), extended audio description (1.2.7), and reading level (3.1.5). Full AAA conformance is not recommended as a general policy because some criteria cannot be satisfied for all content types. Useful for targeted sections like education or government services.",
|
|
298
298
|
},
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diegovelasquezweb/a11y-engine",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "WCAG 2.2
|
|
3
|
+
"version": "0.6.6",
|
|
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
|
+
}
|
|
@@ -751,7 +751,7 @@ function computeTestingMethodology(payload) {
|
|
|
751
751
|
"pa11y (HTML CodeSniffer via Puppeteer)",
|
|
752
752
|
"Playwright + Chromium",
|
|
753
753
|
],
|
|
754
|
-
compliance_target: "WCAG 2.2
|
|
754
|
+
compliance_target: "WCAG 2.2",
|
|
755
755
|
pages_scanned: scanned,
|
|
756
756
|
pages_errored: errored,
|
|
757
757
|
framework_detected: payload.projectContext?.framework || "Not detected",
|
package/src/index.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
|
|
4
4
|
|
|
5
5
|
export interface Finding {
|
|
6
6
|
id: string;
|
|
@@ -135,9 +135,9 @@ export interface AuditSummary {
|
|
|
135
135
|
totalFindings: number;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
141
141
|
|
|
142
142
|
export interface ScanPayload {
|
|
143
143
|
findings: Finding[] | Record<string, unknown>[];
|
|
@@ -209,9 +209,9 @@ export interface SourcePatternOptions {
|
|
|
209
209
|
onlyPattern?: string;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
|
|
215
215
|
|
|
216
216
|
export interface ScannerEngineHelp {
|
|
217
217
|
id: "axe" | "cdp" | "pa11y" | string;
|
|
@@ -222,25 +222,53 @@ export interface ScannerEngineHelp {
|
|
|
222
222
|
defaultEnabled: boolean;
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
export interface EnumOptionValue {
|
|
226
|
-
value: string;
|
|
227
|
-
label: string;
|
|
228
|
-
description?: string;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
225
|
export interface ScannerOptionHelp {
|
|
232
226
|
id: string;
|
|
233
227
|
label: string;
|
|
234
228
|
description: string;
|
|
235
229
|
defaultValue: unknown;
|
|
236
230
|
type: string;
|
|
237
|
-
allowedValues?: unknown[]
|
|
231
|
+
allowedValues?: unknown[];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface GlossaryEntry {
|
|
235
|
+
term: string;
|
|
236
|
+
definition: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export interface ConceptEntry {
|
|
240
|
+
title: string;
|
|
241
|
+
body: string;
|
|
242
|
+
context?: string;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface PersonaReferenceItem {
|
|
246
|
+
id: string;
|
|
247
|
+
icon: string;
|
|
248
|
+
label: string;
|
|
249
|
+
description: string;
|
|
250
|
+
keywords: string[];
|
|
251
|
+
mappedRules: string[];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface PersonaReference {
|
|
255
|
+
locale: string;
|
|
256
|
+
version: string;
|
|
257
|
+
personas: PersonaReferenceItem[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface ScannerHelp {
|
|
261
|
+
locale: string;
|
|
262
|
+
version: string;
|
|
263
|
+
title: string;
|
|
264
|
+
engines: ScannerEngineHelp[];
|
|
265
|
+
options: ScannerOptionHelp[];
|
|
238
266
|
}
|
|
239
267
|
|
|
240
268
|
export interface ConformanceLevel {
|
|
241
269
|
id: "A" | "AA" | "AAA";
|
|
242
270
|
label: string;
|
|
243
|
-
|
|
271
|
+
tag: string;
|
|
244
272
|
description: string;
|
|
245
273
|
shortDescription: string;
|
|
246
274
|
hint: string;
|
|
@@ -267,7 +295,7 @@ export interface DocArticle {
|
|
|
267
295
|
id: string;
|
|
268
296
|
title: string;
|
|
269
297
|
icon?: string;
|
|
270
|
-
|
|
298
|
+
tag?: string;
|
|
271
299
|
summary: string;
|
|
272
300
|
body: string;
|
|
273
301
|
}
|
|
@@ -335,9 +363,9 @@ export interface KnowledgeOptions {
|
|
|
335
363
|
locale?: string;
|
|
336
364
|
}
|
|
337
365
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
|
|
341
369
|
|
|
342
370
|
export interface EngineSelection {
|
|
343
371
|
axe?: boolean;
|
|
@@ -345,9 +373,9 @@ export interface EngineSelection {
|
|
|
345
373
|
pa11y?: boolean;
|
|
346
374
|
}
|
|
347
375
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
|
|
351
379
|
|
|
352
380
|
export interface RunAuditOptions {
|
|
353
381
|
baseUrl: string;
|
|
@@ -366,23 +394,33 @@ export interface RunAuditOptions {
|
|
|
366
394
|
ignoreFindings?: string[];
|
|
367
395
|
framework?: string;
|
|
368
396
|
projectDir?: string;
|
|
397
|
+
repoUrl?: string;
|
|
398
|
+
githubToken?: string;
|
|
369
399
|
skipPatterns?: boolean;
|
|
370
400
|
screenshotsDir?: string;
|
|
371
401
|
engines?: EngineSelection;
|
|
402
|
+
ai?: AiOptions;
|
|
372
403
|
onProgress?: (step: string, status: string, extra?: Record<string, unknown>) => void;
|
|
373
404
|
}
|
|
374
405
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
406
|
+
export interface AiOptions {
|
|
407
|
+
enabled?: boolean;
|
|
408
|
+
apiKey?: string;
|
|
409
|
+
githubToken?: string;
|
|
410
|
+
model?: string;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
|
|
378
416
|
|
|
379
417
|
export interface EnrichmentOptions {
|
|
380
418
|
screenshotUrlBuilder?: (rawPath: string) => string;
|
|
381
419
|
}
|
|
382
420
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
|
|
386
424
|
|
|
387
425
|
export function runAudit(options: RunAuditOptions): Promise<ScanPayload>;
|
|
388
426
|
|
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,20 @@ 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
|
+
try {
|
|
664
|
+
const { fetchPackageJson } = await import("./core/github-api.mjs");
|
|
665
|
+
remotePackageJson = await fetchPackageJson(options.repoUrl, options.githubToken);
|
|
666
|
+
if (remotePackageJson) {
|
|
667
|
+
console.info("[a11y-engine] Fetched package.json from GitHub repo");
|
|
668
|
+
}
|
|
669
|
+
} catch (err) {
|
|
670
|
+
console.warn(`[a11y-engine] Could not fetch package.json from repo (non-fatal): ${err.message}`);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
655
674
|
// Step 1: DOM scan (selected engines)
|
|
656
675
|
if (onProgress) onProgress("page", "running");
|
|
657
676
|
|
|
@@ -672,6 +691,7 @@ export async function runAudit(options) {
|
|
|
672
691
|
excludeSelectors: options.excludeSelectors,
|
|
673
692
|
screenshotsDir: options.screenshotsDir,
|
|
674
693
|
projectDir: options.projectDir,
|
|
694
|
+
remotePackageJson,
|
|
675
695
|
engines,
|
|
676
696
|
},
|
|
677
697
|
{ onProgress },
|
|
@@ -685,10 +705,10 @@ export async function runAudit(options) {
|
|
|
685
705
|
framework: options.framework,
|
|
686
706
|
});
|
|
687
707
|
|
|
688
|
-
// Step 3: Source patterns (optional)
|
|
689
|
-
|
|
708
|
+
// Step 3: Source patterns (optional) — works with local projectDir or remote repoUrl
|
|
709
|
+
const hasSourceContext = (options.projectDir || options.repoUrl) && !options.skipPatterns;
|
|
710
|
+
if (hasSourceContext) {
|
|
690
711
|
try {
|
|
691
|
-
const { resolveScanDirs, scanPattern } = await import("./source-patterns/source-scanner.mjs");
|
|
692
712
|
const { patterns } = loadAssetJson(ASSET_PATHS.remediation.codePatterns, "code-patterns.json");
|
|
693
713
|
|
|
694
714
|
let resolvedFramework = options.framework;
|
|
@@ -696,18 +716,35 @@ export async function runAudit(options) {
|
|
|
696
716
|
resolvedFramework = findingsPayload.metadata.projectContext.framework;
|
|
697
717
|
}
|
|
698
718
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
719
|
+
let allPatternFindings = [];
|
|
720
|
+
|
|
721
|
+
if (options.projectDir) {
|
|
722
|
+
// Local filesystem scan
|
|
723
|
+
const { resolveScanDirs, scanPattern } = await import("./source-patterns/source-scanner.mjs");
|
|
724
|
+
const scanDirs = resolveScanDirs(resolvedFramework || null, options.projectDir);
|
|
725
|
+
for (const pattern of patterns) {
|
|
726
|
+
for (const scanDir of scanDirs) {
|
|
727
|
+
allPatternFindings.push(...scanPattern(pattern, scanDir, options.projectDir));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
} else if (options.repoUrl) {
|
|
731
|
+
// Remote GitHub API scan
|
|
732
|
+
const { scanPatternRemote } = await import("./source-patterns/source-scanner.mjs");
|
|
733
|
+
for (const pattern of patterns) {
|
|
734
|
+
const remoteFindings = await scanPatternRemote(
|
|
735
|
+
pattern,
|
|
736
|
+
options.repoUrl,
|
|
737
|
+
options.githubToken,
|
|
738
|
+
resolvedFramework || null,
|
|
739
|
+
);
|
|
740
|
+
allPatternFindings.push(...remoteFindings);
|
|
704
741
|
}
|
|
705
742
|
}
|
|
706
743
|
|
|
707
744
|
if (allPatternFindings.length > 0) {
|
|
708
745
|
findingsPayload.patternFindings = {
|
|
709
746
|
generated_at: new Date().toISOString(),
|
|
710
|
-
project_dir: options.projectDir,
|
|
747
|
+
project_dir: options.projectDir || options.repoUrl,
|
|
711
748
|
findings: allPatternFindings,
|
|
712
749
|
summary: {
|
|
713
750
|
total: allPatternFindings.length,
|
|
@@ -725,6 +762,43 @@ export async function runAudit(options) {
|
|
|
725
762
|
|
|
726
763
|
if (onProgress) onProgress("intelligence", "done");
|
|
727
764
|
|
|
765
|
+
// Step 4: AI enrichment (optional) — requires ANTHROPIC_API_KEY
|
|
766
|
+
const aiOptions = options.ai || {};
|
|
767
|
+
const aiEnabled = aiOptions.enabled !== false && !!aiOptions.apiKey;
|
|
768
|
+
if (aiEnabled) {
|
|
769
|
+
try {
|
|
770
|
+
if (onProgress) onProgress("ai", "running");
|
|
771
|
+
const { enrichWithAI } = await import("./ai/claude.mjs");
|
|
772
|
+
|
|
773
|
+
const projectContext = findingsPayload.metadata?.projectContext || {};
|
|
774
|
+
const rawFindings = getFindings(findingsPayload);
|
|
775
|
+
|
|
776
|
+
const enrichedFindings = await enrichWithAI(
|
|
777
|
+
rawFindings,
|
|
778
|
+
{
|
|
779
|
+
stack: {
|
|
780
|
+
framework: projectContext.framework || null,
|
|
781
|
+
cms: projectContext.cms || null,
|
|
782
|
+
uiLibraries: projectContext.uiLibraries || [],
|
|
783
|
+
},
|
|
784
|
+
repoUrl: options.repoUrl,
|
|
785
|
+
},
|
|
786
|
+
{
|
|
787
|
+
enabled: true,
|
|
788
|
+
apiKey: aiOptions.apiKey,
|
|
789
|
+
githubToken: aiOptions.githubToken || options.githubToken,
|
|
790
|
+
model: aiOptions.model,
|
|
791
|
+
}
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
// Store enriched findings back into the payload
|
|
795
|
+
findingsPayload.ai_enriched_findings = enrichedFindings;
|
|
796
|
+
if (onProgress) onProgress("ai", "done");
|
|
797
|
+
} catch (err) {
|
|
798
|
+
console.warn(`[a11y-engine] AI step failed (non-fatal): ${err.message}`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
728
802
|
// Attach active engines to metadata so consumers know which ran
|
|
729
803
|
findingsPayload.metadata = findingsPayload.metadata || {};
|
|
730
804
|
findingsPayload.metadata.engines = engines;
|
|
@@ -1029,7 +1103,7 @@ export async function getHTMLReport(payload, options = {}) {
|
|
|
1029
1103
|
</div>
|
|
1030
1104
|
<div class="bg-white rounded-xl p-4 border border-slate-200 shadow-sm">
|
|
1031
1105
|
<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
|
|
1106
|
+
<div class="text-xs font-bold text-slate-500 uppercase">WCAG 2.2</div>
|
|
1033
1107
|
</div>
|
|
1034
1108
|
<div class="bg-white rounded-xl p-4 border border-slate-200 shadow-sm">
|
|
1035
1109
|
<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
|
|
364
|
-
|
|
365
|
-
|
|
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 (
|
|
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: [] };
|
|
@@ -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> • <span class="text-slate-700">WCAG 2.2
|
|
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> • <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
|
|
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
|
*/
|