Initial commit: Clean DSS implementation
Migrated from design-system-swarm with fresh git history.
Old project history preserved in /home/overbits/apps/design-system-swarm
Core components:
- MCP Server (Python FastAPI with mcp 1.23.1)
- Claude Plugin (agents, commands, skills, strategies, hooks, core)
- DSS Backend (dss-mvp1 - token translation, Figma sync)
- Admin UI (Node.js/React)
- Server (Node.js/Express)
- Storybook integration (dss-mvp1/.storybook)
Self-contained configuration:
- All paths relative or use DSS_BASE_PATH=/home/overbits/dss
- PYTHONPATH configured for dss-mvp1 and dss-claude-plugin
- .env file with all configuration
- Claude plugin uses ${CLAUDE_PLUGIN_ROOT} for portability
Migration completed: $(date)
🤖 Clean migration with full functionality preserved
This commit is contained in:
253
dss-claude-plugin/hooks/scripts/token-validator.py
Executable file
253
dss-claude-plugin/hooks/scripts/token-validator.py
Executable file
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DSS Token Validator Hook
|
||||
Detects hardcoded values that should use design tokens.
|
||||
Written from scratch for DSS.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Patterns for hardcoded values that should be tokens
|
||||
HARDCODED_PATTERNS = [
|
||||
{
|
||||
"id": "color-hex",
|
||||
"regex": r"(?<!var\()#[0-9a-fA-F]{3,8}\b",
|
||||
"category": "color",
|
||||
"message": "Hardcoded hex color detected. Consider using a design token.",
|
||||
"suggestion": "Use: var(--color-*) or theme.colors.*",
|
||||
"file_types": [".css", ".scss", ".less", ".js", ".jsx", ".ts", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "color-rgb",
|
||||
"regex": r"rgba?\s*\(\s*\d+\s*,\s*\d+\s*,\s*\d+",
|
||||
"category": "color",
|
||||
"message": "Hardcoded RGB color detected. Consider using a design token.",
|
||||
"suggestion": "Use: var(--color-*) or theme.colors.*",
|
||||
"file_types": [".css", ".scss", ".less", ".js", ".jsx", ".ts", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "color-hsl",
|
||||
"regex": r"hsla?\s*\(\s*\d+\s*,\s*\d+%?\s*,\s*\d+%?",
|
||||
"category": "color",
|
||||
"message": "Hardcoded HSL color detected. Consider using a design token.",
|
||||
"suggestion": "Use: var(--color-*) or theme.colors.*",
|
||||
"file_types": [".css", ".scss", ".less", ".js", ".jsx", ".ts", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "spacing-px",
|
||||
"regex": r":\s*\d{2,}px",
|
||||
"category": "spacing",
|
||||
"message": "Hardcoded pixel spacing detected. Consider using a spacing token.",
|
||||
"suggestion": "Use: var(--spacing-*) or theme.spacing.*",
|
||||
"file_types": [".css", ".scss", ".less"]
|
||||
},
|
||||
{
|
||||
"id": "font-size-px",
|
||||
"regex": r"font-size:\s*\d+px",
|
||||
"category": "typography",
|
||||
"message": "Hardcoded font-size detected. Consider using a typography token.",
|
||||
"suggestion": "Use: var(--font-size-*) or theme.fontSize.*",
|
||||
"file_types": [".css", ".scss", ".less"]
|
||||
},
|
||||
{
|
||||
"id": "font-family-direct",
|
||||
"regex": r"font-family:\s*['\"]?(?:Arial|Helvetica|Times|Verdana|Georgia)",
|
||||
"category": "typography",
|
||||
"message": "Hardcoded font-family detected. Consider using a typography token.",
|
||||
"suggestion": "Use: var(--font-family-*) or theme.fontFamily.*",
|
||||
"file_types": [".css", ".scss", ".less"]
|
||||
},
|
||||
{
|
||||
"id": "border-radius-px",
|
||||
"regex": r"border-radius:\s*\d+px",
|
||||
"category": "border",
|
||||
"message": "Hardcoded border-radius detected. Consider using a radius token.",
|
||||
"suggestion": "Use: var(--radius-*) or theme.borderRadius.*",
|
||||
"file_types": [".css", ".scss", ".less"]
|
||||
},
|
||||
{
|
||||
"id": "box-shadow-direct",
|
||||
"regex": r"box-shadow:\s*\d+px\s+\d+px",
|
||||
"category": "effects",
|
||||
"message": "Hardcoded box-shadow detected. Consider using a shadow token.",
|
||||
"suggestion": "Use: var(--shadow-*) or theme.boxShadow.*",
|
||||
"file_types": [".css", ".scss", ".less"]
|
||||
},
|
||||
{
|
||||
"id": "z-index-magic",
|
||||
"regex": r"z-index:\s*(?:999|9999|99999|\d{4,})",
|
||||
"category": "layout",
|
||||
"message": "Magic number z-index detected. Consider using a z-index token.",
|
||||
"suggestion": "Use: var(--z-index-*) with semantic names (modal, dropdown, tooltip)",
|
||||
"file_types": [".css", ".scss", ".less"]
|
||||
},
|
||||
{
|
||||
"id": "inline-style-color",
|
||||
"regex": r"style=\{?\{[^}]*color:\s*['\"]#[0-9a-fA-F]+['\"]",
|
||||
"category": "color",
|
||||
"message": "Hardcoded color in inline style. Consider using theme tokens.",
|
||||
"suggestion": "Use: style={{ color: theme.colors.* }}",
|
||||
"file_types": [".jsx", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "tailwind-arbitrary",
|
||||
"regex": r"(?:bg|text|border)-\[#[0-9a-fA-F]+\]",
|
||||
"category": "color",
|
||||
"message": "Arbitrary Tailwind color value. Consider using theme colors.",
|
||||
"suggestion": "Use: bg-primary, text-secondary, etc.",
|
||||
"file_types": [".jsx", ".tsx", ".html"]
|
||||
}
|
||||
]
|
||||
|
||||
# Allowlist patterns (common exceptions)
|
||||
ALLOWLIST = [
|
||||
r"#000000?", # Pure black
|
||||
r"#fff(fff)?", # Pure white
|
||||
r"transparent",
|
||||
r"inherit",
|
||||
r"currentColor",
|
||||
r"var\(--", # Already using CSS variables
|
||||
r"theme\.", # Already using theme
|
||||
r"colors\.", # Already using colors object
|
||||
]
|
||||
|
||||
def get_config():
|
||||
"""Load hook configuration."""
|
||||
config_path = Path.home() / ".dss" / "hooks-config.json"
|
||||
default_config = {
|
||||
"token_validator": {
|
||||
"enabled": True,
|
||||
"strict_mode": False,
|
||||
"warn_only": True,
|
||||
"categories": ["color", "spacing", "typography"]
|
||||
}
|
||||
}
|
||||
|
||||
if config_path.exists():
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
user_config = json.load(f)
|
||||
return {**default_config, **user_config}
|
||||
except:
|
||||
pass
|
||||
return default_config
|
||||
|
||||
def is_allowlisted(match: str) -> bool:
|
||||
"""Check if match is in allowlist."""
|
||||
for pattern in ALLOWLIST:
|
||||
if re.search(pattern, match, re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_content(content: str, file_path: str, config: dict) -> list:
|
||||
"""Check content for hardcoded values."""
|
||||
issues = []
|
||||
file_ext = Path(file_path).suffix.lower()
|
||||
enabled_categories = config.get("token_validator", {}).get("categories", [])
|
||||
|
||||
for pattern_def in HARDCODED_PATTERNS:
|
||||
# Skip if file type doesn't match
|
||||
if file_ext not in pattern_def.get("file_types", []):
|
||||
continue
|
||||
|
||||
# Skip if category not enabled (unless empty = all)
|
||||
if enabled_categories and pattern_def["category"] not in enabled_categories:
|
||||
continue
|
||||
|
||||
matches = re.findall(pattern_def["regex"], content, re.IGNORECASE)
|
||||
|
||||
for match in matches:
|
||||
if not is_allowlisted(match):
|
||||
issues.append({
|
||||
"id": pattern_def["id"],
|
||||
"category": pattern_def["category"],
|
||||
"message": pattern_def["message"],
|
||||
"suggestion": pattern_def["suggestion"],
|
||||
"value": match[:50] # Truncate long matches
|
||||
})
|
||||
|
||||
# Deduplicate by id
|
||||
seen = set()
|
||||
unique_issues = []
|
||||
for issue in issues:
|
||||
if issue["id"] not in seen:
|
||||
seen.add(issue["id"])
|
||||
unique_issues.append(issue)
|
||||
|
||||
return unique_issues
|
||||
|
||||
def format_output(issues: list, file_path: str) -> str:
|
||||
"""Format issues for display."""
|
||||
if not issues:
|
||||
return ""
|
||||
|
||||
category_icons = {
|
||||
"color": "[COLOR]",
|
||||
"spacing": "[SPACE]",
|
||||
"typography": "[FONT]",
|
||||
"border": "[BORDER]",
|
||||
"effects": "[EFFECT]",
|
||||
"layout": "[LAYOUT]"
|
||||
}
|
||||
|
||||
lines = [f"\n=== DSS Token Validator: {file_path} ===\n"]
|
||||
|
||||
for issue in issues:
|
||||
icon = category_icons.get(issue["category"], "[TOKEN]")
|
||||
lines.append(f"{icon} {issue['message']}")
|
||||
lines.append(f" Found: {issue['value']}")
|
||||
lines.append(f" {issue['suggestion']}\n")
|
||||
|
||||
lines.append("=" * 50)
|
||||
return "\n".join(lines)
|
||||
|
||||
def main():
|
||||
"""Main hook entry point."""
|
||||
config = get_config()
|
||||
|
||||
if not config.get("token_validator", {}).get("enabled", True):
|
||||
sys.exit(0)
|
||||
|
||||
# Read hook input from stdin
|
||||
try:
|
||||
input_data = json.loads(sys.stdin.read())
|
||||
except json.JSONDecodeError:
|
||||
sys.exit(0)
|
||||
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
|
||||
if tool_name not in ["Edit", "Write"]:
|
||||
sys.exit(0)
|
||||
|
||||
file_path = tool_input.get("file_path", "")
|
||||
|
||||
# Get content to check
|
||||
if tool_name == "Write":
|
||||
content = tool_input.get("content", "")
|
||||
elif tool_name == "Edit":
|
||||
content = tool_input.get("new_string", "")
|
||||
else:
|
||||
content = ""
|
||||
|
||||
if not content or not file_path:
|
||||
sys.exit(0)
|
||||
|
||||
# Check for token issues
|
||||
issues = check_content(content, file_path, config)
|
||||
|
||||
if issues:
|
||||
output = format_output(issues, file_path)
|
||||
print(output, file=sys.stderr)
|
||||
|
||||
# In strict mode, block on issues
|
||||
if config.get("token_validator", {}).get("strict_mode", False):
|
||||
sys.exit(2)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user