@diegovelasquezweb/a11y-engine 0.1.8 → 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.8",
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",
@@ -63,7 +63,28 @@ export interface EnrichedFinding extends Finding {
63
63
  fixDifficultyNotes: string | string[] | null;
64
64
  screenshotPath: string | null;
65
65
  wcagCriterionId: string | null;
66
+ wcagClassification: string | null;
66
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;
67
88
  }
68
89
 
69
90
  export interface SeverityTotals {
@@ -79,12 +100,22 @@ export interface PersonaGroup {
79
100
  icon: string;
80
101
  }
81
102
 
103
+ export interface DetectedStack {
104
+ framework: string | null;
105
+ cms: string | null;
106
+ uiLibraries: string[];
107
+ }
108
+
82
109
  export interface AuditSummary {
83
110
  totals: SeverityTotals;
84
111
  score: number;
85
112
  label: string;
86
113
  wcagStatus: "Pass" | "Conditional Pass" | "Fail";
87
114
  personaGroups: Record<string, PersonaGroup>;
115
+ quickWins: EnrichedFinding[];
116
+ targetUrl: string;
117
+ detectedStack: DetectedStack;
118
+ totalFindings: number;
88
119
  }
89
120
 
90
121
  // ---------------------------------------------------------------------------
@@ -111,14 +142,27 @@ export interface ChecklistReport {
111
142
  contentType: "text/html";
112
143
  }
113
144
 
145
+ // ---------------------------------------------------------------------------
146
+ // Enrichment options
147
+ // ---------------------------------------------------------------------------
148
+
149
+ export interface EnrichmentOptions {
150
+ screenshotUrlBuilder?: (rawPath: string) => string;
151
+ }
152
+
114
153
  // ---------------------------------------------------------------------------
115
154
  // Public API
116
155
  // ---------------------------------------------------------------------------
117
156
 
118
- export function getEnrichedFindings(findings: Finding[]): EnrichedFinding[];
119
- export function getEnrichedFindings(findings: Record<string, unknown>[]): EnrichedFinding[];
157
+ export function getEnrichedFindings(
158
+ input: ScanPayload | Finding[] | Record<string, unknown>[],
159
+ options?: EnrichmentOptions
160
+ ): EnrichedFinding[];
120
161
 
121
- export function getAuditSummary(findings: EnrichedFinding[] | Record<string, unknown>[]): AuditSummary;
162
+ export function getAuditSummary(
163
+ findings: EnrichedFinding[],
164
+ payload?: ScanPayload | null
165
+ ): AuditSummary;
122
166
 
123
167
  export function getPDFReport(
124
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,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
- * 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
- // Always create camelCase aliases from snake_case fields
133
- const normalized = {
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.sourceRuleId || finding.source_rule_id || finding.ruleId || finding.rule_id || null,
138
- fixDescription: finding.fixDescription ?? finding.fix_description ?? null,
139
- fixCode: finding.fixCode ?? finding.fix_code ?? null,
140
- fixCodeLang: finding.fixCodeLang ?? finding.fix_code_lang ?? null,
141
- falsePositiveRisk: finding.falsePositiveRisk ?? finding.false_positive_risk ?? null,
142
- fixDifficultyNotes: finding.fixDifficultyNotes ?? finding.fix_difficulty_notes ?? null,
143
- screenshotPath: finding.screenshotPath ?? finding.screenshot_path ?? null,
144
- wcagCriterionId: finding.wcagCriterionId ?? finding.wcag_criterion_id ?? null,
145
- impactedUsers: finding.impactedUsers ?? finding.impacted_users ?? 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,
146
230
  };
147
231
 
148
232
  // If fix data already exists, no need to look up intelligence
149
- if (normalized.fixDescription || normalized.fixCode) {
150
- return normalized;
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
- const info = rules[canonical];
154
- 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
+ }
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,58 @@ 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, and persona impact groups.
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
- * @returns {{ totals, score, label, wcagStatus, personaGroups }}
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 || 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
- return { totals, score, label, wcagStatus, personaGroups };
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
+ };
284
425
  }
285
426
 
286
427
  // ---------------------------------------------------------------------------
@@ -298,10 +439,9 @@ import {
298
439
 
299
440
  /**
300
441
  * Generates a PDF report buffer from raw scan findings.
301
- * Requires Playwright (chromium) to render the PDF.
302
- * @param {{ findings: object[], metadata?: object }} payload - Raw scan output (snake_case keys).
442
+ * @param {{ findings: object[], metadata?: object }} payload
303
443
  * @param {{ baseUrl?: string, target?: string }} [options={}]
304
- * @returns {Promise<Buffer>} The PDF as a Node.js Buffer.
444
+ * @returns {Promise<{ buffer: Buffer, contentType: "application/pdf" }>}
305
445
  */
306
446
  export async function getPDFReport(payload, options = {}) {
307
447
  const { chromium } = await import("playwright");
@@ -367,9 +507,8 @@ ${buildPdfAuditLimitations()}
367
507
 
368
508
  /**
369
509
  * Generates a standalone manual accessibility checklist HTML string.
370
- * Does not depend on scan results — reads from manual-checks.json asset.
371
510
  * @param {{ baseUrl?: string }} [options={}]
372
- * @returns {Promise<string>} The complete checklist HTML document.
511
+ * @returns {Promise<{ html: string, contentType: "text/html" }>}
373
512
  */
374
513
  export async function getChecklist(options = {}) {
375
514
  const { buildManualCheckCard } = await import("./reports/renderers/html.mjs");
@@ -387,9 +526,6 @@ export async function getChecklist(options = {}) {
387
526
  const selectClasses =
388
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";
389
528
 
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
529
  const html = `<!doctype html>
394
530
  <html lang="en">
395
531
  <head>