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

1"""ll-verify-design-tokens: Structural lint for half-flipped design-token themes. 

2 

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. 

11 

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. 

16 

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. 

20 

21Exit 0 when every inverting theme is complete; exit 1 on any violation. 

22""" 

23 

24from __future__ import annotations 

25 

26import argparse 

27import json 

28import sys 

29from dataclasses import dataclass, field 

30from pathlib import Path 

31 

32from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context 

33 

34# --------------------------------------------------------------------------- 

35# Constants 

36# --------------------------------------------------------------------------- 

37 

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"}) 

43 

44# --------------------------------------------------------------------------- 

45# Data types 

46# --------------------------------------------------------------------------- 

47 

48 

49@dataclass 

50class ThemeViolation: 

51 """One half-flipped theme: an inverting theme missing semantic color groups.""" 

52 

53 profile: str 

54 theme: str 

55 missing_groups: list[str] 

56 

57 

58@dataclass 

59class ProfileResult: 

60 """Lint result for a single profile directory.""" 

61 

62 profile: str 

63 violations: list[ThemeViolation] = field(default_factory=list) 

64 

65 @property 

66 def has_violations(self) -> bool: 

67 return bool(self.violations) 

68 

69 

70# --------------------------------------------------------------------------- 

71# Core lint logic 

72# --------------------------------------------------------------------------- 

73 

74 

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 {} 

84 

85 

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() 

90 

91 

92def lint_profile(profile_dir: Path) -> ProfileResult: 

93 """Lint one profile directory for half-flipped themes. 

94 

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) 

99 

100 semantic_groups = _color_groups(_load_json(profile_dir / "semantic.json")) 

101 if not semantic_groups: 

102 return result # nothing to compare against 

103 

104 themes_dir = profile_dir / "themes" 

105 if not themes_dir.is_dir(): 

106 return result 

107 

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 ) 

122 

123 return result 

124 

125 

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 

134 

135 

136# --------------------------------------------------------------------------- 

137# Reporting 

138# --------------------------------------------------------------------------- 

139 

140 

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) 

158 

159 

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 ) 

176 

177 

178# --------------------------------------------------------------------------- 

179# Entry point 

180# --------------------------------------------------------------------------- 

181 

182 

183def _find_profiles_dir(base_dir: Path) -> Path | None: 

184 """Locate a design-token ``profiles/`` directory under *base_dir*. 

185 

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 

197 

198 

199def main_verify_design_tokens() -> int: 

200 """Entry point for ll-verify-design-tokens. 

201 

202 Returns 0 when no half-flipped themes are found; 1 otherwise. 

203 

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 

223 

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 ) 

248 

249 args = parser.parse_args() 

250 

251 profiles_dir = args.profiles_dir or _find_profiles_dir(args.directory or Path.cwd()) 

252 

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 

260 

261 results = lint_profiles_dir(profiles_dir) 

262 

263 if args.json: 

264 print(_format_json_report(results)) 

265 else: 

266 print(_format_text_report(results)) 

267 

268 return 1 if results else 0