vital-core / src /lib /bug-report.js
vital-core sync
Deploy snapshot for Hugging Face Spaces
50841a0
Raw
History Blame Contribute Delete
12.2 kB
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';
}