#!/usr/bin/env python3 """Conservative CSS reference audit for the RustProxy administration UI. Requires: pip install tinycss2 cssselect2 (audit-only tooling, not runtime deps). Output is an inventory, NOT permission to remove dynamically generated styles. Selectors are considered dead only when a required class/ID token is absent from all application templates and scripts; :not() is not treated as a requirement. The Swagger UI bundle emits its own markup, so docs.css is reviewed separately. """ from __future__ import annotations import argparse from collections import Counter import json from pathlib import Path import re try: import tinycss2 from cssselect2 import parser as css_parser except ImportError as exc: raise SystemExit("Install audit dependencies: python3 -m pip install tinycss2 cssselect2") from exc ROOT = Path(__file__).resolve().parents[1] WEB = ROOT / 'src/admin/web' STYLES = ('style.css', 'docs.css') SOURCE_SUFFIXES = {'.js', '.html', '.json', '.rs'} UNSUPPORTED_PSEUDO_ELEMENTS = re.compile(r'::(?:-webkit-[\w-]+|-moz-[\w-]+|backdrop)\b') WORD_PATTERN = re.compile(r'(? set[str]: paths = list(WEB.rglob('*.js')) + list(WEB.rglob('*.html')) if scope == 'panel': paths += list(WEB.rglob('*.json')) + list((ROOT / 'src').rglob('*.rs')) else: paths = [WEB / 'docs.html', WEB / 'docs.js'] words: set[str] = set() for path in paths: words.update(WORD_PATTERN.findall(path.read_text(encoding='utf-8', errors='replace'))) return words def selector_parts(tokens: list) -> list[str]: parts: list[list] = [[]] for tok in tokens: if tok.type == 'literal' and tok.value == ',': parts.append([]) else: parts[-1].append(tok) return [tinycss2.serialize(part).strip() for part in parts if tinycss2.serialize(part).strip()] def rules(stylesheet: str): def walk(items): for item in items: if item.type == 'qualified-rule': yield item.source_line, selector_parts(item.prelude) elif item.type == 'at-rule' and item.lower_at_keyword in ('media', 'container', 'supports', 'layer') and item.content: yield from walk(tinycss2.parse_rule_list(item.content, skip_whitespace=True, skip_comments=True)) return list(walk(tinycss2.parse_stylesheet(stylesheet, skip_whitespace=True, skip_comments=True))) def token_refs(node) -> list[tuple[str, str]]: if isinstance(node, css_parser.ClassSelector): return [('class', node.class_name)] if isinstance(node, css_parser.IDSelector): return [('id', node.ident)] if isinstance(node, css_parser.AttributeSelector) and node.operator in ('=', '~=') and node.value: if node.name in ('class', 'id'): return [(node.name, node.value)] return [] out = [] for field in ('parsed_tree', 'left', 'right', 'simple_selectors', 'selector', 'selector_list'): value = getattr(node, field, None) for child in (value if isinstance(value, (tuple, list)) else [value]): if child is not None: out.extend(token_refs(child)) return out def possibly_matches(node, words: set[str]) -> bool: # Deliberately conservative: class/id absence proves a positive selector # impossible but does NOT make a selector in :not() impossible. if isinstance(node, css_parser.ClassSelector): return node.class_name in words if isinstance(node, css_parser.IDSelector): return node.ident in words if isinstance(node, css_parser.AttributeSelector): if node.name in ('class', 'id') and node.operator in ('=', '~=') and node.value: return node.value in words return True if isinstance(node, css_parser.NegationSelector): return True if isinstance(node, (css_parser.MatchesAnySelector, css_parser.SpecificityAdjustmentSelector, css_parser.RelationalSelector)): return any(possibly_matches(child, words) for child in node.selector_list) if isinstance(node, (css_parser.CombinedSelector, css_parser.CompoundSelector)): children = ([node.left, node.right] if isinstance(node, css_parser.CombinedSelector) else node.simple_selectors) return all(possibly_matches(child, words) for child in children) if isinstance(node, css_parser.Selector): return possibly_matches(node.parsed_tree, words) if isinstance(node, css_parser.RelativeSelector): return possibly_matches(node.selector, words) # Pseudo-classes, type selectors, vendor syntax and other unknowns: KEEP. return True def audit_file(path: Path, words: set[str], scope: str) -> dict: raw = path.read_text(encoding='utf-8') entries = [] all_classes: set[str] = set() unknown = [] for line, selectors in rules(raw): parts = [] for selector in selectors: text = UNSUPPORTED_PSEUDO_ELEMENTS.sub('', selector) try: parsed = list(css_parser.parse(text)) references = set().union(*(set(token_refs(item)) for item in parsed)) all_classes.update(word for typ, word in references if typ == 'class') dead = bool(parsed) and not any(possibly_matches(item, words) for item in parsed) # docs.css targets classes emitted by the third-party Swagger UI bundle. external = scope == 'docs' and '.swagger-ui' in selector if external: dead = False missing = sorted({f'{typ}:{word}' for typ, word in references if word not in words}) parts.append({'selector': selector, 'dead': dead, 'missing_tokens': missing, 'external': external}) except css_parser.SelectorError as exc: parts.append({'selector': selector, 'dead': False, 'missing_tokens': [], 'unparsed': str(exc)}) unknown.append({'line': line, 'selector': selector, 'error': str(exc)}) entries.append({'line': line, 'parts': parts}) dead_parts = [p for e in entries for p in e['parts'] if p['dead']] kept_parts = [p for e in entries for p in e['parts'] if not p['dead']] return { 'file': str(path.relative_to(ROOT)), 'bytes_before': len(raw.encode('utf-8')), 'total_rules': len(entries), 'total_selectors': len(dead_parts) + len(kept_parts), 'referenced_classes': sorted(all_classes & words), 'unreferenced_classes': sorted(all_classes - words), 'unused_selector_count': len(dead_parts), 'active_selector_count': len(kept_parts), 'unparsed': unknown, 'entries': entries, } def main(): cli = argparse.ArgumentParser(description=__doc__) cli.add_argument('--json', type=Path, help='Write complete selector audit JSON to this file') args = cli.parse_args() results = [audit_file(WEB / filename, source_words('docs' if filename == 'docs.css' else 'panel'), 'docs' if filename == 'docs.css' else 'panel') for filename in STYLES] for r in results: print(f"{r['file']}: {r['total_rules']} rules, {r['total_selectors']} selector branches, " f"{len(r['referenced_classes'])} referenced classes, {len(r['unreferenced_classes'])} unreferenced classes, " f"{r['unused_selector_count']} provably dead selector branches, {len(r['unparsed'])} unparsed") print(' Unreferenced:', ', '.join(r['unreferenced_classes']) or '(none)') for e in r['entries']: for p in e['parts']: if p['dead']: print(f" {e['line']}: {p['selector'][:170]} ({', '.join(p['missing_tokens'])})") if args.json: args.json.write_text(json.dumps(results, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') if __name__ == '__main__': main()