Extends the script with a strict-duplicate detector that distinguishes
real Pattern G dead code (same selector + same @media context + same
body, where everything but the LAST occurrence in cascade order has no
effect) from descendant-selector counts (which were mostly noise in the
previous report).
The new detector surfaced 29 dead rules in the prior commit (14398af);
it now reports 0 — the cleanup is complete and future regressions will
show up immediately.
Also wires the duplicate-rule data into the --json output for tooling.
Updates docs/ui/UI_AUDIT.md §5 #7 with the consolidation metrics.
Generated with [Devin](https://cli.devin.ai/docs)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
229 lines
8.7 KiB
JavaScript
229 lines
8.7 KiB
JavaScript
#!/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.');
|