/* ───────────────────────────────────────────────────────────────────────── * scripts/check-components-fixtures.ts * * Report-mode inventory for design-system `components.html` fixtures. * * This is intentionally non-blocking in PR 1: it lets us see whether each * fixture exposes the selector vocabulary agents should learn from, and where * component CSS still relies on concrete values instead of shared tokens. * * Run standalone: * pnpm exec tsx scripts/check-components-fixtures.ts * * Guard integration: * `checkDesignSystemComponentFixtureReport()` always returns true unless the * script itself crashes. Later PRs can promote stable findings to hard-fail * checks once the report is clean enough to enforce. * ─────────────────────────────────────────────────────────────────── */ import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = path.resolve(import.meta.dirname, ".."); const designSystemsRoot = path.join(repoRoot, "design-systems"); const SKIPPED_DESIGN_SYSTEM_DIRECTORIES = new Set(["_schema"]); type FixtureSource = { name: string; directoryPath: string; fixturePath: string; tokensPath: string; fixtureHtml: string; tokensCss: string | null; }; type SelectorGroup = { name: string; selectors: readonly string[]; }; type FixtureReport = { source: FixtureSource; hasTokens: boolean; hasStyle: boolean; hasRoot: boolean; missingSelectorsByGroup: Map; colorLiteralCount: number; pixelLiteralCount: number; hardcodedFontFamilyCount: number; }; type AggregateSelectorGap = { groupName: string; selector: string; missingCount: number; }; const selectorGroups: readonly SelectorGroup[] = [ { name: "buttons", selectors: [".btn", ".btn-primary", ".btn-secondary", ":hover", ":focus-visible"] }, { name: "inputs", selectors: [".field", "input", "label"] }, { name: "cards", selectors: [".card"] }, { name: "badges", selectors: [".badge"] }, { name: "links", selectors: ["a"] }, { name: "keyboard", selectors: ["kbd"] }, { name: "icons", selectors: [".icon"] }, { name: "typography", selectors: ["h1", "h2", "h3", ".lead", ".eyebrow", ".body-muted", ".body-sm"] }, { name: "layout", selectors: [".container", "section", ".stack-3", ".stack-4", ".stack-6", ".row-between"] }, ]; const colorLiteralPattern = /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|oklch\([^)]*\)|color-mix\([^)]*\)/g; const pixelLiteralPattern = /(? { try { return await readFile(filePath, "utf8"); } catch { return null; } } async function discoverFixtureSources(): Promise { const entries = await readdir(designSystemsRoot, { withFileTypes: true }); const sources: FixtureSource[] = []; for (const entry of entries) { if (!entry.isDirectory()) continue; if (SKIPPED_DESIGN_SYSTEM_DIRECTORIES.has(entry.name)) continue; const directoryPath = path.join(designSystemsRoot, entry.name); const fixturePath = path.join(directoryPath, "components.html"); const tokensPath = path.join(directoryPath, "tokens.css"); const fixtureHtml = await readTextIfExists(fixturePath); if (fixtureHtml == null) continue; sources.push({ name: entry.name, directoryPath, fixturePath, tokensPath, fixtureHtml, tokensCss: await readTextIfExists(tokensPath), }); } sources.sort((a, b) => a.name.localeCompare(b.name)); return sources; } function firstStyleBlock(html: string): string | null { return html.match(/]*>([\s\S]*?)<\/style>/i)?.[1] ?? null; } function stripCssComments(css: string): string { return css.replace(/\/\*[\s\S]*?\*\//g, ""); } function stripFirstRootBlock(css: string): string { return css.replace(/:root(?!\[)\s*\{[\s\S]*?\}/, ""); } function hasUnscopedRoot(css: string | null): boolean { return css != null && /:root(?!\[)\s*\{/.test(stripCssComments(css)); } function selectorPattern(selector: string): RegExp { const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); if (/^[a-z]+$/.test(selector)) { return new RegExp(`(^|[^.#\\w-])${escaped}(?=\\s*[{,.#:>+~\\[])`); } return new RegExp(`${escaped}(?=\\s*[{,.#:>+~\\s]|$)`); } function countMatches(source: string, pattern: RegExp): number { return [...source.matchAll(pattern)].length; } function analyzeFixture(source: FixtureSource): FixtureReport { const style = firstStyleBlock(source.fixtureHtml); const commentlessStyle = style == null ? "" : stripCssComments(style); const componentCss = stripFirstRootBlock(commentlessStyle); const missingSelectorsByGroup = new Map(); for (const group of selectorGroups) { const missing = group.selectors.filter((selector) => !selectorPattern(selector).test(componentCss)); if (missing.length > 0) missingSelectorsByGroup.set(group.name, missing); } return { source, hasTokens: source.tokensCss != null, hasStyle: style != null, hasRoot: hasUnscopedRoot(style), missingSelectorsByGroup, colorLiteralCount: countMatches(componentCss, colorLiteralPattern), pixelLiteralCount: countMatches(componentCss, pixelLiteralPattern), hardcodedFontFamilyCount: countMatches(componentCss, hardcodedFontFamilyPattern), }; } function countCompleteGroup(reports: readonly FixtureReport[], groupName: string): number { return reports.filter((report) => !report.missingSelectorsByGroup.has(groupName)).length; } function selectorGaps(reports: readonly FixtureReport[]): AggregateSelectorGap[] { const gapCounts = new Map(); for (const report of reports) { for (const [groupName, selectors] of report.missingSelectorsByGroup) { for (const selector of selectors) { const key = `${groupName}\0${selector}`; const existing = gapCounts.get(key); if (existing == null) { gapCounts.set(key, { groupName, selector, missingCount: 1 }); } else { existing.missingCount += 1; } } } } return [...gapCounts.values()].sort((a, b) => b.missingCount - a.missingCount || a.selector.localeCompare(b.selector)); } function topBy(items: readonly T[], score: (item: T) => number, limit: number): T[] { return [...items] .filter((item) => score(item) > 0) .sort((a, b) => score(b) - score(a)) .slice(0, limit); } function formatFixtureList(reports: readonly FixtureReport[], reason: (report: FixtureReport) => boolean): string[] { return reports .filter(reason) .map((report) => ` - ${toRepositoryPath(report.source.fixturePath)}`) .slice(0, 20); } export async function checkDesignSystemComponentFixtureReport(): Promise { const sources = await discoverFixtureSources(); const reports = sources.map(analyzeFixture); const total = reports.length; console.log(`Design system component fixture report: scanned ${total} components.html fixtures.`); const missingTokens = formatFixtureList(reports, (report) => !report.hasTokens); const missingStyle = formatFixtureList(reports, (report) => !report.hasStyle); const missingRoot = formatFixtureList(reports, (report) => !report.hasRoot); if (missingTokens.length > 0) { console.log(`Design system component fixture report: ${missingTokens.length} fixture(s) have no paired tokens.css.`); for (const line of missingTokens) console.log(line); } if (missingStyle.length > 0) { console.log(`Design system component fixture report: ${missingStyle.length} fixture(s) have no