#!/usr/bin/env node import { existsSync } from 'node:fs'; import { readdir, readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; const DEFAULT_ROOTS = ['apps/web/src', 'apps/web/index.html']; const EXTENSIONS = new Set(['.tsx', '.jsx', '.html']); const IGNORED_DIRECTORIES = new Set(['node_modules', 'dist', 'build', 'coverage', '.git', '.vite', '.next', 'playwright-report']); const options = parseArgs(process.argv.slice(2)); if (options.help) { console.log(usage()); process.exit(0); } const startedAt = Date.now(); try { const files = await collectFiles(options.roots); const findings = []; for (const file of files) { const source = await readFile(file, 'utf8'); findings.push(...checkFile(file, source)); } const summary = summarize(findings, files, Date.now() - startedAt); if (options.format === 'json') { console.log(JSON.stringify(summary, null, 2)); } else { printTextSummary(summary); } const criticalCount = summary.counts.critical; const warningCount = summary.counts.warning; const shouldFailForWarnings = options.failOnWarning && warningCount > 0; const tooManyWarnings = warningCount > options.maxWarnings; if (criticalCount > 0 || shouldFailForWarnings || tooManyWarnings) { process.exit(1); } } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(2); } function usage() { return ` Fan Passport static accessibility guard Usage: node scripts/qa/a11y-static-check.mjs [options] Options: --root File or directory to scan. Can be repeated. Default: apps/web/src and apps/web/index.html --format Output format. Default: text --max-warnings Fail when warning count is above this value. Default: 25 --fail-on-warning Treat every warning as a failing issue. --help Show this help. This dependency-free checker catches common regressions before the Playwright and manual screen-reader pass: unlabeled buttons, images without alt text, unlabeled form controls, click-only non-semantic controls, positive tabindex, and missing HTML language/viewport metadata. `.trim(); } function parseArgs(args) { const parsed = { roots: [], format: 'text', maxWarnings: 25, failOnWarning: false, help: false }; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; switch (arg) { case '--root': parsed.roots.push(requireValue(args, ++index, arg)); break; case '--format': parsed.format = requireValue(args, ++index, arg); if (!['text', 'json'].includes(parsed.format)) { throw new Error('--format must be either "text" or "json".'); } break; case '--max-warnings': parsed.maxWarnings = parseInteger(requireValue(args, ++index, arg), parsed.maxWarnings); if (parsed.maxWarnings < 0) { throw new Error('--max-warnings must be zero or greater.'); } break; case '--fail-on-warning': parsed.failOnWarning = true; break; case '--help': case '-h': parsed.help = true; break; default: throw new Error(`Unknown option "${arg}". Run with --help for usage.`); } } if (parsed.roots.length === 0) { parsed.roots = DEFAULT_ROOTS; } return parsed; } function requireValue(args, index, optionName) { const value = args[index]; if (!value || value.startsWith('--')) { throw new Error(`${optionName} requires a value.`); } return value; } function parseInteger(value, fallback) { const parsed = Number.parseInt(String(value), 10); return Number.isFinite(parsed) ? parsed : fallback; } async function collectFiles(roots) { const files = []; for (const root of roots) { if (!existsSync(root)) { continue; } const rootStat = await stat(root); if (rootStat.isFile()) { if (EXTENSIONS.has(path.extname(root))) { files.push(root); } continue; } if (rootStat.isDirectory()) { files.push(...await walk(root)); } } return [...new Set(files)].sort(); } async function walk(directory) { const entries = await readdir(directory, { withFileTypes: true }); const files = []; for (const entry of entries) { const fullPath = path.join(directory, entry.name); if (entry.isDirectory()) { if (!IGNORED_DIRECTORIES.has(entry.name)) { files.push(...await walk(fullPath)); } continue; } if (entry.isFile() && EXTENSIONS.has(path.extname(entry.name))) { files.push(fullPath); } } return files; } function checkFile(file, source) { const findings = []; findings.push(...checkDocumentMetadata(file, source)); findings.push(...checkImages(file, source)); findings.push(...checkButtons(file, source)); findings.push(...checkAnchors(file, source)); findings.push(...checkFormControls(file, source)); findings.push(...checkClickOnlyElements(file, source)); findings.push(...checkTabIndex(file, source)); findings.push(...checkAriaTypos(file, source)); findings.push(...checkSvgNames(file, source)); return findings; } function checkDocumentMetadata(file, source) { if (!file.endsWith('.html')) { return []; } const findings = []; if (!/]*\blang\s*=\s*["'][^"']+["']/i.test(source)) { findings.push(finding('critical', file, source, source.search(/]*\bname\s*=\s*["']viewport["'][^>]*>/i.test(source)) { findings.push(finding('warning', file, source, source.search(/]*)>/gi; for (const match of source.matchAll(regex)) { const attrs = match[1] || ''; if (!hasAttribute(attrs, 'alt')) { findings.push(finding('critical', file, source, match.index, 'img-alt', 'Image elements must include alt text. Use alt="" for decorative images.')); } } return findings; } function checkButtons(file, source) { const findings = []; const regex = /]*)>([\s\S]*?)<\/button>/gi; for (const match of source.matchAll(regex)) { const attrs = match[1] || ''; const children = stripJsxExpressions(stripTags(match[2] || '')).trim(); if (!hasAccessibleName(attrs, children)) { findings.push(finding('critical', file, source, match.index, 'button-name', 'Button must have visible text, aria-label, aria-labelledby, or title.')); } } const selfClosingButtonRegex = /]*)\/>/gi; for (const match of source.matchAll(selfClosingButtonRegex)) { const attrs = match[1] || ''; if (!hasAccessibleName(attrs, '')) { findings.push(finding('critical', file, source, match.index, 'button-name', 'Self-closing button must have aria-label, aria-labelledby, or title.')); } } return findings; } function checkAnchors(file, source) { const findings = []; const regex = /]*)>([\s\S]*?)<\/a>/gi; for (const match of source.matchAll(regex)) { const attrs = match[1] || ''; const children = stripJsxExpressions(stripTags(match[2] || '')).trim(); if (!hasAccessibleName(attrs, children)) { findings.push(finding('critical', file, source, match.index, 'link-name', 'Links must have visible text, aria-label, aria-labelledby, or title.')); } if (!hasAttribute(attrs, 'href') && hasAttribute(attrs, 'onClick')) { findings.push(finding('warning', file, source, match.index, 'anchor-without-href', 'Clickable anchors without href should usually be buttons.')); } if (/\bhref\s*=\s*["']#["']/i.test(attrs)) { findings.push(finding('warning', file, source, match.index, 'hash-link', 'href="#" can create keyboard and screen-reader friction; prefer a button or a real destination.')); } } return findings; } function checkFormControls(file, source) { const findings = []; const labelIds = new Set(); for (const match of source.matchAll(/]*)>/gi)) { const attrs = match[1] || ''; const htmlFor = getStaticAttribute(attrs, 'htmlFor') || getStaticAttribute(attrs, 'for'); if (htmlFor) { labelIds.add(htmlFor); } } const regex = /<(input|select|textarea)\b([^>]*)>/gi; for (const match of source.matchAll(regex)) { const tag = match[1].toLowerCase(); const attrs = match[2] || ''; const type = (getStaticAttribute(attrs, 'type') || '').toLowerCase(); if (tag === 'input' && ['hidden', 'submit', 'button', 'reset'].includes(type)) { continue; } if (hasAttribute(attrs, 'aria-label') || hasAttribute(attrs, 'aria-labelledby')) { continue; } const id = getStaticAttribute(attrs, 'id'); if (id && labelIds.has(id)) { continue; } findings.push(finding('warning', file, source, match.index, 'control-label', `${tag} control should have an associated label, aria-label, or aria-labelledby.`)); } return findings; } function checkClickOnlyElements(file, source) { const findings = []; const regex = /<(div|span|li|section|article|p|h[1-6])\b([^>]*)>/gi; for (const match of source.matchAll(regex)) { const tag = match[1].toLowerCase(); const attrs = match[2] || ''; if (!hasAttribute(attrs, 'onClick')) { continue; } const hasSemanticRole = hasAttribute(attrs, 'role'); const hasKeyboardHandler = hasAttribute(attrs, 'onKeyDown') || hasAttribute(attrs, 'onKeyUp') || hasAttribute(attrs, 'onKeyPress'); const hasFocusableTabIndex = /\btabIndex\s*=\s*(?:["']0["']|{0})/i.test(attrs); if (!hasSemanticRole) { findings.push(finding('critical', file, source, match.index, 'clickable-role', `Clickable <${tag}> needs a semantic role, or should be replaced with a button/link.`)); } if (!hasKeyboardHandler || !hasFocusableTabIndex) { findings.push(finding('critical', file, source, match.index, 'clickable-keyboard', `Clickable <${tag}> must be keyboard-focusable and handle keyboard activation.`)); } } return findings; } function checkTabIndex(file, source) { const findings = []; const regex = /\btabIndex\s*=\s*(?:"([0-9-]+)"|'([0-9-]+)'|{([0-9-]+)})/gi; for (const match of source.matchAll(regex)) { const value = Number.parseInt(match[1] || match[2] || match[3], 10); if (value > 0) { findings.push(finding('warning', file, source, match.index, 'positive-tabindex', 'Positive tabIndex values create unexpected focus order. Use 0 or natural DOM order.')); } } return findings; } function checkAriaTypos(file, source) { const findings = []; const likelyTypos = [ ['aria-lable', 'aria-label'], ['arial-label', 'aria-label'], ['aria-described-by', 'aria-describedby'], ['aria-labelled-by', 'aria-labelledby'] ]; for (const [typo, correct] of likelyTypos) { const regex = new RegExp(`\\b${escapeRegex(typo)}\\s*=`, 'gi'); for (const match of source.matchAll(regex)) { findings.push(finding('critical', file, source, match.index, 'aria-typo', `Possible ARIA typo "${typo}". Did you mean "${correct}"?`)); } } return findings; } function checkSvgNames(file, source) { const findings = []; const regex = /]*)>([\s\S]*?)<\/svg>/gi; for (const match of source.matchAll(regex)) { const attrs = match[1] || ''; const children = match[2] || ''; if (!/\brole\s*=\s*["']img["']/i.test(attrs)) { continue; } if (!hasAttribute(attrs, 'aria-label') && !hasAttribute(attrs, 'aria-labelledby') && !/]+>/g, ' '); } function stripJsxExpressions(value) { return value.replace(/{[^{}]*}/g, ' '); } function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function finding(severity, file, source, index, rule, message) { const location = getLocation(source, Math.max(index || 0, 0)); return { severity, rule, message, file, line: location.line, column: location.column }; } function getLocation(source, index) { const prefix = source.slice(0, index); const lines = prefix.split(/\r?\n/); return { line: lines.length, column: lines[lines.length - 1].length + 1 }; } function summarize(findings, files, durationMs) { return { passed: findings.filter((item) => item.severity === 'critical').length === 0, durationMs, filesScanned: files.length, counts: { critical: findings.filter((item) => item.severity === 'critical').length, warning: findings.filter((item) => item.severity === 'warning').length }, findings }; } function printTextSummary(summary) { console.log(`Fan Passport static accessibility check scanned ${summary.filesScanned} file${summary.filesScanned === 1 ? '' : 's'} in ${summary.durationMs}ms.`); console.log(`Critical: ${summary.counts.critical}`); console.log(`Warnings: ${summary.counts.warning}`); if (summary.findings.length === 0) { console.log('No static accessibility findings detected.'); return; } console.log(''); for (const item of summary.findings) { const icon = item.severity === 'critical' ? '✗' : '!'; console.log(`${icon} ${item.file}:${item.line}:${item.column} [${item.severity}/${item.rule}] ${item.message}`); } }