Coverage for little_loops / cli / verify_design_tokens.py: 30%
91 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:55 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:55 -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(f" {v.profile}/themes/{v.theme}.json: missing color groups: {groups}")
149 lines.append("")
150 lines.append("FAILED")
151 else:
152 lines.append("All inverting themes override every semantic color group.")
153 lines.append("")
154 lines.append("PASSED")
155 return "\n".join(lines)
158def _format_json_report(results: list[ProfileResult]) -> str:
159 return json.dumps(
160 {
161 "violations": [
162 {
163 "profile": v.profile,
164 "theme": v.theme,
165 "missing_groups": v.missing_groups,
166 }
167 for r in results
168 for v in r.violations
169 ],
170 "passed": not results,
171 },
172 indent=2,
173 )
176# ---------------------------------------------------------------------------
177# Entry point
178# ---------------------------------------------------------------------------
181def _find_profiles_dir(base_dir: Path) -> Path | None:
182 """Locate a design-token ``profiles/`` directory under *base_dir*.
184 Tries the source-repo bundled-template layout (editable + installed) and the
185 user-project ``.ll/design-tokens/profiles/`` layout.
186 """
187 for candidate in (
188 base_dir / "scripts" / "little_loops" / "templates" / "design-tokens" / "profiles",
189 base_dir / "little_loops" / "templates" / "design-tokens" / "profiles",
190 base_dir / ".ll" / "design-tokens" / "profiles",
191 ):
192 if candidate.is_dir():
193 return candidate
194 return None
197def main_verify_design_tokens() -> int:
198 """Entry point for ll-verify-design-tokens.
200 Returns 0 when no half-flipped themes are found; 1 otherwise.
202 Note: run against the bundled little-loops templates this currently flags
203 ``editorial-mono`` (a known-incomplete profile pending a follow-on); fix or
204 point ``--profiles-dir`` at a complete profile set to gate CI on exit 0.
205 """
206 with cli_event_context(DEFAULT_DB_PATH, "ll-verify-design-tokens", sys.argv[1:]):
207 parser = argparse.ArgumentParser(
208 prog="ll-verify-design-tokens",
209 description=(
210 "Lint design-token profiles for half-flipped themes — a theme that "
211 "inverts surface+text but leaves border/action falling through to "
212 "light-tuned semantic defaults."
213 ),
214 formatter_class=argparse.RawDescriptionHelpFormatter,
215 epilog="""\
216Examples:
217 %(prog)s # Auto-discover profiles dir from cwd
218 %(prog)s -C /path/to/root # Discover under a specific project root
219 %(prog)s --profiles-dir DIR # Lint a specific profiles directory
220 %(prog)s --json # Machine-readable JSON output
222Exit codes:
223 0 - Every inverting theme overrides all semantic color groups
224 1 - One or more half-flipped themes (or no profiles dir found)
225""",
226 )
227 parser.add_argument(
228 "-C",
229 "--directory",
230 type=Path,
231 default=None,
232 help="Project root to discover the profiles directory under (default: cwd)",
233 )
234 parser.add_argument(
235 "--profiles-dir",
236 type=Path,
237 default=None,
238 help="Explicit path to a design-token profiles/ directory (overrides -C discovery)",
239 )
240 parser.add_argument(
241 "--json",
242 action="store_true",
243 default=False,
244 help="Output results as JSON",
245 )
247 args = parser.parse_args()
249 profiles_dir = args.profiles_dir or _find_profiles_dir(args.directory or Path.cwd())
251 if profiles_dir is None or not profiles_dir.is_dir():
252 print(
253 "ERROR: design-token profiles directory not found "
254 f"(searched under {args.directory or Path.cwd()})",
255 file=sys.stderr,
256 )
257 return 1
259 results = lint_profiles_dir(profiles_dir)
261 if args.json:
262 print(_format_json_report(results))
263 else:
264 print(_format_text_report(results))
266 return 1 if results else 0