Spaces:
Sleeping
Sleeping
| import crypto from 'node:crypto'; | |
| import { resolveWcag, classifyFinding, severityFor } from './wcag.js'; | |
| import { impactFor, estimateExcluded, pct } from './fpc.js'; | |
| import { remediationTip } from './remediation.js'; | |
| import { rulePlainLabel } from './rule-label.js'; | |
| /** | |
| * Turn the weekly per-rule summary into structured accessibility bug | |
| * reports following ACCESSIBILITY_BUG_REPORTING_BEST_PRACTICES. | |
| * | |
| * Scope is one report per failing rule (the scanner aggregates weekly, | |
| * not per instance), carrying frequency counts and a few representative | |
| * example instances with real DOM context. | |
| * | |
| * Truthfulness: fields the engines observe (url, selector, snippet, | |
| * rule, WCAG SC, severity, frequency) are populated. Fields automated | |
| * scanning cannot observe (testing environment, affected disability | |
| * groups, manual steps to reproduce) are emitted with explicit | |
| * "requires manual testing" placeholders rather than invented values. | |
| */ | |
| const PREFIX = 'VS'; // vital-scans bug id prefix | |
| const PLACEHOLDER = 'Not captured by automated scan — requires manual testing.'; | |
| function shortHash(...parts) { | |
| return crypto.createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 8); | |
| } | |
| /** Derive a short human label from scanner metadata, with rule-id fallback. */ | |
| function componentLabel(engineKey, ruleId, help, wcag) { | |
| const label = rulePlainLabel(engineKey, ruleId, { help, wcag }); | |
| if (!label) return ruleId; | |
| return label.replace(/\s+/g, ' ').trim().slice(0, 80); | |
| } | |
| /** | |
| * Build the list of bug-report objects for a domain/week. | |
| * Returns [] when there are no findings. | |
| */ | |
| export function buildBugReports(target, summary) { | |
| const reports = []; | |
| const total = summary.pagesScanned; | |
| const fromRule = (engine, toolName, ruleId, rule) => { | |
| const wcag = resolveWcag(engine, { tags: rule.tags, ruleId }); | |
| const wcag_category = classifyFinding(engine, { tags: rule.tags, ruleId }, wcag); | |
| const severity = severityFor(rule.impact ?? null, rule.pages, total); | |
| const patternId = `${PREFIX}-${shortHash(engine, ruleId)}`; | |
| const first = rule.instances?.[0]; | |
| const instanceId = `${PREFIX}-${shortHash(patternId, first?.url ?? '', first?.target ?? '')}`; | |
| const scLabel = wcag ? `WCAG ${wcag.sc}` : 'WCAG criterion undetermined'; | |
| const component = componentLabel(engine, ruleId, rule.help, wcag); | |
| // Human impact: disability groups affected (via WCAG SC -> Section 508 | |
| // FPC) with US prevalence. If the target supplies page_loads_per_week, | |
| // also estimate excluded users, scaled by the share of pages affected. | |
| const fpc = wcag ? impactFor(wcag.sc) : null; | |
| const loads = target.page_loads_per_week; | |
| const affectedShare = total > 0 ? rule.pages / total : 0; | |
| const impact = fpc | |
| ? { | |
| groups: fpc.groups.map((g) => { | |
| const excluded = | |
| loads ? estimateExcluded(g.prevalence, loads * affectedShare) : null; | |
| return { code: g.code, group: g.group, prevalence: g.prevalence, percent: pct(g.prevalence), estimatedExcluded: excluded }; | |
| }), | |
| summary: | |
| 'Affects ' + | |
| fpc.groups.map((g) => `${g.group} (${pct(g.prevalence)})`).join(', ') + | |
| '.', | |
| } | |
| : { groups: [], summary: PLACEHOLDER }; | |
| return { | |
| instance_id: instanceId, | |
| pattern_id: patternId, | |
| url: first?.url ?? summary.domain, | |
| xpath: first?.target ?? null, | |
| html_snippet: first?.html ?? null, | |
| wcag_sc: wcag?.sc ?? null, | |
| wcag_name: wcag?.name ?? null, | |
| wcag_level: wcag?.level ?? null, | |
| wcag_version: wcag?.wcag_version ?? null, | |
| wcag_category, | |
| rule_id: ruleId, | |
| rule_label: component, | |
| engine_key: engine, // 'axe-core' | 'alfa' | 'deprecated-html' (stable; for CSV lookup) | |
| tool: toolName, | |
| rule_url: rule.helpUrl ?? rule.ruleUrl ?? null, | |
| severity, | |
| frequency: { | |
| instances: rule.count, | |
| pages_affected: rule.pages, | |
| total_pages_scanned: total, | |
| }, | |
| summary: `${component} (${scLabel})`, | |
| description: `${component}. Detected by ${toolName} rule ${ruleId} on ${rule.pages} of ${total} scanned pages (${rule.count} instances).`, | |
| // Capped representative instances with real DOM context. | |
| examples: (rule.instances ?? []).map((i) => ({ | |
| url: i.url, | |
| xpath: i.target ?? null, | |
| html_snippet: i.html ?? null, | |
| })), | |
| example_pages: rule.examplePages ?? [], | |
| // Up to 25 affected-page URLs for inline listing in the report; the | |
| // CSV (set later) holds the complete set when there are more. | |
| affected_pages: (rule.affectedPages ?? []).slice(0, 25).map((p) => p.url), | |
| // Human impact derived from the WCAG SC (Section 508 FPC + US | |
| // prevalence). Empty groups => undetermined SC; falls back to the | |
| // manual-testing note. Severity/exact reproduction still need a human. | |
| impact, | |
| testing_environment: `Automated: ${toolName}, headless Chromium (Playwright). Manual AT verification: ${PLACEHOLDER}`, | |
| steps_to_reproduce: [ | |
| `Open ${first?.url ?? 'an affected page (see example pages)'}.`, | |
| first?.target ? `Locate the element: ${first.target}` : 'Locate the affected element.', | |
| `Confirm the ${toolName} finding for rule ${ruleId}${wcag ? ` against ${scLabel} ${wcag.name}` : ''}.`, | |
| ], | |
| remediation_tip: remediationTip(engine, ruleId), | |
| suggested_fix: rule.helpUrl ?? rule.ruleUrl | |
| ? `See remediation guidance: ${rule.helpUrl ?? rule.ruleUrl}` | |
| : PLACEHOLDER, | |
| }; | |
| }; | |
| for (const [id, rule] of Object.entries(summary.axe?.rules ?? {})) { | |
| reports.push(fromRule('axe-core', `axe-core${summary.axe?.version ? ' ' + summary.axe.version : ''}`, id, rule)); | |
| } | |
| for (const [id, rule] of Object.entries(summary.alfa?.rules ?? {})) { | |
| reports.push(fromRule('alfa', 'Siteimprove Alfa', id, rule)); | |
| } | |
| for (const [id, rule] of Object.entries(summary.deprecatedHtml?.rules ?? {})) { | |
| reports.push(fromRule('deprecated-html', 'deprecated-html', id, rule)); | |
| } | |
| // Sort order for engineers: WCAG 2.2 AA requirements first (the primary | |
| // compliance target), then 2.1 additions, then 2.0 baseline, then AAA, | |
| // then Best Practice (not a WCAG requirement), then undetermined. | |
| // Within each category: by WCAG SC number so axe + alfa for the same | |
| // criterion are adjacent, then severity, then pages affected. | |
| const catRank = { | |
| 'WCAG 2.2 A': 0, 'WCAG 2.2 AA': 1, | |
| 'WCAG 2.1 A': 2, 'WCAG 2.1 AA': 3, | |
| 'WCAG 2.0 A': 4, 'WCAG 2.0 AA': 5, | |
| 'WCAG 2.x AAA': 6, | |
| 'Best Practice': 7, | |
| 'Undetermined': 8, | |
| }; | |
| const sevRank = { Critical: 0, High: 1, Medium: 2, Low: 3 }; | |
| reports.sort( | |
| (a, b) => | |
| (catRank[a.wcag_category ?? 'Undetermined'] ?? 8) - (catRank[b.wcag_category ?? 'Undetermined'] ?? 8) || | |
| (a.wcag_sc ?? 'zzz').localeCompare(b.wcag_sc ?? 'zzz') || | |
| sevRank[a.severity] - sevRank[b.severity] || | |
| b.frequency.pages_affected - a.frequency.pages_affected | |
| ); | |
| // Duplicate detection: when axe and alfa both flag the same WCAG SC on | |
| // overlapping pages, the alfa report is likely a duplicate of the axe one | |
| // (axe is the canonical source for engineers since it ships remediation | |
| // tips). Mark the alfa (or second-engine) report so it can be filtered | |
| // in spreadsheets and de-prioritised in JIRA. | |
| const axeBySc = new Map(); // wcag_sc -> first axe bug that covers it | |
| for (const r of reports) { | |
| if (r.engine_key === 'axe-core' && r.wcag_sc) axeBySc.set(r.wcag_sc, r); | |
| } | |
| for (const r of reports) { | |
| if (r.engine_key !== 'axe-core' && r.wcag_sc && axeBySc.has(r.wcag_sc)) { | |
| const axeMatch = axeBySc.get(r.wcag_sc); | |
| // Only flag when the page-overlap is meaningful (>50% of the smaller | |
| // set) — avoids false positives on rules with very different scope. | |
| const smaller = Math.min(r.frequency.pages_affected, axeMatch.frequency.pages_affected); | |
| const larger = Math.max(r.frequency.pages_affected, axeMatch.frequency.pages_affected); | |
| if (smaller > 0 && smaller / larger >= 0.5) { | |
| r.possible_duplicate_of = axeMatch.instance_id; | |
| r.possible_duplicate_pattern = axeMatch.pattern_id; | |
| } | |
| } | |
| } | |
| return reports; | |
| } | |
| /** Render a single bug report as Markdown matching the guide's template. */ | |
| export function bugReportToMarkdown(r) { | |
| const lines = []; | |
| lines.push(`## ${r.summary}`); | |
| lines.push(''); | |
| lines.push(`**Bug ID:** \`${r.instance_id}\` `); | |
| lines.push(`**Pattern ID:** \`${r.pattern_id}\` `); | |
| lines.push(`**URL:** ${r.url} `); | |
| lines.push(`**XPath / selector:** ${r.xpath ? `\`${r.xpath}\`` : '_n/a_'} `); | |
| lines.push( | |
| `**WCAG SC:** ${r.wcag_sc ? `${r.wcag_sc} — ${r.wcag_name} (Level ${r.wcag_level})` : '_undetermined_'} ` | |
| ); | |
| lines.push(`**Rule:** ${r.tool} — \`${r.rule_id}\`${r.rule_url ? ` ([reference](${r.rule_url}))` : ''} `); | |
| lines.push(`**Severity:** ${r.severity} `); | |
| lines.push( | |
| `**Frequency:** ${r.frequency.instances} instances; ${r.frequency.pages_affected} of ${r.frequency.total_pages_scanned} pages affected` | |
| ); | |
| if (r.first_seen) { | |
| lines.push( | |
| ` \n**History:** first seen ${r.first_seen}, last seen ${r.last_seen} (${r.weeks_seen} week${r.weeks_seen === 1 ? '' : 's'})` | |
| ); | |
| } | |
| lines.push(''); | |
| if (r.html_snippet) { | |
| lines.push('### HTML snippet'); | |
| lines.push('```html'); | |
| lines.push(r.html_snippet); | |
| lines.push('```'); | |
| lines.push(''); | |
| } | |
| lines.push('### Description'); | |
| lines.push(r.description); | |
| lines.push(''); | |
| lines.push('### Steps to reproduce'); | |
| r.steps_to_reproduce.forEach((s, i) => lines.push(`${i + 1}. ${s}`)); | |
| lines.push(''); | |
| lines.push('### Impact'); | |
| lines.push(r.impact.summary); | |
| for (const g of r.impact.groups ?? []) { | |
| const excl = g.estimatedExcluded != null ? ` — ~${g.estimatedExcluded.toLocaleString()} people/week potentially excluded` : ''; | |
| lines.push(`- **${g.group}** (${g.percent} of population)${excl}`); | |
| } | |
| lines.push(''); | |
| lines.push('### Affected pages'); | |
| // <=25 affected pages: list the URLs inline (more useful than a CSV link | |
| // for a handful). >25: list the first 25, then link the full CSV. | |
| const total = r.frequency.pages_affected; | |
| const urls = r.affected_pages ?? []; | |
| if (total <= 25 && urls.length >= total) { | |
| urls.forEach((u) => lines.push(`- ${u}`)); | |
| } else { | |
| urls.slice(0, 25).forEach((u) => lines.push(`- ${u}`)); | |
| lines.push('', r.affected_pages_csv | |
| ? `…and more — ${total} pages total ([download CSV](${r.affected_pages_csv})).` | |
| : `…and more — ${total} pages total.`); | |
| } | |
| lines.push(''); | |
| lines.push('### Testing environment'); | |
| lines.push(r.testing_environment); | |
| lines.push(''); | |
| lines.push('### Suggested fix'); | |
| if (r.remediation_tip) lines.push(`**How to fix:** ${r.remediation_tip}`, ''); | |
| lines.push(r.suggested_fix); | |
| if (r.example_pages.length > 1) { | |
| lines.push(''); | |
| lines.push('### Example pages'); | |
| r.example_pages.forEach((u) => lines.push(`- ${u}`)); | |
| } | |
| return lines.join('\n'); | |
| } | |
| /** Full Markdown document for a domain/week. */ | |
| export function bugReportsMarkdown(target, summary, reports) { | |
| const head = [ | |
| `# Accessibility bug reports — ${target.domain}, week ${summary.week}`, | |
| '', | |
| `${reports.length} issue type(s) from ${summary.pagesScanned} pages scanned. ` + | |
| `Generated ${summary.generatedAt.slice(0, 10)} by ` + | |
| `[vital-scans](https://github.com/mgifford/vital-core), following ` + | |
| `[accessibility bug-reporting best practices](https://mgifford.github.io/ACCESSIBILITY.md/examples/ACCESSIBILITY_BUG_REPORTING_BEST_PRACTICES.html).`, | |
| '', | |
| '> Automated checks find roughly a third of accessibility barriers. ' + | |
| 'Fields marked "requires manual testing" cannot be observed by an ' + | |
| 'automated scan and need a human with assistive technology.', | |
| '', | |
| '---', | |
| '', | |
| ]; | |
| if (reports.length === 0) { | |
| return head.join('\n') + '\nNo accessibility findings this week.\n'; | |
| } | |
| return head.join('\n') + reports.map(bugReportToMarkdown).join('\n\n---\n\n') + '\n'; | |
| } | |