Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled
This reverts commit 72cb7319f5.
254 lines
8.2 KiB
Python
Executable File
254 lines
8.2 KiB
Python
Executable File
#!/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()
|