#!/usr/bin/env python3 """ DSS Theme Validation Script. Validates that themes only override tokens defined in the skin contract. Usage: python3 scripts/validate-theme.py [--theme THEME_NAME] [--skin SKIN_NAME] Defaults to validating all themes against the skin contract. """ import json import sys from pathlib import Path from typing import Dict, List, Set, Tuple DSS_ROOT = Path(__file__).parent.parent DSS_DATA = DSS_ROOT / ".dss" def load_json(path: Path) -> dict: """Load JSON file.""" if not path.exists(): return {} with open(path) as f: return json.load(f) def get_contract_tokens(contract: dict) -> Dict[str, Set[str]]: """Extract required token names from contract by category.""" required = contract.get("required_tokens", {}) result = {} for category, data in required.items(): if isinstance(data, dict) and "required" in data: result[category] = set(data["required"]) return result def get_theme_tokens(theme: dict) -> Dict[str, Set[str]]: """Extract token names from theme by category.""" result = {} for key, value in theme.items(): if key.startswith("_"): continue if isinstance(value, dict): # Check if it's a token (has 'value' key) or a category if "value" in value: # Single token at root level if "root" not in result: result["root"] = set() result["root"].add(key) else: # Category with nested tokens tokens = set() for token_name, token_data in value.items(): if isinstance(token_data, dict): tokens.add(token_name) if tokens: result[key] = tokens return result def get_skin_tokens(skin: dict) -> Dict[str, Set[str]]: """Extract token names from skin by category.""" return get_theme_tokens(skin) # Same structure def validate_theme( theme_path: Path, contract_path: Path, skin_path: Path = None ) -> Tuple[bool, List[str], List[str]]: """ Validate a theme against the skin contract. Returns: (is_valid, errors, warnings) """ errors = [] warnings = [] contract = load_json(contract_path) theme = load_json(theme_path) if not contract: errors.append(f"Contract not found: {contract_path}") return False, errors, warnings if not theme: errors.append(f"Theme not found: {theme_path}") return False, errors, warnings contract_tokens = get_contract_tokens(contract) theme_tokens = get_theme_tokens(theme) # Load skin if provided for additional context skin_tokens = {} if skin_path and skin_path.exists(): skin = load_json(skin_path) skin_tokens = get_skin_tokens(skin) # Check each category in the theme for category, tokens in theme_tokens.items(): # Handle dark mode variants base_category = category.replace("-dark", "") if base_category not in contract_tokens: # Category not in contract - check if skin provides it if base_category in skin_tokens: warnings.append( f"Category '{category}' not in contract but exists in skin. " f"Consider adding to contract for stability." ) else: errors.append( f"Category '{category}' is not defined in the skin contract. " f"Theme should only override contract-defined tokens." ) continue # Check each token in the category contract_category = contract_tokens[base_category] for token in tokens: if token not in contract_category: # Token not in contract if skin_tokens.get(base_category) and token in skin_tokens[base_category]: warnings.append( f"Token '{category}.{token}' exists in skin but not in contract. " f"May break on skin updates." ) else: errors.append( f"Token '{category}.{token}' is not in the skin contract. " f"Valid tokens: {sorted(contract_category)}" ) is_valid = len(errors) == 0 return is_valid, errors, warnings def validate_skin(skin_path: Path, contract_path: Path) -> Tuple[bool, List[str], List[str]]: """ Validate that a skin provides all required contract tokens. Returns: (is_valid, errors, warnings) """ errors = [] warnings = [] contract = load_json(contract_path) skin = load_json(skin_path) if not contract: errors.append(f"Contract not found: {contract_path}") return False, errors, warnings if not skin: errors.append(f"Skin not found: {skin_path}") return False, errors, warnings contract_tokens = get_contract_tokens(contract) skin_tokens = get_skin_tokens(skin) # Check all required categories exist for category, required in contract_tokens.items(): if category not in skin_tokens: errors.append( f"Skin missing required category: '{category}'. " f"Required tokens: {sorted(required)}" ) continue # Check all required tokens exist skin_category = skin_tokens[category] missing = required - skin_category if missing: errors.append(f"Skin missing required tokens in '{category}': {sorted(missing)}") # Note extra tokens (not an error, just info) extra = skin_category - required if extra: warnings.append(f"Skin has extra tokens in '{category}' (OK): {sorted(extra)}") is_valid = len(errors) == 0 return is_valid, errors, warnings def main(): import argparse parser = argparse.ArgumentParser(description="Validate DSS themes and skins") parser.add_argument("--theme", help="Theme name to validate (default: all)") parser.add_argument("--skin", help="Skin name to validate (default: shadcn)") parser.add_argument( "--validate-skin", action="store_true", help="Validate skin against contract" ) parser.add_argument("--quiet", "-q", action="store_true", help="Only show errors") args = parser.parse_args() contract_path = DSS_DATA / "schema" / "skin-contract.json" print("=" * 60) print("DSS THEME/SKIN VALIDATION") print("=" * 60) all_valid = True # Validate skin if requested if args.validate_skin or args.skin: skin_name = args.skin or "shadcn" skin_path = DSS_DATA / "skins" / skin_name / "tokens.json" print(f"\n[SKIN] Validating: {skin_name}") print("-" * 40) is_valid, errors, warnings = validate_skin(skin_path, contract_path) if errors: all_valid = False for err in errors: print(f" [ERROR] {err}") if warnings and not args.quiet: for warn in warnings: print(f" [WARN] {warn}") if is_valid: print(f" [OK] Skin '{skin_name}' provides all contract tokens") # Validate themes themes_dir = DSS_DATA / "themes" skin_path = DSS_DATA / "skins" / (args.skin or "shadcn") / "tokens.json" if args.theme: themes = [args.theme] else: themes = ( [p.stem for p in themes_dir.glob("*.json") if not p.stem.startswith("_")] if themes_dir.exists() else [] ) for theme_name in themes: theme_path = themes_dir / f"{theme_name}.json" print(f"\n[THEME] Validating: {theme_name}") print("-" * 40) is_valid, errors, warnings = validate_theme(theme_path, contract_path, skin_path) if errors: all_valid = False for err in errors: print(f" [ERROR] {err}") if warnings and not args.quiet: for warn in warnings: print(f" [WARN] {warn}") if is_valid: print(f" [OK] Theme '{theme_name}' is valid") print("\n" + "=" * 60) if all_valid: print("[OK] All validations passed!") sys.exit(0) else: print("[FAIL] Validation errors found") sys.exit(1) if __name__ == "__main__": main()