magicciv/tools/check-ui-color-sources.py
Natalie 0c2d7c6d4a feat(@projects/@magic-civilization): colour-SoT coverage gate locks in the migration (p2-87)
Add tools/check-ui-color-sources.py: fails if a hardcoded numeric Color()/Color8()
is applied to a widget in a scene (add_theme_*_override / StyleBox *_color).
Allows computed Color(accent.r,…), transparent, named constants, and var-init
fallbacks; excludes scenes/tests + the 3 precursor deletion files. Passes clean
on live scenes (exit 0). Wired into ./run verify as step 17 so a hardcoded
colour can't creep back in.

Capstone for the override→inheritance / single-colour-system work: colours in
live scenes now provably come from the design-token source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:27:56 -05:00

85 lines
3.3 KiB
Python
Executable file

#!/usr/bin/env python3
"""Coverage gate for the single-colour-system (p2-87).
Fails if a HARDCODED colour is *applied* to a widget in a scene script — i.e. a
numeric `Color(r,g,b[,a])` (or `Color8(...)`) literal passed to
`add_theme_*_override(...)` or assigned to a StyleBox `*_color`. Those must come
from the design-token source via `ThemeAssets.color("...")` instead.
NOT flagged (legitimate):
* Computed colours — `Color(accent.r, accent.g, accent.b, 0.5)` (non-numeric
first arg) — derived from a base colour at runtime.
* Fully transparent — `Color(0, 0, 0, 0)` / alpha 0 — an intentional no-fill.
* Named constants — `Color.WHITE`, `Color.BLACK` — contrast strokes.
* `var`-initialiser fallbacks — `var _x: Color = Color(...)` — token-seeded in
_ready(); not an *applied* colour.
* Anything under scenes/tests/ (proof/test visualisations).
Usage:
tools/check-ui-color-sources.py # report; exit 1 if violations
tools/check-ui-color-sources.py --list # list every violation site
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SCENES = REPO_ROOT / "src" / "game" / "engine" / "scenes"
# An applied colour: override call or stylebox colour assignment whose value is
# a numeric Color(...) / Color8(...) literal (first arg starts with a digit/dot
# or 0x — i.e. NOT an identifier like `accent.r`).
_APPLIED = re.compile(
r"""(?x)
(?:
add_theme_(?:color|stylebox)_override\([^,]+,\s* # override(..., <val>)
| \.\w*color\s*=\s* # .bg_color = / .border_color =
)
Color8?\(\s*(0x[0-9a-fA-F]|[0-9.]) # numeric/hex literal
"""
)
# Transparent literal — allowed (intentional no-fill). Matches 0 or 0.0 args.
_Z = r"0(?:\.0+)?"
_TRANSPARENT = re.compile(rf"Color\(\s*{_Z}\s*,\s*{_Z}\s*,\s*{_Z}\s*,\s*{_Z}\s*\)")
# Precursor scenes slated for deletion (Commandment-9, p2-47/p2-48) — their
# hardcoded colours die with the files; not worth tokenising. Excluded here so
# the gate reflects live-scene truth. Remove these entries when the files go.
_DOOMED = {
"overviews/demographics.gd",
"overviews/end_game_stats.gd",
"overviews/victory_screen.gd",
}
def violations() -> list[tuple[str, int, str]]:
out: list[tuple[str, int, str]] = []
for gd in sorted(SCENES.rglob("*.gd")):
if "/tests/" in gd.as_posix():
continue
if gd.relative_to(SCENES).as_posix() in _DOOMED:
continue
for n, line in enumerate(gd.read_text(encoding="utf-8").splitlines(), 1):
if _APPLIED.search(line) and not _TRANSPARENT.search(line):
out.append((str(gd.relative_to(REPO_ROOT)), n, line.strip()))
return out
def main() -> int:
found = violations()
show = "--list" in sys.argv
if not found:
print("OK: no hardcoded applied colours in scenes/ — all from the token source (p2-87).")
return 0
print(f"FAIL: {len(found)} hardcoded applied colour(s) in scene scripts (use ThemeAssets.color):")
for path, n, text in (found if show else found[:20]):
print(f" {path}:{n}: {text}")
if not show and len(found) > 20:
print(f" … and {len(found) - 20} more (run with --list)")
return 1
if __name__ == "__main__":
raise SystemExit(main())