/** * Allowlist matching and violation partitioning. * * Pure functions only — this is the part of the gate that decides what is * allowed to not fail a build, so it is unit-tested directly rather than * inferred from a green CI run. */ import type { AllowedNode, AllowlistEntry, FlatNode, ImpactLevel, RouteSpec } from './types'; /** axe targets nest one level for iframes; flatten to plain selector strings. */ export function flattenTarget(target: unknown): string[] { if (typeof target === 'string') return [target]; if (!Array.isArray(target)) return []; return target.flatMap((t) => flattenTarget(t)); } function normalizeSelector(selector: string): string { return selector.trim().replace(/\s+/g, ' '); } /** * A configured selector matches an axe target when it is the target itself or * an ancestor prefix of it. `#billing-panel` therefore covers * `#billing-panel > table > tr:nth-child(2) > td`, but `#billing` does not — * the boundary must fall on a combinator, so prefixes never match by accident. */ export function selectorMatches(allowSelector: string, target: string): boolean { const allow = normalizeSelector(allowSelector); const actual = normalizeSelector(target); if (allow.length === 0) return false; if (allow === actual) return true; if (!actual.startsWith(allow)) return false; const boundary = actual.charAt(allow.length); return boundary === ' ' || boundary === '>' || boundary === '+' || boundary === '~'; } /** * A configured route matches either exactly, or as a `/prefix/*` subtree. * `/settings/*` covers `/settings/profile` and `/settings` itself. */ export function routeMatches(allowRoute: string, routePath: string): boolean { const allow = allowRoute.trim(); const actual = routePath.trim(); if (allow === actual) return true; if (!allow.endsWith('/*')) return false; const prefix = allow.slice(0, -2); if (prefix.length === 0) return false; return actual === prefix || actual.startsWith(`${prefix}/`); } /** An entry with an `expires` date in the past suppresses nothing. */ export function isExpired(entry: AllowlistEntry, now: Date): boolean { if (!entry.expires) return false; const expiryEnd = new Date(`${entry.expires}T23:59:59.999Z`).getTime(); return now.getTime() > expiryEnd; } /** Does this entry cover this specific failing node? */ export function entryCovers(entry: AllowlistEntry, node: FlatNode, now: Date): boolean { if (entry.rule !== node.rule) return false; if (isExpired(entry, now)) return false; if (entry.routes && !entry.routes.some((r) => routeMatches(r, node.route))) return false; if ( entry.selectors && !entry.selectors.some((s) => node.target.some((t) => selectorMatches(s, t))) ) { return false; } return true; } /** Shape of the subset of an axe violation this module reads. */ export interface AxeViolationLike { id: string; impact?: string | null; help?: string; helpUrl?: string; tags?: string[]; nodes: Array<{ target: unknown; html?: string; impact?: string | null; failureSummary?: string; }>; } /** Flatten axe violations into one record per failing DOM node. */ export function flattenViolations( violations: AxeViolationLike[], context: { route: string; routeName: string; viewport: string } ): FlatNode[] { const flat: FlatNode[] = []; for (const violation of violations) { for (const node of violation.nodes) { flat.push({ rule: violation.id, impact: (node.impact ?? violation.impact ?? 'minor') as ImpactLevel, help: violation.help ?? '', helpUrl: violation.helpUrl ?? '', tags: violation.tags ?? [], route: context.route, routeName: context.routeName, viewport: context.viewport, target: flattenTarget(node.target), html: (node.html ?? '').slice(0, 400), failureSummary: node.failureSummary ?? '', }); } } return flat; } export interface PartitionResult { blocking: FlatNode[]; allowed: AllowedNode[]; advisory: FlatNode[]; /** Indexes into the allowlist that actually suppressed something. */ usedEntries: Set; } /** * Split flattened nodes into build-failing, explicitly-allowed and advisory * (below the failure threshold, reported but not blocking). * * When several entries cover the same node the first one wins, so the report * attributes it to a single, nameable reason. */ export function partitionNodes( nodes: FlatNode[], allowlist: AllowlistEntry[], failOn: ImpactLevel[], now: Date ): PartitionResult { const blocking: FlatNode[] = []; const allowed: AllowedNode[] = []; const advisory: FlatNode[] = []; const usedEntries = new Set(); for (const node of nodes) { const index = allowlist.findIndex((entry) => entryCovers(entry, node, now)); if (index >= 0) { usedEntries.add(index); const entry = allowlist[index]; allowed.push({ ...node, allowlistIndex: index, allowlistReason: entry.reason, allowlistTicket: entry.ticket, }); continue; } if (failOn.includes(node.impact)) blocking.push(node); else advisory.push(node); } return { blocking, allowed, advisory, usedEntries }; } /** * Entries whose `routes` name a path that is not in the audited route list. * Almost always a typo, and a typo here silently widens or voids the scope. */ export function findUnreachableRouteScopes( allowlist: AllowlistEntry[], routes: RouteSpec[] ): Array<{ index: number; route: string }> { const unreachable: Array<{ index: number; route: string }> = []; allowlist.forEach((entry, index) => { if (!entry.routes) return; for (const scope of entry.routes) { if (!routes.some((route) => routeMatches(scope, route.path))) { unreachable.push({ index, route: scope }); } } }); return unreachable; }