learning_ai_invt_trdg/web/scripts/audit-css.mjs
Devin c10de34a11 chore(web): add CSS audit script for duplicate selectors (UI audit #7)
Adds web/scripts/audit-css.mjs — surfaces classes that appear in many
rules (likely targets of style drift) and per-class !important counts
(specificity-fight indicators). Implements the script suggested by
docs/ui/UI_AUDIT.md §5 #7 / Pattern G.

Run: pnpm --filter @bytelyst/trading-web run audit:css
     (or: cd web && npm run audit:css)

Initial run on src/index.css + src/App.css + src/layout-fixes.css
exposes the top hotspots:
  - .positions-tab     22 rules, 26 !important
  - .trade-plans-page  20 rules, 25 !important
  - .history-tab       17 rules, 22 !important
  - .trading-sidebar-logo  9 rules, 20 !important

These are the targets for the future Pattern G consolidation pass.
The script supports --threshold N and --json for tooling.

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:24:07 +00:00

130 lines
4.5 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)
//
// 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.');