/** * Config parsing + validation for the accessibility gate. * * Everything here is pure so the rules that decide what may be suppressed are * unit-testable without a browser. A config that does not validate is a hard * error (exit 2) — a gate that silently degrades to "audited nothing" is the * failure mode this whole check exists to remove. */ import { IMPACT_LEVELS } from './types'; import type { A11yAuditConfig, AllowlistEntry, ImpactLevel, ResolvedConfig, RouteSpec, ViewportSpec, } from './types'; export const DEFAULT_STANDARD = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; export const DEFAULT_FAIL_ON: ImpactLevel[] = ['critical', 'serious']; export const DEFAULT_VIEWPORTS: ViewportSpec[] = [{ name: 'desktop', width: 1280, height: 800 }]; export const MIN_REASON_LENGTH = 20; /** Reason strings that are technically non-empty but say nothing. */ const PLACEHOLDER_REASONS = [ 'todo', 'tbd', 'fixme', 'wip', 'n/a', 'na', 'none', 'temporary', 'temp', 'later', 'known issue', 'known issues', 'legacy', 'see ticket', ]; export class ConfigError extends Error { readonly issues: string[]; constructor(issues: string[]) { super(`Invalid a11y-audit config:\n - ${issues.join('\n - ')}`); this.name = 'ConfigError'; this.issues = issues; } } function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } /** `true` for a bare wildcard, in any of the shapes people reach for first. */ export function isWildcard(value: string): boolean { const trimmed = value.trim(); return trimmed === '*' || trimmed === '**' || trimmed === '/*' || trimmed === '.*'; } const PLACEHOLDER_PREFIX = /^(todo|tbd|fixme|wip|n\/a|na|none|temp|temporary|later|known issues?|legacy|see ticket)\b[\s:,.\-–—]*/; /** * True for a reason that is technically prose but says nothing — either the * bare placeholder, or a placeholder token with no substance behind it * ("TODO: fix later"). Checked before the length floor so the error message * names the real problem. */ export function isPlaceholderReason(reason: string): boolean { const normalized = reason .trim() .toLowerCase() .replace(/[.!\-–—:;,]+$/g, '') .trim(); if (PLACEHOLDER_REASONS.includes(normalized)) return true; const stripped = normalized.replace(PLACEHOLDER_PREFIX, '').trim(); return stripped !== normalized && stripped.length < MIN_REASON_LENGTH; } /** Valid ISO calendar date, and a real one (2026-02-31 is rejected). */ export function isIsoDate(value: string): boolean { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; const parsed = new Date(`${value}T00:00:00Z`); if (Number.isNaN(parsed.getTime())) return false; return parsed.toISOString().slice(0, 10) === value; } function validateAllowlistEntry(entry: unknown, index: number, issues: string[]): void { const where = `allowlist[${index}]`; if (!isPlainObject(entry)) { issues.push(`${where} must be an object`); return; } if (!isNonEmptyString(entry.rule)) { issues.push(`${where}.rule is required and must be a non-empty axe rule id`); } else if (isWildcard(entry.rule)) { issues.push( `${where}.rule "${entry.rule}" is a wildcard — allowlist entries must name one axe rule; ` + 'blanket suppressions are not supported' ); } if (!isNonEmptyString(entry.reason)) { issues.push( `${where}.reason is required — every allowlisted violation must say why it is tolerated` ); } else if (isPlaceholderReason(entry.reason)) { issues.push( `${where}.reason "${entry.reason.trim()}" is a placeholder, not a reason — describe the ` + 'actual constraint and who owns the fix' ); } else if (entry.reason.trim().length < MIN_REASON_LENGTH) { issues.push( `${where}.reason must be at least ${MIN_REASON_LENGTH} characters explaining why this is ` + `tolerated (got ${entry.reason.trim().length})` ); } for (const key of ['routes', 'selectors'] as const) { const value = entry[key]; if (value === undefined) continue; if (!Array.isArray(value) || value.length === 0) { issues.push(`${where}.${key} must be a non-empty array when present`); continue; } value.forEach((item, i) => { if (!isNonEmptyString(item)) { issues.push(`${where}.${key}[${i}] must be a non-empty string`); } else if (isWildcard(item)) { issues.push( `${where}.${key}[${i}] "${item}" is a wildcard — omit ${key} entirely if the entry is ` + 'meant to apply everywhere, so the report shows it as unscoped' ); } }); } if (entry.expires !== undefined) { if (!isNonEmptyString(entry.expires) || !isIsoDate(entry.expires)) { issues.push(`${where}.expires must be an ISO date, e.g. "2026-12-31"`); } } if (entry.ticket !== undefined && !isNonEmptyString(entry.ticket)) { issues.push(`${where}.ticket must be a non-empty string when present`); } } function validateRoute(route: unknown, index: number, issues: string[]): void { const where = `routes[${index}]`; if (!isPlainObject(route)) { issues.push(`${where} must be an object`); return; } if (!isNonEmptyString(route.path)) { issues.push(`${where}.path is required`); } else if (!route.path.startsWith('/')) { issues.push(`${where}.path "${route.path}" must start with "/"`); } if (route.name !== undefined && !isNonEmptyString(route.name)) { issues.push(`${where}.name must be a non-empty string when present`); } if (route.waitForSelector !== undefined && !isNonEmptyString(route.waitForSelector)) { issues.push(`${where}.waitForSelector must be a non-empty string when present`); } if ( route.settleMs !== undefined && (typeof route.settleMs !== 'number' || !Number.isFinite(route.settleMs) || route.settleMs < 0) ) { issues.push(`${where}.settleMs must be a non-negative number when present`); } if ( route.minTextLength !== undefined && (typeof route.minTextLength !== 'number' || !Number.isFinite(route.minTextLength) || route.minTextLength < 0) ) { issues.push(`${where}.minTextLength must be a non-negative number when present`); } } function validateServe(serve: unknown, issues: string[]): void { if (!isPlainObject(serve)) { issues.push('serve is required and must be an object'); return; } const modes = (['staticDir', 'command', 'baseUrl'] as const).filter((k) => isNonEmptyString(serve[k]) ); if (modes.length === 0) { issues.push('serve must set exactly one of staticDir, command or baseUrl'); } else if (modes.length > 1) { issues.push(`serve sets ${modes.join(' and ')} — pick exactly one`); } if (isNonEmptyString(serve.command) && typeof serve.port !== 'number') { issues.push('serve.port is required when serve.command is used'); } if ( serve.readyTimeoutMs !== undefined && (typeof serve.readyTimeoutMs !== 'number' || serve.readyTimeoutMs <= 0) ) { issues.push('serve.readyTimeoutMs must be a positive number when present'); } } /** * Validate a raw parsed config and fill in defaults. * * @throws ConfigError listing every problem found, not just the first. */ export function resolveConfig(raw: unknown): ResolvedConfig { const issues: string[] = []; if (!isPlainObject(raw)) { throw new ConfigError(['config must be a JSON object']); } if (!isNonEmptyString(raw.name)) { issues.push('name is required (use the repo name, e.g. "vibecontrols-app")'); } validateServe(raw.serve, issues); if (!Array.isArray(raw.routes) || raw.routes.length === 0) { issues.push('routes must be a non-empty array — an audit with no routes proves nothing'); } else { raw.routes.forEach((route, i) => validateRoute(route, i, issues)); } if (raw.viewports !== undefined) { if (!Array.isArray(raw.viewports) || raw.viewports.length === 0) { issues.push('viewports must be a non-empty array when present'); } else { raw.viewports.forEach((vp, i) => { if ( !isPlainObject(vp) || !isNonEmptyString(vp.name) || typeof vp.width !== 'number' || typeof vp.height !== 'number' ) { issues.push(`viewports[${i}] must be { name, width, height }`); } }); } } if (raw.standard !== undefined) { if (!Array.isArray(raw.standard) || raw.standard.length === 0) { issues.push('standard must be a non-empty array of axe tags when present'); } else if (!raw.standard.every(isNonEmptyString)) { issues.push('standard entries must be non-empty axe tag strings'); } } if (raw.failOn !== undefined) { if (!Array.isArray(raw.failOn) || raw.failOn.length === 0) { issues.push('failOn must be a non-empty array when present'); } else { for (const level of raw.failOn) { if (typeof level !== 'string' || !IMPACT_LEVELS.includes(level as ImpactLevel)) { issues.push(`failOn contains "${String(level)}" — allowed: ${IMPACT_LEVELS.join(', ')}`); } } if (Array.isArray(raw.failOn) && !raw.failOn.includes('critical')) { issues.push('failOn must include "critical" — the fleet floor is critical + serious'); } if (Array.isArray(raw.failOn) && !raw.failOn.includes('serious')) { issues.push('failOn must include "serious" — the fleet floor is critical + serious'); } } } if (raw.allowlist !== undefined) { if (!Array.isArray(raw.allowlist)) { issues.push('allowlist must be an array when present'); } else { raw.allowlist.forEach((entry, i) => validateAllowlistEntry(entry, i, issues)); } } if (issues.length > 0) throw new ConfigError(issues); const config = raw as unknown as A11yAuditConfig; const settleMs = typeof config.settleMs === 'number' ? config.settleMs : 1500; return { name: config.name, serve: { ...config.serve, spa: config.serve.spa !== false, readyTimeoutMs: config.serve.readyTimeoutMs ?? 60_000, }, routes: config.routes.map((route: RouteSpec) => ({ ...route, name: route.name ?? route.path, allowRedirect: route.allowRedirect ?? false, })), viewports: config.viewports ?? DEFAULT_VIEWPORTS, standard: config.standard ?? DEFAULT_STANDARD, failOn: config.failOn ?? DEFAULT_FAIL_ON, allowlist: (config.allowlist ?? []) as AllowlistEntry[], reportPath: config.reportPath ?? 'a11y-report.json', failOnStaleAllowlist: config.failOnStaleAllowlist ?? false, navigationTimeoutMs: config.navigationTimeoutMs ?? 45_000, settleMs, blockExternalRequests: config.blockExternalRequests !== false, domQuietMs: config.domQuietMs ?? 1500, minTextLength: config.minTextLength ?? 100, routeAttempts: config.routeAttempts ?? 3, }; }