a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
184 lines
6.3 KiB
Python
Executable File
184 lines
6.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Design-system discipline linter for the jcode iOS SwiftUI sources.
|
|
|
|
Screenshots grade the *output*; this grades the *source*. A consistent UI comes
|
|
from routing every color, font, and spacing value through the design tokens in
|
|
Theme.swift instead of sprinkling magic numbers. This linter scans the SwiftUI
|
|
views and reports, per file and overall, a 0-100 discipline score plus the
|
|
exact offending lines so regressions are actionable.
|
|
|
|
Checks (each contributes to the score):
|
|
raw_color Color(red:..) / Color(.sRGB..) / Color(white:..) literals and
|
|
.opacity() on non-token colors that should be Theme tokens.
|
|
raw_font .font(.system(size:..)) literals instead of Theme.mono(..).
|
|
magic_radius cornerRadius: <literal> values not drawn from a small scale.
|
|
magic_pad .padding(<literal>) values off the 4pt spacing grid.
|
|
long_view view files / body blocks past a size budget (split them).
|
|
|
|
Usage:
|
|
python3 ui_lint.py [--root Sources/JCodeMobile] [--json] [--max-issues N]
|
|
Exit code is non-zero when the overall score drops below --min (default 0,
|
|
i.e. report-only); raise --min in CI once the baseline is cleaned up.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass, field, asdict
|
|
from pathlib import Path
|
|
|
|
# Allowed spacing values (4pt grid + a few common insets). Bypass with Theme.
|
|
SPACING_GRID = {0, 2, 4, 6, 8, 10, 12, 14, 16, 20, 24, 32, 40, 48, 56, 64}
|
|
RADIUS_SCALE = {0, 8, 10, 12, 14, 16, 20, 999} # 999 ~ Capsule-ish; allow round
|
|
|
|
RAW_COLOR_RE = re.compile(
|
|
r"Color\(\s*(red|white|\.sRGB|hue|displayP3)", re.IGNORECASE)
|
|
RAW_HEX_IN_VIEW_RE = re.compile(r"Color\(hex:")
|
|
SYSTEM_FONT_RE = re.compile(r"\.system\(\s*size:")
|
|
CORNER_RADIUS_RE = re.compile(r"cornerRadius:\s*([0-9]+(?:\.[0-9]+)?)")
|
|
PADDING_RE = re.compile(r"\.padding\(\s*([0-9]+(?:\.[0-9]+)?)\s*\)")
|
|
PADDING_EDGE_RE = re.compile(r"\.padding\(\s*\.[a-z]+,\s*([0-9]+(?:\.[0-9]+)?)\s*\)")
|
|
|
|
# Penalty weight per issue category (points off per occurrence, capped).
|
|
WEIGHTS = {
|
|
"raw_color": 6,
|
|
"raw_font": 4,
|
|
"magic_radius": 2,
|
|
"magic_pad": 1,
|
|
"long_view": 5,
|
|
}
|
|
BODY_LINE_BUDGET = 220 # a single view file beyond this likely needs splitting
|
|
|
|
|
|
@dataclass
|
|
class Issue:
|
|
file: str
|
|
line: int
|
|
kind: str
|
|
text: str
|
|
|
|
|
|
@dataclass
|
|
class Report:
|
|
score: float
|
|
issues: list = field(default_factory=list)
|
|
per_file: dict = field(default_factory=dict)
|
|
counts: dict = field(default_factory=dict)
|
|
|
|
def render(self, max_issues=40):
|
|
out = ["Design-system discipline", "=" * 40]
|
|
out.append(f" score {self.score:5.1f}/100 ({sum(self.counts.values())} issues)")
|
|
out.append(" by category:")
|
|
for k in WEIGHTS:
|
|
out.append(f" {k:14} {self.counts.get(k, 0)}")
|
|
out.append(" by file:")
|
|
for f, s in sorted(self.per_file.items(), key=lambda kv: kv[1]):
|
|
out.append(f" {s:5.1f} {f}")
|
|
if self.issues:
|
|
out.append("-" * 40)
|
|
for i in self.issues[:max_issues]:
|
|
out.append(f" {i.file}:{i.line} [{i.kind}] {i.text.strip()[:70]}")
|
|
if len(self.issues) > max_issues:
|
|
out.append(f" ... +{len(self.issues) - max_issues} more")
|
|
return "\n".join(out)
|
|
|
|
|
|
def is_token_file(path: Path) -> bool:
|
|
return path.name == "Theme.swift"
|
|
|
|
|
|
def lint_file(path: Path):
|
|
issues = []
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
lines = text.splitlines()
|
|
token_file = is_token_file(path)
|
|
|
|
for n, line in enumerate(lines, 1):
|
|
stripped = line.strip()
|
|
if stripped.startswith("//"):
|
|
continue
|
|
|
|
if not token_file:
|
|
if RAW_COLOR_RE.search(line) or RAW_HEX_IN_VIEW_RE.search(line):
|
|
issues.append(Issue(path.name, n, "raw_color", line))
|
|
if SYSTEM_FONT_RE.search(line) and "Theme" not in line:
|
|
issues.append(Issue(path.name, n, "raw_font", line))
|
|
|
|
m = CORNER_RADIUS_RE.search(line)
|
|
if m and float(m.group(1)) not in RADIUS_SCALE and not token_file:
|
|
issues.append(Issue(path.name, n, "magic_radius", line))
|
|
|
|
for rx in (PADDING_RE, PADDING_EDGE_RE):
|
|
pm = rx.search(line)
|
|
if pm:
|
|
val = float(pm.group(1))
|
|
if val not in SPACING_GRID:
|
|
issues.append(Issue(path.name, n, "magic_pad", line))
|
|
|
|
if not token_file and len(lines) > BODY_LINE_BUDGET:
|
|
issues.append(
|
|
Issue(path.name, len(lines), "long_view",
|
|
f"{len(lines)} lines > {BODY_LINE_BUDGET} budget"))
|
|
return issues, len(lines)
|
|
|
|
|
|
def file_score(issues):
|
|
penalty = sum(WEIGHTS[i.kind] for i in issues)
|
|
return max(0.0, 100.0 - penalty)
|
|
|
|
|
|
def lint_root(root: Path):
|
|
issues = []
|
|
per_file = {}
|
|
files = sorted(root.rglob("*.swift"))
|
|
for f in files:
|
|
fi, _ = lint_file(f)
|
|
issues.extend(fi)
|
|
per_file[str(f.relative_to(root))] = round(file_score(fi), 1)
|
|
|
|
counts = {}
|
|
for i in issues:
|
|
counts[i.kind] = counts.get(i.kind, 0) + 1
|
|
|
|
# Overall: penalty-weighted, normalized by file count so adding clean files
|
|
# doesn't dilute the signal; clamp to [0,100].
|
|
total_penalty = sum(WEIGHTS[i.kind] for i in issues)
|
|
denom = max(1, len(files))
|
|
score = max(0.0, 100.0 - total_penalty / denom)
|
|
return Report(score=round(score, 1), issues=issues,
|
|
per_file=per_file, counts=counts)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
default_root = Path(__file__).resolve().parent.parent / "Sources" / "JCodeMobile"
|
|
ap.add_argument("--root", default=str(default_root))
|
|
ap.add_argument("--json", action="store_true")
|
|
ap.add_argument("--max-issues", type=int, default=40)
|
|
ap.add_argument("--min", type=float, default=0.0,
|
|
help="fail (exit 1) if score < this")
|
|
args = ap.parse_args()
|
|
|
|
root = Path(args.root)
|
|
if not root.exists():
|
|
print(f"no such root: {root}", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
report = lint_root(root)
|
|
if args.json:
|
|
print(json.dumps({
|
|
"score": report.score,
|
|
"counts": report.counts,
|
|
"per_file": report.per_file,
|
|
"issues": [asdict(i) for i in report.issues],
|
|
}, indent=2))
|
|
else:
|
|
print(report.render(args.max_issues))
|
|
|
|
sys.exit(1 if report.score < args.min else 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|