learning_ai_invt_trdg/web/scripts/audit-css.mjs
Devin 391b3a9fd3 chore(web): audit-css.mjs — detect true (selector+context+body) duplicates
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>
2026-05-10 09:51:14 +00:00

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.');