@diegovelasquezweb/a11y-engine 0.1.8 → 0.2.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/CHANGELOG.md +31 -0
- package/README.md +190 -152
- package/docs/architecture.md +22 -0
- package/docs/cli-handbook.md +4 -0
- package/docs/outputs.md +37 -23
- package/package.json +1 -1
- package/scripts/engine/analyzer.mjs +40 -14
- package/scripts/engine/dom-scanner.mjs +56 -3
- package/scripts/engine/source-scanner.mjs +1 -1
- package/scripts/index.d.mts +139 -3
- package/scripts/index.mjs +527 -79
package/scripts/index.mjs
CHANGED
|
@@ -42,31 +42,9 @@ function getWcagReference() {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
// ---------------------------------------------------------------------------
|
|
45
|
-
//
|
|
45
|
+
// Pa11y rule canonicalization (internal)
|
|
46
46
|
// ---------------------------------------------------------------------------
|
|
47
47
|
|
|
48
|
-
/**
|
|
49
|
-
* Returns all engine asset data. Lazy-loaded and cached.
|
|
50
|
-
* @returns {{ intelligence: object, pa11yConfig: object, complianceConfig: object, wcagReference: object }}
|
|
51
|
-
*/
|
|
52
|
-
function getAssets() {
|
|
53
|
-
return {
|
|
54
|
-
intelligence: getIntelligence(),
|
|
55
|
-
pa11yConfig: getPa11yConfig(),
|
|
56
|
-
complianceConfig: getComplianceConfig(),
|
|
57
|
-
wcagReference: getWcagReference(),
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
// Pa11y rule canonicalization
|
|
63
|
-
// ---------------------------------------------------------------------------
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Normalizes a pa11y code token for comparison.
|
|
67
|
-
* @param {string} value
|
|
68
|
-
* @returns {string}
|
|
69
|
-
*/
|
|
70
48
|
function normalizePa11yToken(value) {
|
|
71
49
|
return value
|
|
72
50
|
.toLowerCase()
|
|
@@ -75,13 +53,6 @@ function normalizePa11yToken(value) {
|
|
|
75
53
|
.replace(/[^a-z0-9]/g, "");
|
|
76
54
|
}
|
|
77
55
|
|
|
78
|
-
/**
|
|
79
|
-
* Maps a pa11y rule ID to its canonical axe-equivalent ID.
|
|
80
|
-
* @param {string} ruleId - The current rule ID (may already be canonical or a pa11y slug).
|
|
81
|
-
* @param {string|null} sourceRuleId - The original pa11y code if available.
|
|
82
|
-
* @param {object|null} checkData - The check_data object which may contain a `code` field.
|
|
83
|
-
* @returns {string} The canonical rule ID (e.g., "color-contrast").
|
|
84
|
-
*/
|
|
85
56
|
function mapPa11yRuleToCanonical(ruleId, sourceRuleId = null, checkData = null) {
|
|
86
57
|
const equivalenceMap = getPa11yConfig().equivalenceMap || {};
|
|
87
58
|
|
|
@@ -109,63 +80,196 @@ function mapPa11yRuleToCanonical(ruleId, sourceRuleId = null, checkData = null)
|
|
|
109
80
|
return ruleId;
|
|
110
81
|
}
|
|
111
82
|
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Raw finding normalization (internal)
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
const SEVERITY_ORDER = { Critical: 1, Serious: 2, Moderate: 3, Minor: 4 };
|
|
88
|
+
|
|
89
|
+
function str(v, fallback = "") {
|
|
90
|
+
return typeof v === "string" ? v : (v != null ? String(v) : fallback);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function strOrNull(v) {
|
|
94
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeSingleFinding(item, index, screenshotUrlBuilder) {
|
|
98
|
+
const screenshotRaw = strOrNull(item.screenshot_path);
|
|
99
|
+
const screenshotPath = screenshotRaw && screenshotUrlBuilder
|
|
100
|
+
? screenshotUrlBuilder(screenshotRaw)
|
|
101
|
+
: screenshotRaw;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
id: str(item.id, `A11Y-${String(index + 1).padStart(3, "0")}`),
|
|
105
|
+
rule_id: str(item.rule_id),
|
|
106
|
+
source: str(item.source, "axe"),
|
|
107
|
+
source_rule_id: strOrNull(item.source_rule_id),
|
|
108
|
+
wcag_criterion_id: strOrNull(item.wcag_criterion_id),
|
|
109
|
+
category: strOrNull(item.category),
|
|
110
|
+
title: str(item.title, "Untitled finding"),
|
|
111
|
+
severity: str(item.severity, "Unknown"),
|
|
112
|
+
wcag: str(item.wcag),
|
|
113
|
+
wcag_classification: strOrNull(item.wcag_classification),
|
|
114
|
+
area: str(item.area),
|
|
115
|
+
url: str(item.url),
|
|
116
|
+
selector: str(item.selector),
|
|
117
|
+
primary_selector: str(item.primary_selector || item.selector),
|
|
118
|
+
impacted_users: str(item.impacted_users),
|
|
119
|
+
actual: str(item.actual),
|
|
120
|
+
primary_failure_mode: strOrNull(item.primary_failure_mode),
|
|
121
|
+
relationship_hint: strOrNull(item.relationship_hint),
|
|
122
|
+
failure_checks: Array.isArray(item.failure_checks) ? item.failure_checks : [],
|
|
123
|
+
related_context: Array.isArray(item.related_context) ? item.related_context : [],
|
|
124
|
+
expected: str(item.expected),
|
|
125
|
+
mdn: strOrNull(item.mdn),
|
|
126
|
+
fix_description: strOrNull(item.fix_description),
|
|
127
|
+
fix_code: strOrNull(item.fix_code),
|
|
128
|
+
fix_code_lang: str(item.fix_code_lang, "html"),
|
|
129
|
+
recommended_fix: str(item.recommended_fix),
|
|
130
|
+
evidence: Array.isArray(item.evidence) ? item.evidence : [],
|
|
131
|
+
total_instances: typeof item.total_instances === "number" ? item.total_instances : null,
|
|
132
|
+
effort: strOrNull(item.effort), // null = will be inferred during enrichment
|
|
133
|
+
related_rules: Array.isArray(item.related_rules) ? item.related_rules : [],
|
|
134
|
+
screenshot_path: screenshotPath,
|
|
135
|
+
false_positive_risk: strOrNull(item.false_positive_risk),
|
|
136
|
+
guardrails: item.guardrails && typeof item.guardrails === "object" ? item.guardrails : null,
|
|
137
|
+
fix_difficulty_notes: item.fix_difficulty_notes ?? null,
|
|
138
|
+
framework_notes: strOrNull(item.framework_notes),
|
|
139
|
+
cms_notes: strOrNull(item.cms_notes),
|
|
140
|
+
file_search_pattern: strOrNull(item.file_search_pattern),
|
|
141
|
+
ownership_status: str(item.ownership_status, "unknown"),
|
|
142
|
+
ownership_reason: strOrNull(item.ownership_reason),
|
|
143
|
+
primary_source_scope: Array.isArray(item.primary_source_scope) ? item.primary_source_scope : [],
|
|
144
|
+
search_strategy: str(item.search_strategy, "verify_ownership_before_search"),
|
|
145
|
+
managed_by_library: strOrNull(item.managed_by_library),
|
|
146
|
+
component_hint: strOrNull(item.component_hint),
|
|
147
|
+
verification_command: strOrNull(item.verification_command),
|
|
148
|
+
verification_command_fallback: strOrNull(item.verification_command_fallback),
|
|
149
|
+
check_data: item.check_data && typeof item.check_data === "object" ? item.check_data : null,
|
|
150
|
+
pages_affected: typeof item.pages_affected === "number" ? item.pages_affected : null,
|
|
151
|
+
affected_urls: Array.isArray(item.affected_urls) ? item.affected_urls : null,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
112
155
|
// ---------------------------------------------------------------------------
|
|
113
156
|
// Finding enrichment
|
|
114
157
|
// ---------------------------------------------------------------------------
|
|
115
158
|
|
|
116
159
|
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
160
|
+
* Normalizes and enriches raw findings with intelligence data.
|
|
161
|
+
*
|
|
162
|
+
* Accepts either:
|
|
163
|
+
* - A full scan payload: { findings: object[], metadata?: object }
|
|
164
|
+
* - An array of findings directly: object[]
|
|
165
|
+
*
|
|
166
|
+
* Options:
|
|
167
|
+
* - screenshotUrlBuilder: (rawPath: string) => string — transforms screenshot
|
|
168
|
+
* paths into consumer-specific URLs.
|
|
169
|
+
*
|
|
170
|
+
* @param {object[]|{findings: object[]}} input
|
|
171
|
+
* @param {{ screenshotUrlBuilder?: (path: string) => string }} [options={}]
|
|
172
|
+
* @returns {object[]} Enriched, normalized, sorted findings.
|
|
121
173
|
*/
|
|
122
|
-
export function getEnrichedFindings(
|
|
174
|
+
export function getEnrichedFindings(input, options = {}) {
|
|
175
|
+
const { screenshotUrlBuilder = null } = options;
|
|
123
176
|
const rules = getIntelligence().rules || {};
|
|
124
177
|
|
|
125
|
-
|
|
178
|
+
// Accept payload object or array directly
|
|
179
|
+
const rawFindings = Array.isArray(input) ? input : (input?.findings || []);
|
|
180
|
+
|
|
181
|
+
// Normalize raw findings
|
|
182
|
+
const normalized = rawFindings.map((item, index) =>
|
|
183
|
+
normalizeSingleFinding(item, index, screenshotUrlBuilder)
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Enrich with intelligence + camelCase aliases
|
|
187
|
+
const enriched = normalized.map((finding) => {
|
|
126
188
|
const canonical = mapPa11yRuleToCanonical(
|
|
127
|
-
finding.
|
|
128
|
-
finding.
|
|
129
|
-
finding.
|
|
189
|
+
finding.rule_id,
|
|
190
|
+
finding.source_rule_id,
|
|
191
|
+
finding.check_data,
|
|
130
192
|
);
|
|
131
193
|
|
|
132
|
-
//
|
|
133
|
-
|
|
194
|
+
// Effort will be inferred after enrichment
|
|
195
|
+
|
|
196
|
+
// Always create camelCase aliases
|
|
197
|
+
const withAliases = {
|
|
134
198
|
...finding,
|
|
135
199
|
ruleId: canonical,
|
|
136
200
|
rule_id: canonical,
|
|
137
|
-
sourceRuleId: finding.
|
|
138
|
-
fixDescription: finding.
|
|
139
|
-
fixCode: finding.
|
|
140
|
-
fixCodeLang: finding.
|
|
141
|
-
falsePositiveRisk: finding.
|
|
142
|
-
fixDifficultyNotes: finding.
|
|
143
|
-
screenshotPath: finding.
|
|
144
|
-
wcagCriterionId: finding.
|
|
145
|
-
|
|
201
|
+
sourceRuleId: finding.source_rule_id || finding.rule_id || null,
|
|
202
|
+
fixDescription: finding.fix_description,
|
|
203
|
+
fixCode: finding.fix_code,
|
|
204
|
+
fixCodeLang: finding.fix_code_lang,
|
|
205
|
+
falsePositiveRisk: finding.false_positive_risk,
|
|
206
|
+
fixDifficultyNotes: finding.fix_difficulty_notes,
|
|
207
|
+
screenshotPath: finding.screenshot_path,
|
|
208
|
+
wcagCriterionId: finding.wcag_criterion_id,
|
|
209
|
+
wcagClassification: finding.wcag_classification,
|
|
210
|
+
impactedUsers: finding.impacted_users,
|
|
211
|
+
primarySelector: finding.primary_selector,
|
|
212
|
+
primaryFailureMode: finding.primary_failure_mode,
|
|
213
|
+
relationshipHint: finding.relationship_hint,
|
|
214
|
+
failureChecks: finding.failure_checks,
|
|
215
|
+
relatedContext: finding.related_context,
|
|
216
|
+
recommendedFix: finding.recommended_fix,
|
|
217
|
+
totalInstances: finding.total_instances,
|
|
218
|
+
relatedRules: finding.related_rules,
|
|
219
|
+
ownershipStatus: finding.ownership_status,
|
|
220
|
+
ownershipReason: finding.ownership_reason,
|
|
221
|
+
primarySourceScope: finding.primary_source_scope,
|
|
222
|
+
searchStrategy: finding.search_strategy,
|
|
223
|
+
managedByLibrary: finding.managed_by_library,
|
|
224
|
+
componentHint: finding.component_hint,
|
|
225
|
+
verificationCommand: finding.verification_command,
|
|
226
|
+
verificationCommandFallback: finding.verification_command_fallback,
|
|
227
|
+
checkData: finding.check_data,
|
|
228
|
+
pagesAffected: finding.pages_affected,
|
|
229
|
+
affectedUrls: finding.affected_urls,
|
|
146
230
|
};
|
|
147
231
|
|
|
148
232
|
// If fix data already exists, no need to look up intelligence
|
|
149
|
-
|
|
150
|
-
|
|
233
|
+
let result;
|
|
234
|
+
if (withAliases.fixDescription || withAliases.fixCode) {
|
|
235
|
+
result = withAliases;
|
|
236
|
+
} else {
|
|
237
|
+
const info = rules[canonical];
|
|
238
|
+
if (!info) {
|
|
239
|
+
result = withAliases;
|
|
240
|
+
} else {
|
|
241
|
+
result = {
|
|
242
|
+
...withAliases,
|
|
243
|
+
category: withAliases.category ?? info.category ?? null,
|
|
244
|
+
fixDescription: info.fix?.description ?? null,
|
|
245
|
+
fix_description: info.fix?.description ?? null,
|
|
246
|
+
fixCode: info.fix?.code ?? null,
|
|
247
|
+
fix_code: info.fix?.code ?? withAliases.fix_code ?? null,
|
|
248
|
+
falsePositiveRisk: withAliases.falsePositiveRisk ?? info.false_positive_risk ?? null,
|
|
249
|
+
false_positive_risk: withAliases.false_positive_risk ?? info.false_positive_risk ?? null,
|
|
250
|
+
fixDifficultyNotes: withAliases.fixDifficultyNotes ?? info.fix_difficulty_notes ?? null,
|
|
251
|
+
fix_difficulty_notes: withAliases.fix_difficulty_notes ?? info.fix_difficulty_notes ?? null,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
151
254
|
}
|
|
152
255
|
|
|
153
|
-
|
|
154
|
-
if (!
|
|
256
|
+
// Infer effort AFTER enrichment so intelligence-provided fixCode is considered
|
|
257
|
+
if (!result.effort || result.effort === "null") {
|
|
258
|
+
result.effort = (result.fixCode || result.fix_code) ? "low" : "high";
|
|
259
|
+
}
|
|
155
260
|
|
|
156
|
-
return
|
|
157
|
-
...normalized,
|
|
158
|
-
category: normalized.category ?? info.category ?? null,
|
|
159
|
-
fixDescription: info.fix?.description ?? null,
|
|
160
|
-
fix_description: info.fix?.description ?? normalized.fix_description ?? null,
|
|
161
|
-
fixCode: info.fix?.code ?? null,
|
|
162
|
-
fix_code: info.fix?.code ?? normalized.fix_code ?? null,
|
|
163
|
-
falsePositiveRisk: normalized.falsePositiveRisk ?? info.false_positive_risk ?? null,
|
|
164
|
-
false_positive_risk: normalized.false_positive_risk ?? info.false_positive_risk ?? null,
|
|
165
|
-
fixDifficultyNotes: normalized.fixDifficultyNotes ?? info.fix_difficulty_notes ?? null,
|
|
166
|
-
fix_difficulty_notes: normalized.fix_difficulty_notes ?? info.fix_difficulty_notes ?? null,
|
|
167
|
-
};
|
|
261
|
+
return result;
|
|
168
262
|
});
|
|
263
|
+
|
|
264
|
+
// Sort by severity then by ID
|
|
265
|
+
enriched.sort((a, b) => {
|
|
266
|
+
const sa = SEVERITY_ORDER[a.severity] ?? 99;
|
|
267
|
+
const sb = SEVERITY_ORDER[b.severity] ?? 99;
|
|
268
|
+
if (sa !== sb) return sa - sb;
|
|
269
|
+
return a.id.localeCompare(b.id);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
return enriched;
|
|
169
273
|
}
|
|
170
274
|
|
|
171
275
|
// ---------------------------------------------------------------------------
|
|
@@ -266,21 +370,169 @@ function getPersonaGroups(findings) {
|
|
|
266
370
|
|
|
267
371
|
/**
|
|
268
372
|
* Computes a complete audit summary from enriched findings: severity totals,
|
|
269
|
-
* compliance score, grade label, WCAG status,
|
|
373
|
+
* compliance score, grade label, WCAG status, persona groups, quick wins,
|
|
374
|
+
* target URL, and detected stack.
|
|
375
|
+
*
|
|
270
376
|
* @param {object[]} findings - Array of enriched findings.
|
|
271
|
-
* @
|
|
377
|
+
* @param {{ findings: object[], metadata?: object }|null} [payload=null] - Original scan payload for metadata extraction.
|
|
378
|
+
* @returns {object} Full audit summary.
|
|
272
379
|
*/
|
|
273
|
-
export function getAuditSummary(findings) {
|
|
380
|
+
export function getAuditSummary(findings, payload = null) {
|
|
274
381
|
const totals = { Critical: 0, Serious: 0, Moderate: 0, Minor: 0 };
|
|
275
382
|
for (const f of findings) {
|
|
276
|
-
const severity = f.severity ||
|
|
383
|
+
const severity = f.severity || "";
|
|
277
384
|
if (severity in totals) totals[severity] += 1;
|
|
278
385
|
}
|
|
279
386
|
|
|
280
387
|
const { score, label, wcagStatus } = getComplianceScore(totals);
|
|
281
388
|
const personaGroups = getPersonaGroups(findings);
|
|
282
389
|
|
|
283
|
-
|
|
390
|
+
const quickWins = findings
|
|
391
|
+
.filter((f) =>
|
|
392
|
+
(f.severity === "Critical" || f.severity === "Serious") &&
|
|
393
|
+
(f.fixCode || f.fix_code)
|
|
394
|
+
)
|
|
395
|
+
.slice(0, 3);
|
|
396
|
+
|
|
397
|
+
// Extract metadata from payload if provided
|
|
398
|
+
let targetUrl = "";
|
|
399
|
+
let detectedStack = { framework: null, cms: null, uiLibraries: [] };
|
|
400
|
+
|
|
401
|
+
if (payload && payload.metadata) {
|
|
402
|
+
const meta = payload.metadata;
|
|
403
|
+
const firstUrl = findings.length > 0 ? str(findings[0].url) : "";
|
|
404
|
+
targetUrl = str(meta.target_url || meta.targetUrl || meta.base_url || firstUrl);
|
|
405
|
+
|
|
406
|
+
const ctx = meta.projectContext || {};
|
|
407
|
+
detectedStack = {
|
|
408
|
+
framework: strOrNull(ctx.framework),
|
|
409
|
+
cms: strOrNull(ctx.cms),
|
|
410
|
+
uiLibraries: Array.isArray(ctx.uiLibraries) ? ctx.uiLibraries : [],
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
totals,
|
|
416
|
+
score,
|
|
417
|
+
label,
|
|
418
|
+
wcagStatus,
|
|
419
|
+
personaGroups,
|
|
420
|
+
quickWins,
|
|
421
|
+
targetUrl,
|
|
422
|
+
detectedStack,
|
|
423
|
+
totalFindings: findings.length,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// Full audit pipeline
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Runs a complete accessibility audit: crawl + scan (axe + CDP + pa11y) + analyze.
|
|
433
|
+
* Returns the enriched scan payload ready for getEnrichedFindings().
|
|
434
|
+
*
|
|
435
|
+
* @param {{
|
|
436
|
+
* baseUrl: string,
|
|
437
|
+
* maxRoutes?: number,
|
|
438
|
+
* crawlDepth?: number,
|
|
439
|
+
* routes?: string,
|
|
440
|
+
* waitMs?: number,
|
|
441
|
+
* timeoutMs?: number,
|
|
442
|
+
* headless?: boolean,
|
|
443
|
+
* waitUntil?: string,
|
|
444
|
+
* colorScheme?: string,
|
|
445
|
+
* viewport?: { width: number, height: number },
|
|
446
|
+
* axeTags?: string[],
|
|
447
|
+
* onlyRule?: string,
|
|
448
|
+
* excludeSelectors?: string[],
|
|
449
|
+
* ignoreFindings?: string[],
|
|
450
|
+
* framework?: string,
|
|
451
|
+
* projectDir?: string,
|
|
452
|
+
* skipPatterns?: boolean,
|
|
453
|
+
* onProgress?: (step: string, status: string, extra?: object) => void,
|
|
454
|
+
* }} options
|
|
455
|
+
* @returns {Promise<{ findings: object[], metadata: object, incomplete_findings?: object[] }>}
|
|
456
|
+
*/
|
|
457
|
+
export async function runAudit(options) {
|
|
458
|
+
if (!options.baseUrl) throw new Error("runAudit requires baseUrl");
|
|
459
|
+
|
|
460
|
+
const { runDomScanner } = await import("./engine/dom-scanner.mjs");
|
|
461
|
+
const { runAnalyzer } = await import("./engine/analyzer.mjs");
|
|
462
|
+
|
|
463
|
+
const onProgress = options.onProgress || null;
|
|
464
|
+
|
|
465
|
+
// Step 1: DOM scan (axe + CDP + pa11y)
|
|
466
|
+
if (onProgress) onProgress("page", "running");
|
|
467
|
+
|
|
468
|
+
const scanPayload = await runDomScanner(
|
|
469
|
+
{
|
|
470
|
+
baseUrl: options.baseUrl,
|
|
471
|
+
maxRoutes: options.maxRoutes,
|
|
472
|
+
crawlDepth: options.crawlDepth,
|
|
473
|
+
routes: options.routes,
|
|
474
|
+
waitMs: options.waitMs,
|
|
475
|
+
timeoutMs: options.timeoutMs,
|
|
476
|
+
headless: options.headless,
|
|
477
|
+
waitUntil: options.waitUntil,
|
|
478
|
+
colorScheme: options.colorScheme,
|
|
479
|
+
viewport: options.viewport,
|
|
480
|
+
axeTags: options.axeTags,
|
|
481
|
+
onlyRule: options.onlyRule,
|
|
482
|
+
excludeSelectors: options.excludeSelectors,
|
|
483
|
+
},
|
|
484
|
+
{ onProgress },
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
// Step 2: Analyze + enrich
|
|
488
|
+
if (onProgress) onProgress("intelligence", "running");
|
|
489
|
+
|
|
490
|
+
const findingsPayload = runAnalyzer(scanPayload, {
|
|
491
|
+
ignoreFindings: options.ignoreFindings,
|
|
492
|
+
framework: options.framework,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Step 3: Source patterns (optional)
|
|
496
|
+
if (options.projectDir && !options.skipPatterns) {
|
|
497
|
+
try {
|
|
498
|
+
const { resolveScanDirs, scanPattern } = await import("./engine/source-scanner.mjs");
|
|
499
|
+
const { patterns } = loadAssetJson(ASSET_PATHS.remediation.codePatterns, "code-patterns.json");
|
|
500
|
+
|
|
501
|
+
let resolvedFramework = options.framework;
|
|
502
|
+
if (!resolvedFramework && findingsPayload.metadata?.projectContext?.framework) {
|
|
503
|
+
resolvedFramework = findingsPayload.metadata.projectContext.framework;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const scanDirs = resolveScanDirs(resolvedFramework || null, options.projectDir);
|
|
507
|
+
const allPatternFindings = [];
|
|
508
|
+
for (const pattern of patterns) {
|
|
509
|
+
for (const scanDir of scanDirs) {
|
|
510
|
+
allPatternFindings.push(...scanPattern(pattern, scanDir, options.projectDir));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (allPatternFindings.length > 0) {
|
|
515
|
+
findingsPayload.patternFindings = {
|
|
516
|
+
generated_at: new Date().toISOString(),
|
|
517
|
+
project_dir: options.projectDir,
|
|
518
|
+
findings: allPatternFindings,
|
|
519
|
+
summary: {
|
|
520
|
+
total: allPatternFindings.length,
|
|
521
|
+
confirmed: allPatternFindings.filter((f) => f.status === "confirmed").length,
|
|
522
|
+
potential: allPatternFindings.filter((f) => f.status === "potential").length,
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
} catch (err) {
|
|
527
|
+
// Non-fatal: source scanning is optional
|
|
528
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
529
|
+
console.warn(`Source pattern scan failed (non-fatal): ${msg}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (onProgress) onProgress("intelligence", "done");
|
|
534
|
+
|
|
535
|
+
return findingsPayload;
|
|
284
536
|
}
|
|
285
537
|
|
|
286
538
|
// ---------------------------------------------------------------------------
|
|
@@ -298,10 +550,9 @@ import {
|
|
|
298
550
|
|
|
299
551
|
/**
|
|
300
552
|
* Generates a PDF report buffer from raw scan findings.
|
|
301
|
-
*
|
|
302
|
-
* @param {{ findings: object[], metadata?: object }} payload - Raw scan output (snake_case keys).
|
|
553
|
+
* @param {{ findings: object[], metadata?: object }} payload
|
|
303
554
|
* @param {{ baseUrl?: string, target?: string }} [options={}]
|
|
304
|
-
* @returns {Promise<
|
|
555
|
+
* @returns {Promise<{ buffer: Buffer, contentType: "application/pdf" }>}
|
|
305
556
|
*/
|
|
306
557
|
export async function getPDFReport(payload, options = {}) {
|
|
307
558
|
const { chromium } = await import("playwright");
|
|
@@ -367,9 +618,8 @@ ${buildPdfAuditLimitations()}
|
|
|
367
618
|
|
|
368
619
|
/**
|
|
369
620
|
* Generates a standalone manual accessibility checklist HTML string.
|
|
370
|
-
* Does not depend on scan results — reads from manual-checks.json asset.
|
|
371
621
|
* @param {{ baseUrl?: string }} [options={}]
|
|
372
|
-
* @returns {Promise<
|
|
622
|
+
* @returns {Promise<{ html: string, contentType: "text/html" }>}
|
|
373
623
|
*/
|
|
374
624
|
export async function getChecklist(options = {}) {
|
|
375
625
|
const { buildManualCheckCard } = await import("./reports/renderers/html.mjs");
|
|
@@ -387,9 +637,6 @@ export async function getChecklist(options = {}) {
|
|
|
387
637
|
const selectClasses =
|
|
388
638
|
"pl-4 pr-10 py-3 bg-white border border-slate-300 rounded-2xl text-sm font-bold text-slate-800 focus:outline-none focus:ring-4 focus:ring-amber-500/20 focus:border-amber-400 shadow-sm transition-all appearance-none cursor-pointer bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20viewBox%3D%220%200%2020%2020%22%3E%3Cpath%20stroke%3D%22%23374151%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%221.5%22%3E%3Cpath%20d%3D%22m6%208%204%204%204-4%22%2F%3E%3C%2Fsvg%3E')] bg-[length:1.25rem_1.25rem] bg-[right_0.5rem_center] bg-no-repeat";
|
|
389
639
|
|
|
390
|
-
// Import the full checklist builder to reuse its buildHtml
|
|
391
|
-
// The checklist builder module has a main() that auto-runs, so we dynamically
|
|
392
|
-
// construct the same output using the renderer functions directly.
|
|
393
640
|
const html = `<!doctype html>
|
|
394
641
|
<html lang="en">
|
|
395
642
|
<head>
|
|
@@ -472,3 +719,204 @@ export async function getChecklist(options = {}) {
|
|
|
472
719
|
contentType: "text/html",
|
|
473
720
|
};
|
|
474
721
|
}
|
|
722
|
+
|
|
723
|
+
// ---------------------------------------------------------------------------
|
|
724
|
+
// HTML Report
|
|
725
|
+
// ---------------------------------------------------------------------------
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Generates an interactive HTML audit dashboard from raw scan findings.
|
|
729
|
+
* Embeds screenshots as base64 data URIs when available.
|
|
730
|
+
* @param {{ findings: object[], metadata?: object }} payload - Raw scan output.
|
|
731
|
+
* @param {{ baseUrl?: string, target?: string, screenshotsDir?: string }} [options={}]
|
|
732
|
+
* @returns {Promise<{ html: string, contentType: "text/html" }>}
|
|
733
|
+
*/
|
|
734
|
+
export async function getHTMLReport(payload, options = {}) {
|
|
735
|
+
const fs = await import("node:fs");
|
|
736
|
+
const path = await import("node:path");
|
|
737
|
+
const { buildIssueCard, buildPageGroupedSection } = await import("./reports/renderers/html.mjs");
|
|
738
|
+
const { escapeHtml } = await import("./reports/renderers/utils.mjs");
|
|
739
|
+
|
|
740
|
+
const args = { baseUrl: options.baseUrl || "", target: options.target || "WCAG 2.2 AA" };
|
|
741
|
+
const findings = normalizeForReports(payload).filter(
|
|
742
|
+
(f) => f.wcagClassification !== "AAA" && f.wcagClassification !== "Best Practice",
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
// Embed screenshots as base64 if screenshotsDir is provided
|
|
746
|
+
if (options.screenshotsDir) {
|
|
747
|
+
for (const finding of findings) {
|
|
748
|
+
if (finding.screenshotPath) {
|
|
749
|
+
const filename = path.basename(finding.screenshotPath);
|
|
750
|
+
const absolutePath = path.join(options.screenshotsDir, filename);
|
|
751
|
+
try {
|
|
752
|
+
if (fs.existsSync(absolutePath)) {
|
|
753
|
+
const data = fs.readFileSync(absolutePath);
|
|
754
|
+
finding.screenshotPath = `data:image/png;base64,${data.toString("base64")}`;
|
|
755
|
+
} else {
|
|
756
|
+
finding.screenshotPath = null;
|
|
757
|
+
}
|
|
758
|
+
} catch {
|
|
759
|
+
finding.screenshotPath = null;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Dynamically import the html builder's buildHtml — it auto-executes main() on import,
|
|
766
|
+
// so we replicate its logic here using the renderers directly.
|
|
767
|
+
const {
|
|
768
|
+
buildSummary: buildSummaryLocal,
|
|
769
|
+
computeComplianceScore: computeScoreLocal,
|
|
770
|
+
scoreLabel: scoreLabelLocal,
|
|
771
|
+
buildPersonaSummary: buildPersonaSummaryLocal,
|
|
772
|
+
wcagOverallStatus: wcagOverallStatusLocal,
|
|
773
|
+
} = await import("./reports/renderers/findings.mjs");
|
|
774
|
+
|
|
775
|
+
// Use the builder's internal buildHtml by re-importing it
|
|
776
|
+
// Since html.mjs auto-runs main() on import, we cannot import it directly.
|
|
777
|
+
// Instead, we construct the HTML using the same renderers.
|
|
778
|
+
const totals = buildSummaryLocal(findings);
|
|
779
|
+
const score = computeScoreLocal(totals);
|
|
780
|
+
const label = scoreLabelLocal(score);
|
|
781
|
+
const wcagStatus = wcagOverallStatusLocal(totals);
|
|
782
|
+
const personaCounts = buildPersonaSummaryLocal(findings);
|
|
783
|
+
|
|
784
|
+
let siteHostname = args.baseUrl;
|
|
785
|
+
try {
|
|
786
|
+
siteHostname = new URL(args.baseUrl.startsWith("http") ? args.baseUrl : `https://${args.baseUrl}`).hostname;
|
|
787
|
+
} catch {}
|
|
788
|
+
|
|
789
|
+
const pageGroups = {};
|
|
790
|
+
for (const f of findings) {
|
|
791
|
+
const area = f.area || "Unknown";
|
|
792
|
+
if (!pageGroups[area]) pageGroups[area] = [];
|
|
793
|
+
pageGroups[area].push(f);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const issueCards = findings.map((f) => buildIssueCard(f)).join("\n");
|
|
797
|
+
const pageGroupedSections = Object.entries(pageGroups)
|
|
798
|
+
.map(([area, group]) => buildPageGroupedSection(area, group))
|
|
799
|
+
.join("\n");
|
|
800
|
+
|
|
801
|
+
const quickWins = findings
|
|
802
|
+
.filter((f) => (f.severity === "Critical" || f.severity === "Serious") && f.fixCode)
|
|
803
|
+
.slice(0, 3);
|
|
804
|
+
|
|
805
|
+
// Build a self-contained HTML report
|
|
806
|
+
const html = `<!doctype html>
|
|
807
|
+
<html lang="en">
|
|
808
|
+
<head>
|
|
809
|
+
<meta charset="utf-8">
|
|
810
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
811
|
+
<title>Accessibility Audit — ${escapeHtml(siteHostname)}</title>
|
|
812
|
+
<script src="https://cdn.tailwindcss.com"><\/script>
|
|
813
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
814
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
815
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
816
|
+
<style>
|
|
817
|
+
body { font-family: 'Inter', sans-serif; background: #f8fafc; }
|
|
818
|
+
</style>
|
|
819
|
+
</head>
|
|
820
|
+
<body>
|
|
821
|
+
<main class="max-w-5xl mx-auto px-4 py-12">
|
|
822
|
+
<h1 class="text-3xl font-extrabold mb-2">Accessibility Audit Dashboard</h1>
|
|
823
|
+
<p class="text-slate-500 mb-8">${escapeHtml(siteHostname)} — ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</p>
|
|
824
|
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
|
825
|
+
<div class="bg-white rounded-xl p-4 border border-slate-200 shadow-sm">
|
|
826
|
+
<div class="text-3xl font-black">${score}</div>
|
|
827
|
+
<div class="text-xs font-bold text-slate-500 uppercase">${label}</div>
|
|
828
|
+
</div>
|
|
829
|
+
<div class="bg-white rounded-xl p-4 border border-slate-200 shadow-sm">
|
|
830
|
+
<div class="text-3xl font-black">${findings.length}</div>
|
|
831
|
+
<div class="text-xs font-bold text-slate-500 uppercase">Issues</div>
|
|
832
|
+
</div>
|
|
833
|
+
<div class="bg-white rounded-xl p-4 border border-slate-200 shadow-sm">
|
|
834
|
+
<div class="text-3xl font-black ${wcagStatus === 'Pass' ? 'text-emerald-600' : 'text-rose-600'}">${wcagStatus}</div>
|
|
835
|
+
<div class="text-xs font-bold text-slate-500 uppercase">WCAG 2.2 AA</div>
|
|
836
|
+
</div>
|
|
837
|
+
<div class="bg-white rounded-xl p-4 border border-slate-200 shadow-sm">
|
|
838
|
+
<div class="text-3xl font-black">${Object.keys(pageGroups).length}</div>
|
|
839
|
+
<div class="text-xs font-bold text-slate-500 uppercase">Pages</div>
|
|
840
|
+
</div>
|
|
841
|
+
</div>
|
|
842
|
+
<div class="space-y-4">
|
|
843
|
+
${pageGroupedSections}
|
|
844
|
+
</div>
|
|
845
|
+
</main>
|
|
846
|
+
</body>
|
|
847
|
+
</html>`;
|
|
848
|
+
|
|
849
|
+
return {
|
|
850
|
+
html,
|
|
851
|
+
contentType: "text/html",
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// ---------------------------------------------------------------------------
|
|
856
|
+
// Remediation Guide (Markdown)
|
|
857
|
+
// ---------------------------------------------------------------------------
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Generates a Markdown remediation guide from raw scan findings.
|
|
861
|
+
* @param {{ findings: object[], metadata?: object, incomplete_findings?: object[] }} payload
|
|
862
|
+
* @param {{ baseUrl?: string, target?: string, patternFindings?: object }} [options={}]
|
|
863
|
+
* @returns {Promise<{ markdown: string, contentType: "text/markdown" }>}
|
|
864
|
+
*/
|
|
865
|
+
export async function getRemediationGuide(payload, options = {}) {
|
|
866
|
+
const { buildMarkdownSummary } = await import("./reports/renderers/md.mjs");
|
|
867
|
+
|
|
868
|
+
const args = { baseUrl: options.baseUrl || "", target: options.target || "WCAG 2.2 AA" };
|
|
869
|
+
const findings = normalizeForReports(payload);
|
|
870
|
+
|
|
871
|
+
const markdown = buildMarkdownSummary(args, findings, {
|
|
872
|
+
...payload.metadata,
|
|
873
|
+
incomplete_findings: payload.incomplete_findings,
|
|
874
|
+
pattern_findings: options.patternFindings || null,
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
return {
|
|
878
|
+
markdown,
|
|
879
|
+
contentType: "text/markdown",
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ---------------------------------------------------------------------------
|
|
884
|
+
// Source Pattern Scanner
|
|
885
|
+
// ---------------------------------------------------------------------------
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Scans a project's source code for accessibility patterns not detectable by axe-core.
|
|
889
|
+
* @param {string} projectDir - Absolute path to the project root.
|
|
890
|
+
* @param {{ framework?: string, onlyPattern?: string }} [options={}]
|
|
891
|
+
* @returns {Promise<{ findings: object[], summary: { total: number, confirmed: number, potential: number } }>}
|
|
892
|
+
*/
|
|
893
|
+
export async function getSourcePatterns(projectDir, options = {}) {
|
|
894
|
+
const { scanPattern, resolveScanDirs } = await import("./engine/source-scanner.mjs");
|
|
895
|
+
|
|
896
|
+
const { patterns } = loadAssetJson(ASSET_PATHS.remediation.codePatterns, "code-patterns.json");
|
|
897
|
+
|
|
898
|
+
const activePatterns = options.onlyPattern
|
|
899
|
+
? patterns.filter((p) => p.id === options.onlyPattern)
|
|
900
|
+
: patterns;
|
|
901
|
+
|
|
902
|
+
if (activePatterns.length === 0) {
|
|
903
|
+
return { findings: [], summary: { total: 0, confirmed: 0, potential: 0 } };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const scanDirs = resolveScanDirs(options.framework || null, projectDir);
|
|
907
|
+
const allFindings = [];
|
|
908
|
+
|
|
909
|
+
for (const pattern of activePatterns) {
|
|
910
|
+
for (const scanDir of scanDirs) {
|
|
911
|
+
allFindings.push(...scanPattern(pattern, scanDir, projectDir));
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const confirmed = allFindings.filter((f) => f.status === "confirmed").length;
|
|
916
|
+
const potential = allFindings.filter((f) => f.status === "potential").length;
|
|
917
|
+
|
|
918
|
+
return {
|
|
919
|
+
findings: allFindings,
|
|
920
|
+
summary: { total: allFindings.length, confirmed, potential },
|
|
921
|
+
};
|
|
922
|
+
}
|