@diegovelasquezweb/a11y-engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +20 -0
- package/assets/discovery/crawler-config.json +11 -0
- package/assets/discovery/stack-detection.json +33 -0
- package/assets/remediation/axe-check-maps.json +31 -0
- package/assets/remediation/code-patterns.json +109 -0
- package/assets/remediation/guardrails.json +24 -0
- package/assets/remediation/intelligence.json +4166 -0
- package/assets/remediation/source-boundaries.json +46 -0
- package/assets/reporting/compliance-config.json +173 -0
- package/assets/reporting/manual-checks.json +944 -0
- package/assets/reporting/wcag-reference.json +588 -0
- package/package.json +37 -0
- package/scripts/audit.mjs +326 -0
- package/scripts/core/asset-loader.mjs +54 -0
- package/scripts/core/toolchain.mjs +102 -0
- package/scripts/core/utils.mjs +105 -0
- package/scripts/engine/analyzer.mjs +1022 -0
- package/scripts/engine/dom-scanner.mjs +685 -0
- package/scripts/engine/source-scanner.mjs +300 -0
- package/scripts/reports/builders/checklist.mjs +307 -0
- package/scripts/reports/builders/html.mjs +766 -0
- package/scripts/reports/builders/md.mjs +96 -0
- package/scripts/reports/builders/pdf.mjs +259 -0
- package/scripts/reports/renderers/findings.mjs +188 -0
- package/scripts/reports/renderers/html.mjs +452 -0
- package/scripts/reports/renderers/md.mjs +595 -0
- package/scripts/reports/renderers/pdf.mjs +551 -0
- package/scripts/reports/renderers/utils.mjs +42 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file format-md.mjs
|
|
3
|
+
* @description Markdown remediation guide builder optimized for AI agents.
|
|
4
|
+
* Generates an actionable markdown document containing technical solutions,
|
|
5
|
+
* surgical selectors, and framework-specific guardrails.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { buildSummary } from "./findings.mjs";
|
|
9
|
+
import { ASSET_PATHS, loadAssetJson } from "../../core/asset-loader.mjs";
|
|
10
|
+
|
|
11
|
+
const GUARDRAILS = loadAssetJson(
|
|
12
|
+
ASSET_PATHS.remediation.guardrails,
|
|
13
|
+
"assets/remediation/guardrails.json",
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const SOURCE_BOUNDARIES = loadAssetJson(
|
|
17
|
+
ASSET_PATHS.remediation.sourceBoundaries,
|
|
18
|
+
"assets/remediation/source-boundaries.json",
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolves the active web framework or CMS platform to provide tailored guardrails.
|
|
24
|
+
* @param {Object} [metadata={}] - Scan metadata including project context.
|
|
25
|
+
* @param {string} [baseUrl=""] - The target base URL.
|
|
26
|
+
* @param {string} [configFramework=null] - Explicit framework override from config.
|
|
27
|
+
* @returns {string} The identified framework ID (e.g., 'shopify', 'nextjs', 'generic').
|
|
28
|
+
*/
|
|
29
|
+
function resolveFramework(metadata = {}, baseUrl = "", configFramework = null) {
|
|
30
|
+
if (configFramework) return configFramework.toLowerCase();
|
|
31
|
+
const detected = metadata.projectContext?.framework;
|
|
32
|
+
if (detected) return detected;
|
|
33
|
+
return "generic";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Constructs a set of guardrail instructions tailored to a specific framework.
|
|
38
|
+
* @param {string} framework - The identified framework ID.
|
|
39
|
+
* @returns {string} A numbered list of framework-specific operating procedures.
|
|
40
|
+
*/
|
|
41
|
+
function buildGuardrails(framework) {
|
|
42
|
+
const guardrails = GUARDRAILS || {};
|
|
43
|
+
const shared = guardrails.shared || [];
|
|
44
|
+
const stackRules = guardrails.stack || {};
|
|
45
|
+
const frameworkRule = stackRules[framework] ?? stackRules.generic;
|
|
46
|
+
return [frameworkRule, ...shared]
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.map((rule, index) => `${index + 1}. ${rule}`)
|
|
49
|
+
.join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Renders the source file location table for the detected framework.
|
|
54
|
+
* @param {string} framework
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
function buildSourceBoundariesSection(framework) {
|
|
58
|
+
const boundaries = SOURCE_BOUNDARIES?.[framework];
|
|
59
|
+
if (!boundaries) return "";
|
|
60
|
+
const rows = [];
|
|
61
|
+
if (boundaries.components) rows.push(`| Components | \`${boundaries.components}\` |`);
|
|
62
|
+
if (boundaries.styles) rows.push(`| Styles | \`${boundaries.styles}\` |`);
|
|
63
|
+
if (rows.length === 0) return "";
|
|
64
|
+
return `## Source File Locations\n\n| Type | Glob Pattern |\n|---|---|\n${rows.join("\n")}\n`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatViolation(actual) {
|
|
68
|
+
if (!actual) return "";
|
|
69
|
+
return actual.replace(/^Fix any of the following:\s*/i, "").trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeComponentHint(hint) {
|
|
73
|
+
if (!hint || hint === "other") return "source-not-resolved";
|
|
74
|
+
if (hint.length <= 3) return "source-not-resolved";
|
|
75
|
+
if (/^(p|px|py|pt|pr|pb|pl|m|mx|my|mt|mr|mb|ml|w|h|min|max|gap|space|text|bg|border|rounded|shadow|opacity|z|top|right|bottom|left|flex|grid|items|justify|content|self)-/i.test(hint)) return "source-not-resolved";
|
|
76
|
+
if (/^\d/.test(hint)) return "source-not-resolved";
|
|
77
|
+
return hint;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const PRIORITY_BY_SEVERITY = {
|
|
81
|
+
Critical: 1,
|
|
82
|
+
Serious: 2,
|
|
83
|
+
Moderate: 3,
|
|
84
|
+
Minor: 4,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Builds the Recommendations section with single-point-fix opportunities and systemic patterns.
|
|
90
|
+
* @param {Object} recommendations
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
function buildRecommendationsSection(recommendations) {
|
|
94
|
+
if (!recommendations) return "";
|
|
95
|
+
const { single_point_fixes = [], systemic_patterns = [] } = recommendations;
|
|
96
|
+
if (single_point_fixes.length === 0 && systemic_patterns.length === 0) return "";
|
|
97
|
+
if (single_point_fixes.length === 1 && systemic_patterns.length === 0) return "";
|
|
98
|
+
|
|
99
|
+
const parts = [];
|
|
100
|
+
|
|
101
|
+
if (single_point_fixes.length > 0) {
|
|
102
|
+
const merged = new Map();
|
|
103
|
+
for (const r of single_point_fixes) {
|
|
104
|
+
const component = normalizeComponentHint(r.component);
|
|
105
|
+
if (!merged.has(component)) {
|
|
106
|
+
merged.set(component, { ...r, component, rules: [...r.rules] });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const current = merged.get(component);
|
|
110
|
+
current.total_issues += r.total_issues;
|
|
111
|
+
current.total_pages = Math.max(current.total_pages, r.total_pages);
|
|
112
|
+
current.rules = [...new Set([...(current.rules || []), ...(r.rules || [])])];
|
|
113
|
+
}
|
|
114
|
+
const mergedRows = [...merged.values()].sort(
|
|
115
|
+
(a, b) => (b.total_issues * b.total_pages) - (a.total_issues * a.total_pages),
|
|
116
|
+
);
|
|
117
|
+
const normalizedRows = mergedRows.map(
|
|
118
|
+
(r) => `| \`${r.component}\` | ${r.total_issues} | ${r.total_pages} | ${(r.rules || []).map((x) => `\`${x}\``).join(", ")} |`,
|
|
119
|
+
);
|
|
120
|
+
parts.push(
|
|
121
|
+
`### Shared Component Opportunities\n\n| Component | Issues | Pages | Rules |\n|---|---|---|---|\n${normalizedRows.join("\n")}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (systemic_patterns.length > 0) {
|
|
126
|
+
const rows = systemic_patterns.map(
|
|
127
|
+
(r) => `| ${r.wcag_criterion} | ${r.total_issues} | ${r.affected_components.map((c) => `\`${normalizeComponentHint(c)}\``).join(", ")} |`,
|
|
128
|
+
);
|
|
129
|
+
parts.push(
|
|
130
|
+
`### Systemic Patterns\n\n| Criterion | Issues | Affected Components |\n|---|---|---|\n${rows.join("\n")}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return `## Recommendations\n\n${parts.join("\n\n")}\n`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Builds the Potential Issues section from incomplete (needs-review) axe violations.
|
|
140
|
+
* @param {Object[]} incompleteFindings
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
function buildIncompleteSection(incompleteFindings) {
|
|
144
|
+
if (!Array.isArray(incompleteFindings) || incompleteFindings.length === 0) return "";
|
|
145
|
+
const rows = incompleteFindings.map((f) => {
|
|
146
|
+
const msg = (f.message || f.description || "Needs manual review").replace(/\|/g, "\\|");
|
|
147
|
+
const areaCell = f.pages_affected > 1
|
|
148
|
+
? `${f.pages_affected} pages`
|
|
149
|
+
: `\`${f.areas?.[0] ?? "?"}\``;
|
|
150
|
+
let actionableHint = "";
|
|
151
|
+
if (f.rule_id === "duplicate-id-aria" && f.message) {
|
|
152
|
+
const idMatch = f.message.match(/same id attribute[:\s]+(\S+?)\.?\s*$/i);
|
|
153
|
+
if (idMatch) actionableHint = ` — grep: \`id="${idMatch[1]}"\``;
|
|
154
|
+
}
|
|
155
|
+
return `| \`${f.rule_id}\` | ${f.impact ?? "?"} | ${areaCell} | ${msg}${actionableHint} |`;
|
|
156
|
+
});
|
|
157
|
+
return `## Potential Issues — Manual Review Required
|
|
158
|
+
|
|
159
|
+
axe flagged these but could not auto-confirm them. Do not apply automated fixes — verify manually before acting.
|
|
160
|
+
|
|
161
|
+
| Rule | Impact | Area | Axe Message |
|
|
162
|
+
|---|---|---|---|
|
|
163
|
+
${rows.join("\n")}
|
|
164
|
+
`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const WCAG_CRITERIA = {
|
|
168
|
+
"1.1.1": { name: "Non-text Content", level: "A" },
|
|
169
|
+
"1.2.1": { name: "Audio-only and Video-only (Prerecorded)", level: "A" },
|
|
170
|
+
"1.2.2": { name: "Captions (Prerecorded)", level: "A" },
|
|
171
|
+
"1.2.3": { name: "Audio Description or Media Alternative", level: "A" },
|
|
172
|
+
"1.2.4": { name: "Captions (Live)", level: "AA" },
|
|
173
|
+
"1.2.5": { name: "Audio Description (Prerecorded)", level: "AA" },
|
|
174
|
+
"1.3.1": { name: "Info and Relationships", level: "A" },
|
|
175
|
+
"1.3.2": { name: "Meaningful Sequence", level: "A" },
|
|
176
|
+
"1.3.3": { name: "Sensory Characteristics", level: "A" },
|
|
177
|
+
"1.3.4": { name: "Orientation", level: "AA" },
|
|
178
|
+
"1.3.5": { name: "Identify Input Purpose", level: "AA" },
|
|
179
|
+
"1.3.6": { name: "Identify Purpose", level: "AAA" },
|
|
180
|
+
"1.4.1": { name: "Use of Color", level: "A" },
|
|
181
|
+
"1.4.2": { name: "Audio Control", level: "A" },
|
|
182
|
+
"1.4.3": { name: "Contrast (Minimum)", level: "AA" },
|
|
183
|
+
"1.4.4": { name: "Resize Text", level: "AA" },
|
|
184
|
+
"1.4.5": { name: "Images of Text", level: "AA" },
|
|
185
|
+
"1.4.6": { name: "Contrast (Enhanced)", level: "AAA" },
|
|
186
|
+
"1.4.10": { name: "Reflow", level: "AA" },
|
|
187
|
+
"1.4.11": { name: "Non-text Contrast", level: "AA" },
|
|
188
|
+
"1.4.12": { name: "Text Spacing", level: "AA" },
|
|
189
|
+
"1.4.13": { name: "Content on Hover or Focus", level: "AA" },
|
|
190
|
+
"2.1.1": { name: "Keyboard", level: "A" },
|
|
191
|
+
"2.1.2": { name: "No Keyboard Trap", level: "A" },
|
|
192
|
+
"2.1.4": { name: "Character Key Shortcuts", level: "A" },
|
|
193
|
+
"2.2.1": { name: "Timing Adjustable", level: "A" },
|
|
194
|
+
"2.2.2": { name: "Pause, Stop, Hide", level: "A" },
|
|
195
|
+
"2.3.1": { name: "Three Flashes or Below Threshold", level: "A" },
|
|
196
|
+
"2.4.1": { name: "Bypass Blocks", level: "A" },
|
|
197
|
+
"2.4.2": { name: "Page Titled", level: "A" },
|
|
198
|
+
"2.4.3": { name: "Focus Order", level: "A" },
|
|
199
|
+
"2.4.4": { name: "Link Purpose (In Context)", level: "A" },
|
|
200
|
+
"2.4.5": { name: "Multiple Ways", level: "AA" },
|
|
201
|
+
"2.4.6": { name: "Headings and Labels", level: "AA" },
|
|
202
|
+
"2.4.7": { name: "Focus Visible", level: "AA" },
|
|
203
|
+
"2.4.11": { name: "Focus Not Obscured (Minimum)", level: "AA" },
|
|
204
|
+
"2.4.12": { name: "Focus Not Obscured (Enhanced)", level: "AAA" },
|
|
205
|
+
"2.5.1": { name: "Pointer Gestures", level: "A" },
|
|
206
|
+
"2.5.2": { name: "Pointer Cancellation", level: "A" },
|
|
207
|
+
"2.5.3": { name: "Label in Name", level: "A" },
|
|
208
|
+
"2.5.4": { name: "Motion Actuation", level: "A" },
|
|
209
|
+
"2.5.7": { name: "Dragging Movements", level: "AA" },
|
|
210
|
+
"2.5.8": { name: "Target Size (Minimum)", level: "AA" },
|
|
211
|
+
"3.1.1": { name: "Language of Page", level: "A" },
|
|
212
|
+
"3.1.2": { name: "Language of Parts", level: "AA" },
|
|
213
|
+
"3.2.1": { name: "On Focus", level: "A" },
|
|
214
|
+
"3.2.2": { name: "On Input", level: "A" },
|
|
215
|
+
"3.2.3": { name: "Consistent Navigation", level: "AA" },
|
|
216
|
+
"3.2.4": { name: "Consistent Identification", level: "AA" },
|
|
217
|
+
"3.2.6": { name: "Consistent Help", level: "A" },
|
|
218
|
+
"3.3.1": { name: "Error Identification", level: "A" },
|
|
219
|
+
"3.3.2": { name: "Labels or Instructions", level: "A" },
|
|
220
|
+
"3.3.3": { name: "Error Suggestion", level: "AA" },
|
|
221
|
+
"3.3.4": { name: "Error Prevention (Legal, Financial, Data)", level: "AA" },
|
|
222
|
+
"3.3.7": { name: "Redundant Entry", level: "A" },
|
|
223
|
+
"3.3.8": { name: "Accessible Authentication (Minimum)", level: "AA" },
|
|
224
|
+
"4.1.1": { name: "Parsing", level: "A" },
|
|
225
|
+
"4.1.2": { name: "Name, Role, Value", level: "A" },
|
|
226
|
+
"4.1.3": { name: "Status Messages", level: "AA" },
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Builds the Passed WCAG Criteria section from passedCriteria metadata.
|
|
231
|
+
* Filters out AAA criteria since the target compliance level is AA.
|
|
232
|
+
* @param {string[]} passedCriteria - Array of criterion IDs e.g. ["1.1.1", "1.3.1"]
|
|
233
|
+
* @returns {string}
|
|
234
|
+
*/
|
|
235
|
+
function buildPassedCriteriaSection(passedCriteria) {
|
|
236
|
+
if (!Array.isArray(passedCriteria) || passedCriteria.length === 0) return "";
|
|
237
|
+
const rows = passedCriteria
|
|
238
|
+
.filter((id) => WCAG_CRITERIA[id]?.level !== "AAA")
|
|
239
|
+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
|
240
|
+
.map((id) => {
|
|
241
|
+
const meta = WCAG_CRITERIA[id];
|
|
242
|
+
const name = meta?.name ?? "Unknown";
|
|
243
|
+
const level = meta?.level ?? "?";
|
|
244
|
+
return `| ${id} | ${name} | ${level} |`;
|
|
245
|
+
});
|
|
246
|
+
if (rows.length === 0) return "";
|
|
247
|
+
return `## Passed WCAG 2.2 Criteria\n\n| Criterion | Name | Level |\n|---|---|---|\n${rows.join("\n")}\n`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Builds the Source Code Pattern Findings section from pattern-scanner output.
|
|
252
|
+
* @param {Object|null} patternPayload - The a11y-pattern-findings.json payload.
|
|
253
|
+
* @returns {string}
|
|
254
|
+
*/
|
|
255
|
+
function buildPatternSection(patternPayload) {
|
|
256
|
+
if (!patternPayload || !Array.isArray(patternPayload.findings) || patternPayload.findings.length === 0) return "";
|
|
257
|
+
|
|
258
|
+
const { findings, project_dir } = patternPayload;
|
|
259
|
+
|
|
260
|
+
const groups = new Map();
|
|
261
|
+
for (const f of findings) {
|
|
262
|
+
if (!groups.has(f.pattern_id)) {
|
|
263
|
+
groups.set(f.pattern_id, {
|
|
264
|
+
title: f.title,
|
|
265
|
+
severity: f.severity,
|
|
266
|
+
wcag: f.wcag,
|
|
267
|
+
type: f.type,
|
|
268
|
+
fix_description: f.fix_description ?? null,
|
|
269
|
+
findings: [],
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
groups.get(f.pattern_id).findings.push(f);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const totalLocations = findings.length;
|
|
276
|
+
const confirmedCount = findings.filter((f) => f.status === "confirmed").length;
|
|
277
|
+
const potentialCount = findings.filter((f) => f.status === "potential").length;
|
|
278
|
+
const badge = [
|
|
279
|
+
confirmedCount > 0 ? `${confirmedCount} confirmed` : null,
|
|
280
|
+
potentialCount > 0 ? `${potentialCount} potential` : null,
|
|
281
|
+
].filter(Boolean).join(", ");
|
|
282
|
+
|
|
283
|
+
function groupToMd(group) {
|
|
284
|
+
const confirmed = group.findings.filter((f) => f.status === "confirmed");
|
|
285
|
+
const potential = group.findings.filter((f) => f.status === "potential");
|
|
286
|
+
const count = group.findings.length;
|
|
287
|
+
|
|
288
|
+
const lines = [
|
|
289
|
+
`---`,
|
|
290
|
+
`### ${group.title} · ${group.severity} · ${count} location${count !== 1 ? "s" : ""}`,
|
|
291
|
+
``,
|
|
292
|
+
`- **WCAG:** ${group.wcag}`,
|
|
293
|
+
`- **Type:** ${group.type}`,
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
if (confirmed.length > 0) {
|
|
297
|
+
lines.push(``, `**Confirmed (${confirmed.length}):**`);
|
|
298
|
+
for (const f of confirmed) lines.push(`- \`${f.file}:${f.line}\` — \`${f.match}\``);
|
|
299
|
+
}
|
|
300
|
+
if (potential.length > 0) {
|
|
301
|
+
lines.push(``, `**Potential — verify before fixing (${potential.length}):**`);
|
|
302
|
+
for (const f of potential) lines.push(`- \`${f.file}:${f.line}\` — \`${f.match}\``);
|
|
303
|
+
}
|
|
304
|
+
if (group.fix_description) {
|
|
305
|
+
lines.push(``, `#### Recommended Fix`, group.fix_description);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return lines.join("\n");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const parts = [...groups.values()].map(groupToMd);
|
|
312
|
+
|
|
313
|
+
return `## Source Code Pattern Findings
|
|
314
|
+
|
|
315
|
+
${groups.size} pattern type${groups.size !== 1 ? "s" : ""} · ${totalLocations} location${totalLocations !== 1 ? "s" : ""} (${badge}) — scanned \`${project_dir || "project"}\`
|
|
316
|
+
|
|
317
|
+
Do not auto-fix — inspect each match in the source file before applying any fix.
|
|
318
|
+
|
|
319
|
+
${parts.join("\n\n")}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Builds the full AI-optimized remediation guide in Markdown format.
|
|
324
|
+
* Includes a summary table, guardrails, component map, and detailed issue lists.
|
|
325
|
+
* @param {Object} args - The parsed CLI arguments.
|
|
326
|
+
* @param {Object[]} findings - The normalized findings to include.
|
|
327
|
+
* @param {Object} [metadata={}] - Optional scan metadata.
|
|
328
|
+
* @returns {string} The complete Markdown document.
|
|
329
|
+
*/
|
|
330
|
+
export function buildMarkdownSummary(args, findings, metadata = {}) {
|
|
331
|
+
const framework = resolveFramework(
|
|
332
|
+
metadata,
|
|
333
|
+
args.baseUrl,
|
|
334
|
+
args.framework ?? null,
|
|
335
|
+
);
|
|
336
|
+
const wcagFindings = findings.filter(
|
|
337
|
+
(f) => f.wcagClassification !== "AAA" && f.wcagClassification !== "Best Practice",
|
|
338
|
+
);
|
|
339
|
+
const totals = buildSummary(wcagFindings);
|
|
340
|
+
const orderedFindings = [...wcagFindings].sort((a, b) => {
|
|
341
|
+
const pa = PRIORITY_BY_SEVERITY[a.severity] ?? 99;
|
|
342
|
+
const pb = PRIORITY_BY_SEVERITY[b.severity] ?? 99;
|
|
343
|
+
if (pa !== pb) return pa - pb;
|
|
344
|
+
return String(a.id || "").localeCompare(String(b.id || ""));
|
|
345
|
+
});
|
|
346
|
+
const executionIndex = new Map(
|
|
347
|
+
orderedFindings.map((f, idx) => [f.id || f.ruleId, idx + 1]),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
function buildExecutionOrderSection() {
|
|
351
|
+
if (orderedFindings.length === 0) return "";
|
|
352
|
+
const rows = orderedFindings.map((f) => {
|
|
353
|
+
const id = f.id || f.ruleId;
|
|
354
|
+
return `| ${executionIndex.get(id)} | \`${id}\` | ${f.severity} | \`${f.ruleId}\` | ${f.category || "n/a"} | \`${f.area}\` |`;
|
|
355
|
+
});
|
|
356
|
+
return `## Execution Order\n\n| Priority | ID | Severity | Rule | Category | Area |\n|---|---|---|---|---|---|\n${rows.join("\n")}\n`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function findingToMd(f) {
|
|
360
|
+
let evidenceHtml = null;
|
|
361
|
+
let evidenceLabel = "#### Evidence from DOM";
|
|
362
|
+
(() => {
|
|
363
|
+
if (!f.evidence || !Array.isArray(f.evidence) || f.evidence.length === 0)
|
|
364
|
+
return;
|
|
365
|
+
const seen = new Set();
|
|
366
|
+
const unique = f.evidence.filter((n) => {
|
|
367
|
+
if (!n.html || seen.has(n.html)) return false;
|
|
368
|
+
seen.add(n.html);
|
|
369
|
+
return true;
|
|
370
|
+
});
|
|
371
|
+
const shownCount = unique.length;
|
|
372
|
+
if (f.totalInstances && f.totalInstances > shownCount) {
|
|
373
|
+
evidenceLabel = `#### Evidence from DOM (showing ${shownCount} of ${f.totalInstances} instances)`;
|
|
374
|
+
}
|
|
375
|
+
evidenceHtml = unique
|
|
376
|
+
.map((n, i) => {
|
|
377
|
+
const ancestryLine = n.ancestry ? `\n**DOM Path:** \`${n.ancestry}\`` : "";
|
|
378
|
+
return `**Instance ${i + 1}**:\n\`\`\`html\n${n.html}\n\`\`\`${ancestryLine}`;
|
|
379
|
+
})
|
|
380
|
+
.join("\n\n");
|
|
381
|
+
})();
|
|
382
|
+
|
|
383
|
+
const codeLang = f.fixCodeLang || "html";
|
|
384
|
+
const fixBlock =
|
|
385
|
+
f.fixDescription || f.fixCode
|
|
386
|
+
? `#### Recommended Technical Solution\n${f.fixDescription ? `${f.fixDescription}\n\n` : ""}${f.fixCode ? `\`\`\`${codeLang}\n${f.fixCode}\n\`\`\`` : ""}`.trimEnd()
|
|
387
|
+
: `#### Recommended Remediation\n${f.recommendedFix}`;
|
|
388
|
+
|
|
389
|
+
const crossPageBlock =
|
|
390
|
+
f.pagesAffected && f.pagesAffected > 1
|
|
391
|
+
? `> **Cross-page:** Found on ${f.pagesAffected} pages — ${(f.affectedUrls || []).join(", ")}`
|
|
392
|
+
: null;
|
|
393
|
+
|
|
394
|
+
const difficultyBlock = f.fixDifficultyNotes
|
|
395
|
+
? `#### Implementation Notes\n${
|
|
396
|
+
Array.isArray(f.fixDifficultyNotes)
|
|
397
|
+
? f.fixDifficultyNotes.map((n) => `- ${n}`).join("\n")
|
|
398
|
+
: f.fixDifficultyNotes
|
|
399
|
+
}`
|
|
400
|
+
: null;
|
|
401
|
+
|
|
402
|
+
const frameworkBlock =
|
|
403
|
+
f.frameworkNotes && typeof f.frameworkNotes === "object"
|
|
404
|
+
? `#### Framework Notes\n${Object.entries(f.frameworkNotes)
|
|
405
|
+
.map(([fw, note]) => `- **${fw.charAt(0).toUpperCase() + fw.slice(1)}:** ${note}`)
|
|
406
|
+
.join("\n")}`
|
|
407
|
+
: null;
|
|
408
|
+
|
|
409
|
+
const cmsBlock =
|
|
410
|
+
f.cmsNotes && typeof f.cmsNotes === "object"
|
|
411
|
+
? `#### CMS Notes\n${Object.entries(f.cmsNotes)
|
|
412
|
+
.map(([cms, note]) => `- **${cms.charAt(0).toUpperCase() + cms.slice(1)}:** ${note}`)
|
|
413
|
+
.join("\n")}`
|
|
414
|
+
: null;
|
|
415
|
+
|
|
416
|
+
const relatedBlock =
|
|
417
|
+
Array.isArray(f.relatedRules) && f.relatedRules.length > 0
|
|
418
|
+
? `**Fixing this also helps:**\n${f.relatedRules.map((r) => `- \`${r.id}\` — ${r.reason}`).join("\n")}`
|
|
419
|
+
: null;
|
|
420
|
+
|
|
421
|
+
const contrastDiagnosticsBlock = (() => {
|
|
422
|
+
if (!["color-contrast", "color-contrast-enhanced"].includes(f.ruleId)) return null;
|
|
423
|
+
const d = f.checkData;
|
|
424
|
+
if (!d || !d.fgColor) return null;
|
|
425
|
+
const ratio = d.contrastRatio ?? d.contrast ?? "?";
|
|
426
|
+
const expected = d.expectedContrastRatio ?? "4.5:1";
|
|
427
|
+
const rows = [
|
|
428
|
+
`| Foreground | \`${d.fgColor}\` |`,
|
|
429
|
+
`| Background | \`${d.bgColor ?? "unknown"}\` |`,
|
|
430
|
+
`| Measured ratio | **${ratio}:1** |`,
|
|
431
|
+
`| Required ratio | **${expected}** |`,
|
|
432
|
+
d.fontSize ? `| Font | ${d.fontSize} · ${d.fontWeight ?? "normal"} weight |` : null,
|
|
433
|
+
].filter(Boolean).join("\n");
|
|
434
|
+
return `#### Contrast Diagnostics\n| Property | Value |\n|---|---|\n${rows}`;
|
|
435
|
+
})();
|
|
436
|
+
|
|
437
|
+
const managedBlock = f.managedByLibrary
|
|
438
|
+
? `> **Managed Component:** Controlled by \`${f.managedByLibrary}\` — fix via the library's prop API, not direct DOM attributes.`
|
|
439
|
+
: null;
|
|
440
|
+
const ownershipBlock =
|
|
441
|
+
f.ownershipStatus === "outside_primary_source"
|
|
442
|
+
? `> **Ownership Check Required:** ${f.ownershipReason}\n> Ask the user whether to ignore this issue or handle it outside the primary source before editing.`
|
|
443
|
+
: f.ownershipStatus === "unknown"
|
|
444
|
+
? `> **Ownership Unclear:** ${f.ownershipReason}\n> Ask the user whether to ignore this issue until the editable source is confirmed.`
|
|
445
|
+
: null;
|
|
446
|
+
|
|
447
|
+
const verifyBlock = f.verificationCommand
|
|
448
|
+
? `**Quick verify:** \`${f.verificationCommand}\``
|
|
449
|
+
: null;
|
|
450
|
+
|
|
451
|
+
const id = f.id || f.ruleId;
|
|
452
|
+
const requiresManualVerification = f.falsePositiveRisk && f.falsePositiveRisk !== "low";
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
const guardrailsBlock =
|
|
456
|
+
f.guardrails && typeof f.guardrails === "object"
|
|
457
|
+
? [
|
|
458
|
+
Array.isArray(f.guardrails.must) && f.guardrails.must.length > 0
|
|
459
|
+
? `#### Preconditions\n${f.guardrails.must.map((g) => `- ${g}`).join("\n")}`
|
|
460
|
+
: null,
|
|
461
|
+
Array.isArray(f.guardrails.must_not) && f.guardrails.must_not.length > 0
|
|
462
|
+
? `#### Do Not Apply\n${f.guardrails.must_not.map((g) => `- ${g}`).join("\n")}`
|
|
463
|
+
: null,
|
|
464
|
+
Array.isArray(f.guardrails.verify) && f.guardrails.verify.length > 0
|
|
465
|
+
? `#### Post-Fix Checks\n${f.guardrails.verify.map((g) => `- ${g}`).join("\n")}`
|
|
466
|
+
: null,
|
|
467
|
+
].filter(Boolean).join("\n\n")
|
|
468
|
+
: null;
|
|
469
|
+
|
|
470
|
+
return [
|
|
471
|
+
`---`,
|
|
472
|
+
`### ID: ${id} · ${f.severity} · \`${f.title}\``,
|
|
473
|
+
``,
|
|
474
|
+
`- **WCAG Criterion:** ${f.wcag}`,
|
|
475
|
+
f.category ? `- **Category:** ${f.category}` : null,
|
|
476
|
+
requiresManualVerification ? `- **False Positive Risk:** ${f.falsePositiveRisk} — verify before applying` : null,
|
|
477
|
+
``,
|
|
478
|
+
crossPageBlock,
|
|
479
|
+
managedBlock,
|
|
480
|
+
ownershipBlock,
|
|
481
|
+
crossPageBlock || managedBlock || ownershipBlock ? `` : null,
|
|
482
|
+
`**Observed Violation:** ${formatViolation(f.actual)}`,
|
|
483
|
+
contrastDiagnosticsBlock ? `` : null,
|
|
484
|
+
contrastDiagnosticsBlock,
|
|
485
|
+
``,
|
|
486
|
+
fixBlock,
|
|
487
|
+
guardrailsBlock ? `` : null,
|
|
488
|
+
guardrailsBlock,
|
|
489
|
+
difficultyBlock ? `` : null,
|
|
490
|
+
difficultyBlock,
|
|
491
|
+
frameworkBlock ? `` : null,
|
|
492
|
+
frameworkBlock,
|
|
493
|
+
cmsBlock ? `` : null,
|
|
494
|
+
cmsBlock,
|
|
495
|
+
evidenceHtml ? `` : null,
|
|
496
|
+
evidenceHtml ? `${evidenceLabel}\n${evidenceHtml}` : null,
|
|
497
|
+
relatedBlock ? `` : null,
|
|
498
|
+
relatedBlock,
|
|
499
|
+
verifyBlock ? `` : null,
|
|
500
|
+
verifyBlock,
|
|
501
|
+
]
|
|
502
|
+
.filter((line) => line !== null)
|
|
503
|
+
.join("\n");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function findingsByPage(severities) {
|
|
507
|
+
const filtered = wcagFindings.filter((f) => severities.includes(f.severity));
|
|
508
|
+
if (filtered.length === 0) return "";
|
|
509
|
+
|
|
510
|
+
const pages = new Set();
|
|
511
|
+
for (const f of filtered) pages.add(f.area);
|
|
512
|
+
|
|
513
|
+
return Array.from(pages)
|
|
514
|
+
.sort()
|
|
515
|
+
.map((page) => {
|
|
516
|
+
const pageFindings = filtered.filter((f) => f.area === page);
|
|
517
|
+
return `## [PAGE] ${page || "/"}\n\n${pageFindings.map(findingToMd).join("\n\n")}`;
|
|
518
|
+
})
|
|
519
|
+
.join("\n\n");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const blockers = findingsByPage(["Critical", "Serious"]);
|
|
523
|
+
const deferred = findingsByPage(["Moderate", "Minor"]);
|
|
524
|
+
|
|
525
|
+
function buildComponentMap() {
|
|
526
|
+
const isLikelyUtilityHint = (hint) => {
|
|
527
|
+
if (!hint || hint === "other") return true;
|
|
528
|
+
if (hint.length <= 3) return true;
|
|
529
|
+
if (/^(p|px|py|pt|pr|pb|pl|m|mx|my|mt|mr|mb|ml|w|h|min|max|gap|space|text|bg|border|rounded|shadow|opacity|z|top|right|bottom|left|flex|grid|items|justify|content|self)-/i.test(hint)) return true;
|
|
530
|
+
if (/^\d/.test(hint)) return true;
|
|
531
|
+
return false;
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const groups = {};
|
|
535
|
+
for (const f of wcagFindings) {
|
|
536
|
+
const hint = isLikelyUtilityHint(f.componentHint)
|
|
537
|
+
? "source-not-resolved"
|
|
538
|
+
: normalizeComponentHint(f.componentHint);
|
|
539
|
+
if (!groups[hint]) groups[hint] = [];
|
|
540
|
+
groups[hint].push(f);
|
|
541
|
+
}
|
|
542
|
+
const sorted = Object.entries(groups).sort(
|
|
543
|
+
(a, b) => b[1].length - a[1].length,
|
|
544
|
+
);
|
|
545
|
+
if (sorted.length <= 1) return "";
|
|
546
|
+
const rows = sorted.map(([component, items]) => {
|
|
547
|
+
const severities = [...new Set(items.map((i) => i.severity))].join(", ");
|
|
548
|
+
const rules = [...new Set(items.map((i) => i.ruleId))].join(", ");
|
|
549
|
+
return `| \`${component}\` | ${items.length} | ${severities} | ${rules} |`;
|
|
550
|
+
});
|
|
551
|
+
return `## Fixes by Component
|
|
552
|
+
|
|
553
|
+
| Component | Issues | Severities | Rules |
|
|
554
|
+
|---|---|---|---|
|
|
555
|
+
${rows.join("\n")}
|
|
556
|
+
`;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const incompleteSection = buildIncompleteSection(metadata.incomplete_findings);
|
|
560
|
+
const patternSection = buildPatternSection(metadata.pattern_findings);
|
|
561
|
+
const passedCriteriaSection = buildPassedCriteriaSection(metadata.passedCriteria);
|
|
562
|
+
const sourceBoundariesSection = buildSourceBoundariesSection(framework);
|
|
563
|
+
|
|
564
|
+
return (
|
|
565
|
+
`# Accessibility Remediation Guide — WCAG 2.2 AA
|
|
566
|
+
> **Base URL:** ${args.baseUrl || "N/A"}
|
|
567
|
+
|
|
568
|
+
| Severity | Count |
|
|
569
|
+
|---|---|
|
|
570
|
+
| Critical | ${totals.Critical} |
|
|
571
|
+
| Serious | ${totals.Serious} |
|
|
572
|
+
| Moderate | ${totals.Moderate} |
|
|
573
|
+
| Minor | ${totals.Minor} |
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
## Agent Operating Procedures (Guardrails)
|
|
578
|
+
|
|
579
|
+
${buildGuardrails(framework)}
|
|
580
|
+
|
|
581
|
+
---
|
|
582
|
+
${sourceBoundariesSection ? `\n${sourceBoundariesSection}\n---` : ""}
|
|
583
|
+
|
|
584
|
+
${buildComponentMap()}
|
|
585
|
+
${buildRecommendationsSection(metadata.recommendations)}
|
|
586
|
+
${buildExecutionOrderSection()}
|
|
587
|
+
${blockers ? `## Priority Fixes (Critical and Serious)\n\n${blockers}` : "## Priority Fixes\n\nNo critical or serious severity issues found."}
|
|
588
|
+
${deferred ? `\n## Deferred Issues (Moderate and Minor)\n\n${deferred}` : ""}
|
|
589
|
+
${incompleteSection ? `\n${incompleteSection}` : ""}
|
|
590
|
+
${patternSection ? `\n${patternSection}` : ""}
|
|
591
|
+
${passedCriteriaSection ? `\n${passedCriteriaSection}` : ""}
|
|
592
|
+
`
|
|
593
|
+
.trimEnd() + "\n"
|
|
594
|
+
);
|
|
595
|
+
}
|