#!/usr/bin/env node // Audit CSS files in src/ for: // - Class selectors defined more than once (rule duplication / drift) // - Per-class !important counts (specificity-fight indicators) // - Hardcoded hex colors outside CSS custom-property definitions // and outside `var(--token, #fallback)` token-default fallbacks // (which are explicitly allowed per repo design-system rules) // // Outputs a report to stdout. Exits 0 always; this is informational. // // Usage: // node scripts/audit-css.mjs [path-or-glob...] // node scripts/audit-css.mjs --threshold 2 # only show classes with >= N defs // node scripts/audit-css.mjs --json # machine-readable output // // See docs/ui/UI_AUDIT.md §5 #7/#10 and Patterns G. import fs from 'node:fs'; import path from 'node:path'; import { argv, exit } from 'node:process'; const args = argv.slice(2); const opts = { threshold: 2, json: false, paths: [] }; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === '--threshold' || a === '-t') opts.threshold = parseInt(args[++i], 10); else if (a === '--json') opts.json = true; else opts.paths.push(a); } if (opts.paths.length === 0) opts.paths = ['src/index.css', 'src/App.css', 'src/layout-fixes.css']; const SELECTOR_RE = /(^|[\s},])\s*\.([a-zA-Z_][\w-]*)\b/g; const RULE_BLOCK_RE = /([^{}]+?)\{([^{}]*)\}/gs; function readSafe(p) { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } } function lineOf(content, idx) { return content.slice(0, idx).split('\n').length; } // --- Hex-color audit ---------------------------------------------------- const HEX_RE = /#(?:[0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})\b/g; const TOKEN_FALLBACK_RE = /var\([^)]*?#[0-9A-Fa-f]{3,8}[^)]*?\)/g; const CUSTOM_PROP_DEF_RE = /^\s*--[\w-]+\s*:/; function findHexViolations(content) { // Mask out var(--x, #xxx) so hex inside fallbacks doesn't count. const masked = content.replace(TOKEN_FALLBACK_RE, '___TOKEN___'); const violations = []; const lines = masked.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; const matches = line.match(HEX_RE); if (!matches) continue; // Allowed: hex in a CSS custom-property definition (--name: #xxx;) if (CUSTOM_PROP_DEF_RE.test(line)) continue; // Allowed: comments if (/^\s*\*|^\s*\/\//.test(line)) continue; for (const hex of matches) { violations.push({ line: i + 1, hex, snippet: line.trim().slice(0, 100) }); } } return violations; } function audit(filePath) { const content = readSafe(filePath); if (content == null) return null; // For each top-level rule block, collect class selectors and !important counts. const defs = new Map(); // className -> [{file, line, importants}] let m; RULE_BLOCK_RE.lastIndex = 0; while ((m = RULE_BLOCK_RE.exec(content)) !== null) { const selector = m[1]; const body = m[2]; const ruleStartIdx = m.index; const importants = (body.match(/!important/g) || []).length; // Skip @media / @supports / @keyframes blocks for "duplicate" detection — they're // legitimately repeated. We track them separately below. if (/^\s*@/.test(selector)) continue; // Extract class names from selector list. const classes = new Set(); let s; SELECTOR_RE.lastIndex = 0; while ((s = SELECTOR_RE.exec(selector)) !== null) { classes.add(s[2]); } for (const cls of classes) { if (!defs.has(cls)) defs.set(cls, []); defs.get(cls).push({ file: filePath, line: lineOf(content, ruleStartIdx), importants }); } } // Total !important const totalImportant = (content.match(/!important/g) || []).length; const hexViolations = findHexViolations(content); return { filePath, defs, totalImportant, hexViolations }; } const reports = []; for (const p of opts.paths) { const abs = path.resolve(p); const r = audit(abs); if (r) reports.push(r); } // Merge defs across files const merged = new Map(); let totalImportant = 0; const allHexViolations = []; for (const r of reports) { totalImportant += r.totalImportant; for (const [cls, locs] of r.defs.entries()) { if (!merged.has(cls)) merged.set(cls, []); merged.get(cls).push(...locs); } for (const v of r.hexViolations) allHexViolations.push({ file: r.filePath, ...v }); } // Filter to classes with >= threshold definitions const dups = [...merged.entries()] .filter(([, locs]) => locs.length >= opts.threshold) .sort((a, b) => b[1].length - a[1].length); const importantPerClass = [...merged.entries()] .map(([cls, locs]) => [cls, locs.reduce((s, l) => s + l.importants, 0)]) .filter(([, n]) => n > 0) .sort((a, b) => b[1] - a[1]); if (opts.json) { console.log(JSON.stringify({ files: opts.paths, totalImportant, duplicateSelectors: dups.map(([cls, locs]) => ({ class: cls, count: locs.length, locations: locs })), importantByClass: importantPerClass.map(([cls, n]) => ({ class: cls, count: n })), hexViolations: allHexViolations, }, null, 2)); exit(0); } console.log('=== CSS audit ==='); console.log(`Files scanned: ${opts.paths.join(', ')}`); console.log(`Total !important declarations: ${totalImportant}`); console.log(''); console.log(`Class selectors defined ${opts.threshold}+ times (top 30):`); console.log(' count class'); console.log(' ----- ----------------------------------------'); for (const [cls, locs] of dups.slice(0, 30)) { const lines = locs.map(l => `${path.basename(l.file)}:${l.line}`).join(', '); console.log(` ${String(locs.length).padStart(5)} .${cls} (${lines})`); } console.log(''); console.log('Top 15 classes by !important count (each declaration is a specificity fight):'); console.log(' count class'); console.log(' ----- ----------------------------------------'); for (const [cls, n] of importantPerClass.slice(0, 15)) { console.log(` ${String(n).padStart(5)} .${cls}`); } console.log(''); console.log(''); console.log(`Hardcoded hex colors outside token defs / fallbacks: ${allHexViolations.length}`); if (allHexViolations.length > 0) { console.log(' (allowed: hex inside `--name: #xxx;` defs and `var(--x, #xxx)` fallbacks)'); for (const v of allHexViolations.slice(0, 20)) { console.log(` ${path.basename(v.file)}:${v.line} ${v.hex} ${v.snippet}`); } if (allHexViolations.length > 20) console.log(` ... ${allHexViolations.length - 20} more`); } console.log(''); console.log('See docs/ui/UI_AUDIT.md Pattern G + §5 #7/#10 for fix guidance.');