"""INV-UI-1: no-increase ratchet on purple UI literals in changed files.
Compares purple-hit counts in changed UI sources against the merge base.
Existing debt may remain; introducing new purple (raising a file's count, or
adding purple in a new file) fails.
Allowlist: paths in ALLOWLIST_FILES are skipped (document why in a comment here).
"""
from __future__ import annotations
import argparse
import colorsys
import re
import subprocess
import sys
from pathlib import Path
UI_ROOTS = (
"desktop/macos/Desktop/Sources/",
"app/lib/",
"web/",
)
UI_SUFFIXES = {".swift", ".dart", ".ts", ".tsx", ".js", ".jsx", ".css"}
SKIP_PARTS = {".git", "node_modules", "build", "dist", ".next", "__pycache__"}
ALLOWLIST_FILES: set[str] = {
"desktop/macos/Desktop/Sources/Theme/OmiColors.swift",
}
PURPLE_HEX_HUES = "7C3AED|8B5CF6|A855F7|9333EA|6D28D9|AF52DE|D946EF|A78BFA|C4B5FD"
PURPLE_PATTERNS = [
re.compile(r"Color\.purple\b"),
re.compile(r"\.purple\b"),
re.compile(r"Colors\.purple\b"),
re.compile(r"\bdeepPurple(?:Accent)?\b"),
re.compile(rf"#(?:{PURPLE_HEX_HUES})\b", re.I),
re.compile(rf"(?<![0-9A-Za-z_])0x(?:[0-9A-F]{{2}})?(?:{PURPLE_HEX_HUES})\b", re.I),
re.compile(r"purple(?:Primary|Secondary|Accent|Light|Gradient|LightGradient)\b"),
re.compile(r"purple-(?:primary|secondary|accent|\d{2,4})\b"),
re.compile(r"--purple-"),
re.compile(r"""['"]Purple['"]"""),
re.compile(r"""\bpurple\b""", re.I),
re.compile(r"\b(?:violet|indigo|fuchsia)-\d{2,4}\b"),
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--changed-files", required=True, help="File listing changed paths.")
parser.add_argument(
"--base",
default=None,
help="Git ref for the merge base content (required for ratchet vs base).",
)
parser.add_argument("--root", default=".", help="Repository root.")
return parser.parse_args()
def is_ui_source(path: str) -> bool:
if path in ALLOWLIST_FILES:
return False
if not any(path.startswith(prefix) for prefix in UI_ROOTS):
return False
if Path(path).suffix not in UI_SUFFIXES:
return False
parts = set(Path(path).parts)
if parts & SKIP_PARTS:
return False
return True
HEX_LITERAL = re.compile(r"(?<![0-9A-Za-z_])(?:#|0x(?:[0-9A-Fa-f]{2})?)([0-9A-Fa-f]{6})\b")
PURPLE_HUE_RANGE = (235.0, 320.0)
MIN_SATURATION = 0.25
MIN_VALUE = 0.20
def is_purple_hex(digits: str) -> bool:
r, g, b = (int(digits[i : i + 2], 16) / 255 for i in (0, 2, 4))
h, s, v = colorsys.rgb_to_hsv(r, g, b)
if s < MIN_SATURATION or v < MIN_VALUE:
return False
low, high = PURPLE_HUE_RANGE
return low <= h * 360 <= high
def count_purple(text: str) -> int:
hits = sum(len(p.findall(text)) for p in PURPLE_PATTERNS)
hits += sum(1 for m in HEX_LITERAL.finditer(text) if is_purple_hex(m.group(1)))
return hits
def git_show(ref: str, path: str) -> str | None:
try:
return subprocess.check_output(["git", "show", f"{ref}:{path}"], text=True, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError:
return None
def main() -> int:
args = parse_args()
root = Path(args.root).resolve()
changed = [
line.strip()
for line in Path(args.changed_files).read_text(encoding="utf-8").splitlines()
if line.strip() and is_ui_source(line.strip())
]
if not changed:
print("OK: no UI sources in changed files for INV-UI-1.")
return 0
if not args.base:
print("FAIL: --base is required for the brand UI ratchet.")
return 1
regressions: list[str] = []
for path in changed:
head_file = root / path
if not head_file.is_file():
continue
head_text = head_file.read_text(encoding="utf-8", errors="ignore")
head_count = count_purple(head_text)
base_text = git_show(args.base, path)
base_count = count_purple(base_text) if base_text is not None else 0
if head_count > base_count:
regressions.append(f"{path}: purple hits {base_count} → {head_count}")
if regressions:
print("FAIL: INV-UI-1 — purple usage increased in changed UI files.")
print("Purple is off-brand. Use white/neutral accents. See product/invariants/brand-ui.md")
for line in regressions:
print(f" - {line}")
return 1
print(f"OK: INV-UI-1 — no purple increase across {len(changed)} changed UI file(s).")
return 0
if __name__ == "__main__":
sys.exit(main())