#!/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. Absent class/ID tokens are candidates for review, because templates can assemble class names at runtime; :not() is not treated as a positive requirement. The Swagger UI bundle emits its own markup, so docs.css is reviewed separately. """ from __future__ import annotations import argparse from collections import defaultdict 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' SOURCE_SUFFIXES = {'.js', '.html', '.json', '.rs'} UNSUPPORTED_PSEUDO_ELEMENTS = re.compile(r'::(?:-webkit-[\w-]+|-moz-[\w-]+|backdrop)\b') WORD_PATTERN = re.compile(r'(? list[Path]: if __package__: from .build.panel_css import source_files else: from build.panel_css import source_files return source_files(ROOT) def embedded_javascript_sources() -> list[Path]: paths = set() for parent in (ROOT / 'src').rglob('*.rs'): for relative in re.findall(r'\binclude_str!\s*\(\s*"([^"\n]+\.js)"\s*\)', parent.read_text(encoding='utf-8')): path = (parent.parent / relative).resolve() if path.is_relative_to(WEB) and path.is_file(): paths.add(path) return sorted(paths) def structural_inventory(paths: list[Path]) -> dict: """Report repetitions in their actual conditional scope; never reorder CSS.""" selectors = defaultdict(list) conditions = defaultdict(list) identical_rules = defaultdict(list) identical_blocks = defaultdict(list) properties = [] parse_errors = [] def walk(items, path, context=()): for item in items: location = f'{path.relative_to(ROOT)}:{item.source_line}' if item.type == 'error': parse_errors.append({'location': location, 'message': item.message}) elif item.type == 'qualified-rule': parts = selector_parts(item.prelude) declarations = [] property_locations = defaultdict(list) for declaration in tinycss2.parse_declaration_list(item.content, skip_whitespace=True, skip_comments=True): if declaration.type == 'error': parse_errors.append({'location': location, 'message': declaration.message}) elif declaration.type == 'declaration': name = declaration.name if declaration.name.startswith('--') else declaration.lower_name value = tinycss2.serialize(declaration.value).strip() declarations.append((name, value, declaration.important)) property_locations[name].append(declaration.source_line) for name, lines in property_locations.items(): if len(lines) > 1: properties.append({'location': location, 'selector': ', '.join(parts), 'property': name, 'lines': lines}) for selector in parts: selectors[(context, selector)].append(location) identical_rules[(context, tuple(parts), tuple(declarations))].append(location) if declarations: identical_blocks[(context, tuple(declarations))].append({'location': location, 'selectors': parts}) elif item.type == 'at-rule' and item.content is not None: prelude = re.sub(r'\s+', ' ', tinycss2.serialize(item.prelude).strip()) prelude = re.sub(r'\s*([():,])\s*', r'\1', prelude) label = f'@{item.lower_at_keyword} {prelude}' if item.lower_at_keyword in ('media', 'container', 'supports'): conditions[(context, label)].append(location) if item.lower_at_keyword in ('media', 'container', 'supports', 'layer', 'keyframes', '-webkit-keyframes'): walk(tinycss2.parse_rule_list(item.content, skip_whitespace=True, skip_comments=True), path, context + (label,)) for path in paths: walk(tinycss2.parse_stylesheet(path.read_text(encoding='utf-8'), skip_whitespace=True, skip_comments=True), path) repeated_bodies = [] for (scope, _), entries in identical_blocks.items(): if len(entries) < 2: continue families = [tuple(sorted({family for selector in entry['selectors'] for family in VENDOR_SELECTOR.findall(selector)})) for entry in entries] # A browser can reject an entire comma-separated selector list when it # contains another engine's pseudo-element. Keep one body per compatible # family, while still rejecting copies within the same family. vendor_only = (any(families) and all(len(family) <= 1 for family in families) and len(families) == len(set(families))) repeated_bodies.append({'scope': list(scope), 'rules': entries, 'vendor_compatibility_only': vendor_only}) return { 'repeated_selectors': [{'scope': list(scope), 'selector': selector, 'locations': locations} for (scope, selector), locations in selectors.items() if len(locations) > 1], 'repeated_conditions': [{'scope': list(scope), 'condition': condition, 'locations': locations} for (scope, condition), locations in conditions.items() if len(locations) > 1], 'identical_rules': [{'scope': list(scope), 'selectors': list(parts), 'locations': locations} for (scope, parts, _), locations in identical_rules.items() if len(locations) > 1], 'identical_declaration_blocks': repeated_bodies, 'repeated_properties': properties, 'parse_errors': parse_errors, } def source_words(scope: str) -> set[str]: # Rust's include_str! lists are the asset entry points; an abandoned JS file # beside a bundle must not make its obsolete selectors appear live. paths = embedded_javascript_sources() + 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: # Only positive class/id requirements identify review candidates. Runtime # interpolation still requires manual inspection before removing a rule. 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}) # Retain the historical JSON key for older audit consumers; # "dead" is a candidate flag, never proof of runtime absence. parts.append({'selector': selector, 'dead': dead, 'review_required': 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') cli.add_argument('--structure-json', type=Path, help='Write cross-module duplicate inventory JSON to this file') cli.add_argument('--strict', action='store_true', help='Fail on repeated panel selectors/conditions/rules/properties, compatible declaration blocks or parse errors') args = cli.parse_args() panel = panel_sources() orphan_scripts = sorted(set(WEB.rglob('*.js')) - set(embedded_javascript_sources())) if orphan_scripts: print('JavaScript outside embedded asset entry points (review):', ', '.join(str(path.relative_to(ROOT)) for path in orphan_scripts)) paths = [*panel, WEB / 'docs.css'] words = {'panel': source_words('panel'), 'docs': source_words('docs')} results = [audit_file(path, words['docs' if path.name == 'docs.css' else 'panel'], 'docs' if path.name == 'docs.css' else 'panel') for path in paths] 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']} selector branches requiring review, {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') structure = structural_inventory(panel) print('Panel structure:', ', '.join(f'{len(entries)} {name.replace("_", " ")}' for name, entries in structure.items())) if args.structure_json: args.structure_json.write_text(json.dumps(structure, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') failures = [name for name in ('repeated_selectors', 'repeated_conditions', 'identical_rules', 'repeated_properties', 'parse_errors') if structure[name]] if any(not group['vendor_compatibility_only'] for group in structure['identical_declaration_blocks']): failures.append('identical_declaration_blocks') if args.strict and failures: raise SystemExit('Panel CSS structure check failed: ' + ', '.join(failures)) if __name__ == '__main__': main()