@diegovelasquezweb/a11y-engine 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegovelasquezweb/a11y-engine",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "WCAG 2.2 AA accessibility audit engine — scanner, analyzer, and report builders",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,8 +58,33 @@ export interface EnrichedFinding extends Finding {
58
58
  sourceRuleId: string | null;
59
59
  fixDescription: string | null;
60
60
  fixCode: string | null;
61
+ fixCodeLang: string | null;
61
62
  falsePositiveRisk: string | null;
62
63
  fixDifficultyNotes: string | string[] | null;
64
+ screenshotPath: string | null;
65
+ wcagCriterionId: string | null;
66
+ wcagClassification: string | null;
67
+ impactedUsers: string | null;
68
+ primarySelector: string;
69
+ primaryFailureMode: string | null;
70
+ relationshipHint: string | null;
71
+ failureChecks: unknown[];
72
+ relatedContext: unknown[];
73
+ recommendedFix: string;
74
+ totalInstances: number | null;
75
+ relatedRules: string[];
76
+ ownershipStatus: string;
77
+ ownershipReason: string | null;
78
+ primarySourceScope: string[];
79
+ searchStrategy: string;
80
+ managedByLibrary: string | null;
81
+ componentHint: string | null;
82
+ verificationCommand: string | null;
83
+ verificationCommandFallback: string | null;
84
+ checkData: Record<string, unknown> | null;
85
+ pagesAffected: number | null;
86
+ affectedUrls: string[] | null;
87
+ effort: string;
63
88
  }
64
89
 
65
90
  export interface SeverityTotals {
@@ -69,18 +94,30 @@ export interface SeverityTotals {
69
94
  Minor: number;
70
95
  }
71
96
 
72
- export interface ComplianceScore {
73
- score: number;
74
- label: string;
75
- wcagStatus: "Pass" | "Conditional Pass" | "Fail";
76
- }
77
-
78
97
  export interface PersonaGroup {
79
98
  label: string;
80
99
  count: number;
81
100
  icon: string;
82
101
  }
83
102
 
103
+ export interface DetectedStack {
104
+ framework: string | null;
105
+ cms: string | null;
106
+ uiLibraries: string[];
107
+ }
108
+
109
+ export interface AuditSummary {
110
+ totals: SeverityTotals;
111
+ score: number;
112
+ label: string;
113
+ wcagStatus: "Pass" | "Conditional Pass" | "Fail";
114
+ personaGroups: Record<string, PersonaGroup>;
115
+ quickWins: EnrichedFinding[];
116
+ targetUrl: string;
117
+ detectedStack: DetectedStack;
118
+ totalFindings: number;
119
+ }
120
+
84
121
  // ---------------------------------------------------------------------------
85
122
  // Report types
86
123
  // ---------------------------------------------------------------------------
@@ -106,17 +143,26 @@ export interface ChecklistReport {
106
143
  }
107
144
 
108
145
  // ---------------------------------------------------------------------------
109
- // Public API
146
+ // Enrichment options
110
147
  // ---------------------------------------------------------------------------
111
148
 
112
- export function getEnrichedFindings(findings: Finding[]): EnrichedFinding[];
113
- export function getEnrichedFindings(findings: Record<string, unknown>[]): EnrichedFinding[];
149
+ export interface EnrichmentOptions {
150
+ screenshotUrlBuilder?: (rawPath: string) => string;
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Public API
155
+ // ---------------------------------------------------------------------------
114
156
 
115
- export function getComplianceScore(totals: SeverityTotals): ComplianceScore;
157
+ export function getEnrichedFindings(
158
+ input: ScanPayload | Finding[] | Record<string, unknown>[],
159
+ options?: EnrichmentOptions
160
+ ): EnrichedFinding[];
116
161
 
117
- export function getPersonaGroups(
118
- findings: EnrichedFinding[] | Record<string, unknown>[]
119
- ): Record<string, PersonaGroup>;
162
+ export function getAuditSummary(
163
+ findings: EnrichedFinding[],
164
+ payload?: ScanPayload | null
165
+ ): AuditSummary;
120
166
 
121
167
  export function getPDFReport(
122
168
  payload: ScanPayload,
package/scripts/index.mjs CHANGED
@@ -42,31 +42,9 @@ function getWcagReference() {
42
42
  }
43
43
 
44
44
  // ---------------------------------------------------------------------------
45
- // Assets access
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,66 +80,203 @@ 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
- * Enriches findings with intelligence data (fix descriptions, fix code, category).
118
- * Each finding should have at minimum: { ruleId, sourceRuleId?, checkData?, fixDescription?, fixCode? }
119
- * @param {object[]} findings - Array of findings with camelCase keys.
120
- * @returns {object[]} Enriched findings.
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(findings) {
174
+ export function getEnrichedFindings(input, options = {}) {
175
+ const { screenshotUrlBuilder = null } = options;
123
176
  const rules = getIntelligence().rules || {};
124
177
 
125
- return findings.map((finding) => {
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.ruleId || finding.rule_id || "",
128
- finding.sourceRuleId || finding.source_rule_id || null,
129
- finding.checkData || finding.check_data || null,
189
+ finding.rule_id,
190
+ finding.source_rule_id,
191
+ finding.check_data,
130
192
  );
131
193
 
132
- const normalized = {
194
+ // Effort will be inferred after enrichment
195
+
196
+ // Always create camelCase aliases
197
+ const withAliases = {
133
198
  ...finding,
134
199
  ruleId: canonical,
135
200
  rule_id: canonical,
136
- sourceRuleId: finding.sourceRuleId || finding.source_rule_id || finding.ruleId || finding.rule_id || null,
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,
137
230
  };
138
231
 
139
- if (normalized.fixDescription || normalized.fix_description ||
140
- normalized.fixCode || normalized.fix_code) {
141
- return normalized;
232
+ // If fix data already exists, no need to look up intelligence
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
+ }
142
254
  }
143
255
 
144
- const info = rules[canonical];
145
- if (!info) return normalized;
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
+ }
146
260
 
147
- return {
148
- ...normalized,
149
- category: normalized.category ?? info.category ?? null,
150
- fixDescription: info.fix?.description ?? null,
151
- fix_description: info.fix?.description ?? null,
152
- fixCode: info.fix?.code ?? null,
153
- fix_code: info.fix?.code ?? null,
154
- falsePositiveRisk: normalized.falsePositiveRisk ?? normalized.false_positive_risk ?? info.false_positive_risk ?? null,
155
- false_positive_risk: normalized.false_positive_risk ?? info.false_positive_risk ?? null,
156
- fixDifficultyNotes: normalized.fixDifficultyNotes ?? normalized.fix_difficulty_notes ?? info.fix_difficulty_notes ?? null,
157
- fix_difficulty_notes: normalized.fix_difficulty_notes ?? info.fix_difficulty_notes ?? null,
158
- };
261
+ return result;
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);
159
270
  });
271
+
272
+ return enriched;
160
273
  }
161
274
 
162
275
  // ---------------------------------------------------------------------------
163
- // Score computation
276
+ // Score computation (internal)
164
277
  // ---------------------------------------------------------------------------
165
278
 
166
- /**
167
- * Computes compliance score, grade label, and WCAG pass/fail status.
168
- * @param {{ Critical: number, Serious: number, Moderate: number, Minor: number }} totals
169
- * @returns {{ score: number, label: string, wcagStatus: "Pass" | "Conditional Pass" | "Fail" }}
170
- */
171
- export function getComplianceScore(totals) {
279
+ function getComplianceScore(totals) {
172
280
  const config = getComplianceConfig();
173
281
  const penalties = config.complianceScore.penalties;
174
282
  const thresholds = config.gradeThresholds;
@@ -198,15 +306,10 @@ export function getComplianceScore(totals) {
198
306
  }
199
307
 
200
308
  // ---------------------------------------------------------------------------
201
- // Persona grouping
309
+ // Persona grouping (internal)
202
310
  // ---------------------------------------------------------------------------
203
311
 
204
- /**
205
- * Groups findings by accessibility persona (screen reader, keyboard, cognitive, etc.).
206
- * @param {object[]} findings - Array of findings with ruleId, wcagCriterionId, impactedUsers.
207
- * @returns {Record<string, { label: string, count: number, icon: string }>}
208
- */
209
- export function getPersonaGroups(findings) {
312
+ function getPersonaGroups(findings) {
210
313
  const ref = getWcagReference();
211
314
  const personaConfig = ref.personaConfig || {};
212
315
  const personaMapping = ref.personaMapping || {};
@@ -261,6 +364,66 @@ export function getPersonaGroups(findings) {
261
364
  return groups;
262
365
  }
263
366
 
367
+ // ---------------------------------------------------------------------------
368
+ // Audit summary
369
+ // ---------------------------------------------------------------------------
370
+
371
+ /**
372
+ * Computes a complete audit summary from enriched findings: severity totals,
373
+ * compliance score, grade label, WCAG status, persona groups, quick wins,
374
+ * target URL, and detected stack.
375
+ *
376
+ * @param {object[]} findings - Array of enriched findings.
377
+ * @param {{ findings: object[], metadata?: object }|null} [payload=null] - Original scan payload for metadata extraction.
378
+ * @returns {object} Full audit summary.
379
+ */
380
+ export function getAuditSummary(findings, payload = null) {
381
+ const totals = { Critical: 0, Serious: 0, Moderate: 0, Minor: 0 };
382
+ for (const f of findings) {
383
+ const severity = f.severity || "";
384
+ if (severity in totals) totals[severity] += 1;
385
+ }
386
+
387
+ const { score, label, wcagStatus } = getComplianceScore(totals);
388
+ const personaGroups = getPersonaGroups(findings);
389
+
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
+
264
427
  // ---------------------------------------------------------------------------
265
428
  // Report generation
266
429
  // ---------------------------------------------------------------------------
@@ -276,10 +439,9 @@ import {
276
439
 
277
440
  /**
278
441
  * Generates a PDF report buffer from raw scan findings.
279
- * Requires Playwright (chromium) to render the PDF.
280
- * @param {{ findings: object[], metadata?: object }} payload - Raw scan output (snake_case keys).
442
+ * @param {{ findings: object[], metadata?: object }} payload
281
443
  * @param {{ baseUrl?: string, target?: string }} [options={}]
282
- * @returns {Promise<Buffer>} The PDF as a Node.js Buffer.
444
+ * @returns {Promise<{ buffer: Buffer, contentType: "application/pdf" }>}
283
445
  */
284
446
  export async function getPDFReport(payload, options = {}) {
285
447
  const { chromium } = await import("playwright");
@@ -345,9 +507,8 @@ ${buildPdfAuditLimitations()}
345
507
 
346
508
  /**
347
509
  * Generates a standalone manual accessibility checklist HTML string.
348
- * Does not depend on scan results — reads from manual-checks.json asset.
349
510
  * @param {{ baseUrl?: string }} [options={}]
350
- * @returns {Promise<string>} The complete checklist HTML document.
511
+ * @returns {Promise<{ html: string, contentType: "text/html" }>}
351
512
  */
352
513
  export async function getChecklist(options = {}) {
353
514
  const { buildManualCheckCard } = await import("./reports/renderers/html.mjs");
@@ -365,9 +526,6 @@ export async function getChecklist(options = {}) {
365
526
  const selectClasses =
366
527
  "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";
367
528
 
368
- // Import the full checklist builder to reuse its buildHtml
369
- // The checklist builder module has a main() that auto-runs, so we dynamically
370
- // construct the same output using the renderer functions directly.
371
529
  const html = `<!doctype html>
372
530
  <html lang="en">
373
531
  <head>