Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled
- Remove legacy admin-ui/js/ vanilla JS components - Add .dss/ directory with core tokens, skins, themes - Add Storybook configuration and generated stories - Add DSS management scripts (dss-services, dss-init, dss-setup, dss-reset) - Add MCP command definitions for DSS plugin - Add Figma sync architecture and scripts - Update pre-commit hooks with documentation validation - Fix JSON trailing commas in skin files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
279 lines
8.4 KiB
Python
Executable File
279 lines
8.4 KiB
Python
Executable File
#!/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 sys
|
|
import os
|
|
import json
|
|
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()
|