Files
wehub-resource-sync f4ed7901c3
Module Market Check / Validate modules/ (push) Failing after 1s
Android CI Build / check (push) Failing after 1s
Module Market Publish / Generate submissions.json (push) Failing after 1s
Android CI Build / package-apks (push) Has been skipped
chore: import upstream snapshot with attribution
2026-07-13 12:29:44 +08:00

279 lines
9.0 KiB
Python

#!/usr/bin/env python3
"""Audit Compose UI files for design-system migration debt.
This script is intentionally non-failing by default. It reports legacy patterns
so migrations can reduce the count over time without blocking unrelated work.
Use --enforce-baseline with .github/scripts/ui_design_allowlist.txt to block new debt
while allowing documented historical debt to remain during migration.
"""
from __future__ import annotations
import argparse
import re
from collections import Counter, defaultdict
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_TARGETS = (
REPO_ROOT / "app/src/main/java/com/webtoapp/ui/screens",
REPO_ROOT / "app/src/main/java/com/webtoapp/ui/components",
)
DEFAULT_ALLOWLIST = REPO_ROOT / ".github/scripts/ui_design_allowlist.txt"
@dataclass(frozen=True)
class Rule:
key: str
label: str
pattern: re.Pattern[str]
RULES = (
Rule(
key="hardcoded_color",
label="Direct Color(0x...)",
pattern=re.compile(r"\bColor\s*\(\s*0x[0-9A-Fa-f_]+"),
),
Rule(
key="raw_corner_shape",
label="Raw RoundedCornerShape(dp)",
pattern=re.compile(r"\bRoundedCornerShape\s*\(\s*\d+(?:\.\d+)?\.dp"),
),
Rule(
key="raw_card",
label="Raw Card/ElevatedCard/OutlinedCard",
pattern=re.compile(r"(?<!Enhanced)(?<!Wta)\b(?:Card|ElevatedCard|OutlinedCard)\s*\("),
),
Rule(
key="raw_surface",
label="Raw Surface",
pattern=re.compile(r"(?<!Wta)\bSurface\s*\("),
),
Rule(
key="raw_button",
label="Raw Button",
pattern=re.compile(r"(?<!Icon)(?<!Text)(?<!Outlined)(?<!Tonal)(?<!Premium)(?<!Wta)\bButton\s*\("),
),
)
def iter_kotlin_files(target: Path) -> list[Path]:
if target.is_file():
return [target]
return sorted(target.rglob("*.kt"))
def resolve_target(target_text: str) -> Path:
target = Path(target_text)
if not target.is_absolute():
target = REPO_ROOT / target
return target
def display_targets(targets: list[Path]) -> str:
labels = []
for target in targets:
labels.append(
str(target.relative_to(REPO_ROOT))
if target.is_relative_to(REPO_ROOT)
else str(target)
)
return ", ".join(labels)
def strip_line_comment(line: str) -> str:
return line.split("//", 1)[0]
def audit_file(path: Path) -> dict[str, list[int]]:
findings: dict[str, list[int]] = defaultdict(list)
try:
lines = path.read_text(encoding="utf-8").splitlines()
except UnicodeDecodeError:
lines = path.read_text(errors="ignore").splitlines()
for line_no, line in enumerate(lines, start=1):
candidate = strip_line_comment(line)
for rule in RULES:
if rule.pattern.search(candidate):
findings[rule.key].append(line_no)
return findings
def relative_repo_path(path: Path) -> str:
if path.is_relative_to(REPO_ROOT):
return path.relative_to(REPO_ROOT).as_posix()
return path.as_posix()
def load_allowlist(path: Path) -> dict[str, Counter[str]]:
allowlist: dict[str, Counter[str]] = defaultdict(Counter)
if not path.exists():
return allowlist
for line_no, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
parts = line.split("|")
if len(parts) != 3:
raise ValueError(f"Invalid allowlist line {line_no}: {raw_line}")
rel_path, rule_key, count_text = parts
if rule_key not in {rule.key for rule in RULES}:
raise ValueError(f"Unknown rule '{rule_key}' on allowlist line {line_no}")
try:
count = int(count_text)
except ValueError as exc:
raise ValueError(f"Invalid count '{count_text}' on allowlist line {line_no}") from exc
if count < 0:
raise ValueError(f"Negative count on allowlist line {line_no}")
allowlist[rel_path][rule_key] = count
return allowlist
def write_allowlist(path: Path, current_counts: dict[str, Counter[str]]) -> None:
lines = [
"# WebToApp UI design-system historical debt baseline.",
"# Format: relative/path.kt|rule_key|allowed_count",
"# Regenerate only after intentionally accepting a new migration checkpoint.",
]
for rel_path in sorted(current_counts):
counts = current_counts[rel_path]
for rule in RULES:
count = counts[rule.key]
if count > 0:
lines.append(f"{rel_path}|{rule.key}|{count}")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def find_baseline_violations(
current_counts: dict[str, Counter[str]],
allowlist: dict[str, Counter[str]],
) -> list[tuple[str, str, int, int]]:
violations: list[tuple[str, str, int, int]] = []
for rel_path in sorted(current_counts):
counts = current_counts[rel_path]
allowed = allowlist.get(rel_path, Counter())
for rule in RULES:
current = counts[rule.key]
baseline = allowed[rule.key]
if current > baseline:
violations.append((rel_path, rule.key, current, baseline))
return violations
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"targets",
nargs="*",
help="Files or directories to scan. Defaults to app ui/screens and ui/components.",
)
parser.add_argument(
"--fail-on-findings",
action="store_true",
help="Exit with status 1 if any finding is detected.",
)
parser.add_argument(
"--enforce-baseline",
action="store_true",
help="Exit with status 1 only when current findings exceed the allowlist baseline.",
)
parser.add_argument(
"--allowlist",
default=str(DEFAULT_ALLOWLIST),
help="Baseline allowlist file for --enforce-baseline.",
)
parser.add_argument(
"--write-allowlist",
help="Write the current finding counts as an allowlist baseline.",
)
parser.add_argument(
"--top",
type=int,
default=20,
help="Number of highest-debt files to print.",
)
args = parser.parse_args()
targets = [resolve_target(target) for target in args.targets] if args.targets else list(DEFAULT_TARGETS)
files = sorted(
{file_path.resolve() for target in targets for file_path in iter_kotlin_files(target)}
)
totals: Counter[str] = Counter()
per_file: list[tuple[Path, Counter[str], dict[str, list[int]]]] = []
current_counts: dict[str, Counter[str]] = {}
for file_path in files:
findings = audit_file(file_path)
counts = Counter({key: len(lines) for key, lines in findings.items()})
if counts:
totals.update(counts)
per_file.append((file_path, counts, findings))
current_counts[relative_repo_path(file_path)] = counts
print("WebToApp UI design-system audit")
print(f"Targets: {display_targets(targets)}")
print(f"Files scanned: {len(files)}")
print()
if args.write_allowlist:
write_path = Path(args.write_allowlist)
if not write_path.is_absolute():
write_path = REPO_ROOT / write_path
write_allowlist(write_path, current_counts)
print(f"Allowlist written: {relative_repo_path(write_path)}")
print()
if not totals:
print("No findings.")
return 0
print("Totals:")
for rule in RULES:
print(f" {rule.label}: {totals[rule.key]}")
print()
print(f"Top {args.top} files:")
per_file.sort(key=lambda item: sum(item[1].values()), reverse=True)
for file_path, counts, findings in per_file[: args.top]:
rel = file_path.relative_to(REPO_ROOT)
total = sum(counts.values())
detail = ", ".join(
f"{rule.key}={counts[rule.key]}"
for rule in RULES
if counts[rule.key]
)
first_lines = []
for rule in RULES:
lines = findings.get(rule.key)
if lines:
first_lines.append(f"{rule.key}@{','.join(map(str, lines[:5]))}")
print(f" {rel}: {total} ({detail})")
print(f" first: {'; '.join(first_lines)}")
if args.enforce_baseline:
allowlist_path = Path(args.allowlist)
if not allowlist_path.is_absolute():
allowlist_path = REPO_ROOT / allowlist_path
allowlist = load_allowlist(allowlist_path)
violations = find_baseline_violations(current_counts, allowlist)
print()
if violations:
print("Baseline enforcement failed:")
for rel_path, rule_key, current, baseline in violations[:50]:
print(f" {rel_path}|{rule_key}: current={current}, allowed={baseline}")
return 1
print("Baseline enforcement passed.")
return 1 if args.fail_on_findings else 0
if __name__ == "__main__":
raise SystemExit(main())