278 lines
15 KiB
Python
Executable File
278 lines
15 KiB
Python
Executable File
#!/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'(?<![\w-])[a-zA-Z_][\w-]*(?![\w-])')
|
|
VENDOR_SELECTOR = re.compile(r'::?-(webkit|moz|ms|o)-[\w-]+')
|
|
|
|
|
|
def panel_sources() -> 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()
|