v0.34.5
This commit is contained in:
1 parent
3776edf4fc
commit
7cfc3a6da1
35 files changed
+1377
-875
No files matched your search
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/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'(?<![\w-])[a-zA-Z_][\w-]*(?![\w-])')
|
||||
|
||||
|
||||
def source_words(scope: str) -> 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()
|
||||
Reference in new issue
Block a user