#!/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) // // 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 and Pattern 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; } 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; return { filePath, defs, totalImportant }; } 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; 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); } } // 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 })), }, 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('See docs/ui/UI_AUDIT.md Pattern G for fix guidance.');