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:
268
dss-claude-plugin/hooks/scripts/component-checker.py
Executable file
268
dss-claude-plugin/hooks/scripts/component-checker.py
Executable file
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DSS Component Checker Hook
|
||||
Validates React components for best practices and accessibility.
|
||||
Written from scratch for DSS.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# React component patterns to check
|
||||
COMPONENT_PATTERNS = [
|
||||
# Accessibility checks
|
||||
{
|
||||
"id": "a11y-img-alt",
|
||||
"regex": r"<img\s+(?![^>]*alt=)[^>]*>",
|
||||
"category": "accessibility",
|
||||
"severity": "high",
|
||||
"message": "Missing alt attribute on <img>. Add alt text for accessibility.",
|
||||
"file_types": [".jsx", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "a11y-button-type",
|
||||
"regex": r"<button\s+(?![^>]*type=)[^>]*>",
|
||||
"category": "accessibility",
|
||||
"severity": "medium",
|
||||
"message": "Button missing type attribute. Add type='button' or type='submit'.",
|
||||
"file_types": [".jsx", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "a11y-anchor-href",
|
||||
"regex": r"<a\s+(?![^>]*href=)[^>]*>",
|
||||
"category": "accessibility",
|
||||
"severity": "high",
|
||||
"message": "Anchor tag missing href. Use button for actions without navigation.",
|
||||
"file_types": [".jsx", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "a11y-click-handler",
|
||||
"regex": r"<(?:div|span)\s+[^>]*onClick",
|
||||
"category": "accessibility",
|
||||
"severity": "medium",
|
||||
"message": "Click handler on non-interactive element. Use <button> or add role/tabIndex.",
|
||||
"file_types": [".jsx", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "a11y-form-label",
|
||||
"regex": r"<input\s+(?![^>]*(?:aria-label|id))[^>]*>",
|
||||
"category": "accessibility",
|
||||
"severity": "medium",
|
||||
"message": "Input may be missing label association. Add id with <label> or aria-label.",
|
||||
"file_types": [".jsx", ".tsx"]
|
||||
},
|
||||
# React best practices
|
||||
{
|
||||
"id": "react-key-index",
|
||||
"regex": r"\.map\([^)]*,\s*(?:index|i|idx)\s*\)[^{]*key=\{(?:index|i|idx)\}",
|
||||
"category": "react",
|
||||
"severity": "medium",
|
||||
"message": "Using array index as key. Use unique, stable IDs when possible.",
|
||||
"file_types": [".jsx", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "react-bind-render",
|
||||
"regex": r"onClick=\{[^}]*\.bind\(this",
|
||||
"category": "react",
|
||||
"severity": "low",
|
||||
"message": "Binding in render creates new function each time. Use arrow function or bind in constructor.",
|
||||
"file_types": [".jsx", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "react-inline-style-object",
|
||||
"regex": r"style=\{\{[^}]{100,}\}\}",
|
||||
"category": "react",
|
||||
"severity": "low",
|
||||
"message": "Large inline style object. Consider extracting to a constant or CSS module.",
|
||||
"file_types": [".jsx", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "react-console-log",
|
||||
"regex": r"console\.(log|debug|info)\(",
|
||||
"category": "react",
|
||||
"severity": "low",
|
||||
"message": "Console statement detected. Remove before production.",
|
||||
"file_types": [".js", ".jsx", ".ts", ".tsx"]
|
||||
},
|
||||
# TypeScript checks
|
||||
{
|
||||
"id": "ts-any-type",
|
||||
"regex": r":\s*any\b",
|
||||
"category": "typescript",
|
||||
"severity": "medium",
|
||||
"message": "Using 'any' type loses type safety. Consider using a specific type or 'unknown'.",
|
||||
"file_types": [".ts", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "ts-type-assertion",
|
||||
"regex": r"as\s+any\b",
|
||||
"category": "typescript",
|
||||
"severity": "medium",
|
||||
"message": "Type assertion to 'any'. This bypasses type checking.",
|
||||
"file_types": [".ts", ".tsx"]
|
||||
},
|
||||
# Component structure
|
||||
{
|
||||
"id": "component-no-export",
|
||||
"regex": r"^(?!.*export).*(?:function|const)\s+[A-Z][a-zA-Z]*\s*(?:=|:|\()",
|
||||
"category": "structure",
|
||||
"severity": "low",
|
||||
"message": "Component may not be exported. Ensure it's exported if meant to be reused.",
|
||||
"file_types": [".jsx", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "component-missing-displayname",
|
||||
"regex": r"(?:forwardRef|memo)\s*\([^)]*\)",
|
||||
"category": "structure",
|
||||
"severity": "low",
|
||||
"message": "HOC component may need displayName for debugging.",
|
||||
"file_types": [".jsx", ".tsx"]
|
||||
}
|
||||
]
|
||||
|
||||
def get_config():
|
||||
"""Load hook configuration."""
|
||||
config_path = Path.home() / ".dss" / "hooks-config.json"
|
||||
default_config = {
|
||||
"component_checker": {
|
||||
"enabled": True,
|
||||
"categories": ["accessibility", "react", "typescript"],
|
||||
"min_severity": "low"
|
||||
}
|
||||
}
|
||||
|
||||
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 severity_level(severity: str) -> int:
|
||||
"""Convert severity to numeric level."""
|
||||
levels = {"low": 1, "medium": 2, "high": 3}
|
||||
return levels.get(severity, 0)
|
||||
|
||||
def check_content(content: str, file_path: str, config: dict) -> list:
|
||||
"""Check content for component issues."""
|
||||
issues = []
|
||||
file_ext = Path(file_path).suffix.lower()
|
||||
|
||||
checker_config = config.get("component_checker", {})
|
||||
enabled_categories = checker_config.get("categories", [])
|
||||
min_severity = checker_config.get("min_severity", "low")
|
||||
min_level = severity_level(min_severity)
|
||||
|
||||
for pattern_def in COMPONENT_PATTERNS:
|
||||
# Skip if file type doesn't match
|
||||
if file_ext not in pattern_def.get("file_types", []):
|
||||
continue
|
||||
|
||||
# Skip if category not enabled
|
||||
if enabled_categories and pattern_def["category"] not in enabled_categories:
|
||||
continue
|
||||
|
||||
# Skip if below minimum severity
|
||||
if severity_level(pattern_def["severity"]) < min_level:
|
||||
continue
|
||||
|
||||
if re.search(pattern_def["regex"], content, re.MULTILINE):
|
||||
issues.append({
|
||||
"id": pattern_def["id"],
|
||||
"category": pattern_def["category"],
|
||||
"severity": pattern_def["severity"],
|
||||
"message": pattern_def["message"]
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
def format_output(issues: list, file_path: str) -> str:
|
||||
"""Format issues for display."""
|
||||
if not issues:
|
||||
return ""
|
||||
|
||||
severity_icons = {
|
||||
"high": "[HIGH]",
|
||||
"medium": "[MED]",
|
||||
"low": "[LOW]"
|
||||
}
|
||||
|
||||
category_labels = {
|
||||
"accessibility": "A11Y",
|
||||
"react": "REACT",
|
||||
"typescript": "TS",
|
||||
"structure": "STRUCT"
|
||||
}
|
||||
|
||||
lines = [f"\n=== DSS Component Checker: {file_path} ===\n"]
|
||||
|
||||
# Group by category
|
||||
by_category = {}
|
||||
for issue in issues:
|
||||
cat = issue["category"]
|
||||
if cat not in by_category:
|
||||
by_category[cat] = []
|
||||
by_category[cat].append(issue)
|
||||
|
||||
for category, cat_issues in by_category.items():
|
||||
label = category_labels.get(category, category.upper())
|
||||
lines.append(f"[{label}]")
|
||||
for issue in cat_issues:
|
||||
sev = severity_icons.get(issue["severity"], "[?]")
|
||||
lines.append(f" {sev} {issue['message']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("=" * 50)
|
||||
return "\n".join(lines)
|
||||
|
||||
def main():
|
||||
"""Main hook entry point."""
|
||||
config = get_config()
|
||||
|
||||
if not config.get("component_checker", {}).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", "")
|
||||
file_ext = Path(file_path).suffix.lower() if file_path else ""
|
||||
|
||||
# Only check React/TypeScript files
|
||||
if file_ext not in [".jsx", ".tsx", ".js", ".ts"]:
|
||||
sys.exit(0)
|
||||
|
||||
# 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:
|
||||
sys.exit(0)
|
||||
|
||||
issues = check_content(content, file_path, config)
|
||||
|
||||
if issues:
|
||||
output = format_output(issues, file_path)
|
||||
print(output, file=sys.stderr)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user