#!/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; } // Find the @media (or @supports etc) context for a given byte offset in CSS. // Walks backward through the source counting unmatched braces. function findEnclosingAtRule(content, idx) { let depth = 0; for (let i = idx - 1; i >= 0; i--) { const c = content[i]; if (c === '}') depth++; else if (c === '{') { if (depth === 0) { // Find selector-like text before this `{` let j = i - 1; while (j > 0 && content[j] !== '{' && content[j] !== '}') j--; const snippet = content.slice(j + 1, i).trim().replace(/\s+/g, ' '); if (snippet.startsWith('@')) return snippet; return null; } depth--; } } return null; } 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}] // Same-selector + same-context + same-body groups (true Pattern G dead code). const trueDups = new Map(); // key -> [{file, line, selector, context}] 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; if (/^\s*@/.test(selector)) continue; const normSel = selector.trim().replace(/\s+/g, ' '); const normBody = body.trim().replace(/\s+/g, ' '); const context = findEnclosingAtRule(content, ruleStartIdx); if (normBody) { const key = `${normSel}::${context || 'top'}::${normBody}`; if (!trueDups.has(key)) trueDups.set(key, []); trueDups.get(key).push({ file: filePath, line: lineOf(content, ruleStartIdx), selector: normSel, context: context || 'top-level', }); } // Also track loose "class touched by N rules" for the duplicate-class report 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 }); } } const totalImportant = (content.match(/!important/g) || []).length; const hexViolations = findHexViolations(content); return { filePath, defs, totalImportant, hexViolations, trueDups }; } 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 = []; const allTrueDups = new Map(); // key -> occurrences across files 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 }); for (const [k, occs] of r.trueDups.entries()) { if (!allTrueDups.has(k)) allTrueDups.set(k, []); allTrueDups.get(k).push(...occs); } } const trueDupGroups = [...allTrueDups.values()].filter(g => g.length >= 2); // 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, trueDuplicates: trueDupGroups.map(g => ({ selector: g[0].selector, context: g[0].context, count: g.length, locations: g.map(o => `${path.basename(o.file)}:${o.line}`), })), }, 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(`True duplicate rules (same selector + same @media + same body — pure dead code): ${trueDupGroups.reduce((s, g) => s + g.length - 1, 0)}`); if (trueDupGroups.length > 0) { console.log(' (these are strict copy-paste duplicates; the LAST one in source order wins, the rest are dead code and can be deleted)'); for (const g of trueDupGroups.slice(0, 10)) { console.log(` ${g.length}x ${g[0].selector} [${g[0].context}]`); for (const o of g) console.log(` ${path.basename(o.file)}:${o.line}`); } if (trueDupGroups.length > 10) console.log(` ... ${trueDupGroups.length - 10} more groups`); } 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.');