Coverage for little_loops / cli / verify_design_tokens.py: 98%
91 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
1"""ll-verify-design-tokens: Structural lint for half-flipped design-token themes.
3A design-token profile pairs a light-tuned ``semantic.json`` (defining the
4``surface``, ``text``, ``border`` and ``action`` color groups) with one or more
5``themes/<name>.json`` overrides. A *half-flipped* theme is one that inverts the
6foreground/background pair — overriding both ``surface`` **and** ``text`` to move
7onto a near-black (or near-white) surface — but leaves ``border``/``action`` (and
8any other semantic color group) falling through to the light-tuned defaults. The
9token-stamping path then renders those light values onto the inverted surface:
10harsh gridlines, muddy accents, and a ``danger == action.primary`` collision.
12This lint catches that class at authoring time. For every profile under the
13profiles directory it computes, for each theme that performs a full inversion
14(overrides ``surface`` and ``text``), the set of semantic color groups the theme
15fails to override. A non-empty set is a violation.
17A theme that does not invert the surface/text pair (e.g. a ``light.json`` that
18only restates ``surface``) is exempt — falling through to the light-tuned
19``semantic.json`` is correct for it.
21Exit 0 when every inverting theme is complete; exit 1 on any violation.
22"""
24from __future__ import annotations
26import argparse
27import json
28import sys
29from dataclasses import dataclass, field
30from pathlib import Path
32from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context
34# ---------------------------------------------------------------------------
35# Constants
36# ---------------------------------------------------------------------------
38# Theme is treated as a full foreground/background inversion (and therefore
39# required to override every semantic color group) only when it overrides BOTH
40# of these groups. A theme touching neither (or only one) is not inverting and
41# is exempt from the completeness requirement.
42_INVERSION_GROUPS: frozenset[str] = frozenset({"surface", "text"})
44# ---------------------------------------------------------------------------
45# Data types
46# ---------------------------------------------------------------------------
49@dataclass
50class ThemeViolation:
51 """One half-flipped theme: an inverting theme missing semantic color groups."""
53 profile: str
54 theme: str
55 missing_groups: list[str]
58@dataclass
59class ProfileResult:
60 """Lint result for a single profile directory."""
62 profile: str
63 violations: list[ThemeViolation] = field(default_factory=list)
65 @property
66 def has_violations(self) -> bool:
67 return bool(self.violations)
70# ---------------------------------------------------------------------------
71# Core lint logic
72# ---------------------------------------------------------------------------
75def _load_json(path: Path) -> dict:
76 """Load a JSON object, returning {} on absence or parse/type error."""
77 if not path.exists():
78 return {}
79 try:
80 data = json.loads(path.read_text(encoding="utf-8"))
81 except (OSError, json.JSONDecodeError):
82 return {}
83 return data if isinstance(data, dict) else {}
86def _color_groups(doc: dict) -> set[str]:
87 """Return the set of color-group keys (surface, text, border, action, ...)."""
88 color = doc.get("color")
89 return set(color.keys()) if isinstance(color, dict) else set()
92def lint_profile(profile_dir: Path) -> ProfileResult:
93 """Lint one profile directory for half-flipped themes.
95 For each ``themes/*.json`` that overrides both ``surface`` and ``text``
96 (a full inversion), report any semantic color group it fails to override.
97 """
98 result = ProfileResult(profile=profile_dir.name)
100 semantic_groups = _color_groups(_load_json(profile_dir / "semantic.json"))
101 if not semantic_groups:
102 return result # nothing to compare against
104 themes_dir = profile_dir / "themes"
105 if not themes_dir.is_dir():
106 return result
108 for theme_file in sorted(themes_dir.glob("*.json")):
109 theme_groups = _color_groups(_load_json(theme_file))
110 # Only inverting themes (override both surface and text) must be complete.
111 if not _INVERSION_GROUPS.issubset(theme_groups):
112 continue
113 missing = semantic_groups - theme_groups
114 if missing:
115 result.violations.append(
116 ThemeViolation(
117 profile=profile_dir.name,
118 theme=theme_file.stem,
119 missing_groups=sorted(missing),
120 )
121 )
123 return result
126def lint_profiles_dir(profiles_dir: Path) -> list[ProfileResult]:
127 """Lint every profile subdirectory under *profiles_dir*."""
128 results: list[ProfileResult] = []
129 for profile_dir in sorted(p for p in profiles_dir.iterdir() if p.is_dir()):
130 result = lint_profile(profile_dir)
131 if result.has_violations:
132 results.append(result)
133 return results
136# ---------------------------------------------------------------------------
137# Reporting
138# ---------------------------------------------------------------------------
141def _format_text_report(results: list[ProfileResult]) -> str:
142 lines: list[str] = ["Design-Token Theme Completeness Lint", "=" * 50, ""]
143 if results:
144 lines.append("Half-flipped themes (override surface+text but omit groups):")
145 for r in results:
146 for v in r.violations:
147 groups = ", ".join(v.missing_groups)
148 lines.append(
149 f" {v.profile}/themes/{v.theme}.json: missing color groups: {groups}"
150 )
151 lines.append("")
152 lines.append("FAILED")
153 else:
154 lines.append("All inverting themes override every semantic color group.")
155 lines.append("")
156 lines.append("PASSED")
157 return "\n".join(lines)
160def _format_json_report(results: list[ProfileResult]) -> str:
161 return json.dumps(
162 {
163 "violations": [
164 {
165 "profile": v.profile,
166 "theme": v.theme,
167 "missing_groups": v.missing_groups,
168 }
169 for r in results
170 for v in r.violations
171 ],
172 "passed": not results,
173 },
174 indent=2,
175 )
178# ---------------------------------------------------------------------------
179# Entry point
180# ---------------------------------------------------------------------------
183def _find_profiles_dir(base_dir: Path) -> Path | None:
184 """Locate a design-token ``profiles/`` directory under *base_dir*.
186 Tries the source-repo bundled-template layout (editable + installed) and the
187 user-project ``.ll/design-tokens/profiles/`` layout.
188 """
189 for candidate in (
190 base_dir / "scripts" / "little_loops" / "templates" / "design-tokens" / "profiles",
191 base_dir / "little_loops" / "templates" / "design-tokens" / "profiles",
192 base_dir / ".ll" / "design-tokens" / "profiles",
193 ):
194 if candidate.is_dir():
195 return candidate
196 return None
199def main_verify_design_tokens() -> int:
200 """Entry point for ll-verify-design-tokens.
202 Returns 0 when no half-flipped themes are found; 1 otherwise.
204 Note: run against the bundled little-loops templates this currently flags
205 ``editorial-mono`` (a known-incomplete profile pending a follow-on); fix or
206 point ``--profiles-dir`` at a complete profile set to gate CI on exit 0.
207 """
208 with cli_event_context(DEFAULT_DB_PATH, "ll-verify-design-tokens", sys.argv[1:]):
209 parser = argparse.ArgumentParser(
210 prog="ll-verify-design-tokens",
211 description=(
212 "Lint design-token profiles for half-flipped themes — a theme that "
213 "inverts surface+text but leaves border/action falling through to "
214 "light-tuned semantic defaults."
215 ),
216 formatter_class=argparse.RawDescriptionHelpFormatter,
217 epilog="""\
218Examples:
219 %(prog)s # Auto-discover profiles dir from cwd
220 %(prog)s -C /path/to/root # Discover under a specific project root
221 %(prog)s --profiles-dir DIR # Lint a specific profiles directory
222 %(prog)s --json # Machine-readable JSON output
224Exit codes:
225 0 - Every inverting theme overrides all semantic color groups
226 1 - One or more half-flipped themes (or no profiles dir found)
227""",
228 )
229 parser.add_argument(
230 "-C",
231 "--directory",
232 type=Path,
233 default=None,
234 help="Project root to discover the profiles directory under (default: cwd)",
235 )
236 parser.add_argument(
237 "--profiles-dir",
238 type=Path,
239 default=None,
240 help="Explicit path to a design-token profiles/ directory (overrides -C discovery)",
241 )
242 parser.add_argument(
243 "--json",
244 action="store_true",
245 default=False,
246 help="Output results as JSON",
247 )
249 args = parser.parse_args()
251 profiles_dir = args.profiles_dir or _find_profiles_dir(args.directory or Path.cwd())
253 if profiles_dir is None or not profiles_dir.is_dir():
254 print(
255 "ERROR: design-token profiles directory not found "
256 f"(searched under {args.directory or Path.cwd()})",
257 file=sys.stderr,
258 )
259 return 1
261 results = lint_profiles_dir(profiles_dir)
263 if args.json:
264 print(_format_json_report(results))
265 else:
266 print(_format_text_report(results))
268 return 1 if results else 0