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:
1
dss-claude-plugin/hooks/.state/.git-backup.lock
Normal file
1
dss-claude-plugin/hooks/.state/.git-backup.lock
Normal file
@@ -0,0 +1 @@
|
||||
1765316404612
|
||||
58
dss-claude-plugin/hooks/dss-hooks-config.json
Normal file
58
dss-claude-plugin/hooks/dss-hooks-config.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"description": "DSS Hooks Configuration - Customize hook behavior",
|
||||
"version": "1.0.0",
|
||||
|
||||
"security_check": {
|
||||
"enabled": true,
|
||||
"block_on_critical": false,
|
||||
"warn_only": true,
|
||||
"ignored_patterns": []
|
||||
},
|
||||
|
||||
"token_validator": {
|
||||
"enabled": true,
|
||||
"strict_mode": false,
|
||||
"warn_only": true,
|
||||
"categories": ["color", "spacing", "typography", "border", "effects", "layout"]
|
||||
},
|
||||
|
||||
"component_checker": {
|
||||
"enabled": true,
|
||||
"categories": ["accessibility", "react", "typescript", "structure"],
|
||||
"min_severity": "low"
|
||||
},
|
||||
|
||||
"complexity_monitor": {
|
||||
"enabled": true,
|
||||
"max_function_lines": 50,
|
||||
"max_component_lines": 200,
|
||||
"max_props": 10,
|
||||
"max_nesting_depth": 4,
|
||||
"warn_only": true
|
||||
},
|
||||
|
||||
"storybook_reminder": {
|
||||
"enabled": true,
|
||||
"component_patterns": ["**/components/**/*.tsx", "**/ui/**/*.tsx"],
|
||||
"story_extensions": [".stories.tsx", ".stories.jsx", ".stories.ts", ".stories.js"],
|
||||
"remind_on_new": true,
|
||||
"remind_on_props_change": true
|
||||
},
|
||||
|
||||
"session_summary": {
|
||||
"enabled": true,
|
||||
"output_file": ".dss-session-summary.md",
|
||||
"include_git_diff": true,
|
||||
"include_file_list": true,
|
||||
"max_diff_lines": 100
|
||||
},
|
||||
|
||||
"git_backup": {
|
||||
"enabled": true,
|
||||
"require_git_repo": true,
|
||||
"commit_only_if_changes": true,
|
||||
"include_timestamp": true,
|
||||
"commit_prefix": "dss-auto-backup",
|
||||
"show_logs": false
|
||||
}
|
||||
}
|
||||
111
dss-claude-plugin/hooks/hooks.json
Normal file
111
dss-claude-plugin/hooks/hooks.json
Normal file
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"description": "DSS Developer Hooks - React/UI Development & QA Tools",
|
||||
"version": "1.0.0",
|
||||
"author": "DSS Team",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"description": "Security pattern validation for file edits",
|
||||
"matcher": "Edit|Write",
|
||||
"priority": 100,
|
||||
"enabled": true,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/security-check.py",
|
||||
"timeout": 5000,
|
||||
"continueOnError": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Design token compliance validation",
|
||||
"matcher": "Edit|Write",
|
||||
"priority": 90,
|
||||
"enabled": true,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/token-validator.py",
|
||||
"timeout": 5000,
|
||||
"continueOnError": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"description": "React component best practices and accessibility",
|
||||
"matcher": "Edit|Write",
|
||||
"priority": 80,
|
||||
"enabled": true,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/component-checker.py",
|
||||
"timeout": 5000,
|
||||
"continueOnError": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Code complexity tracking",
|
||||
"matcher": "Edit|Write",
|
||||
"priority": 70,
|
||||
"enabled": true,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/complexity-monitor.js",
|
||||
"timeout": 5000,
|
||||
"continueOnError": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Storybook coverage reminder for components",
|
||||
"matcher": "Edit|Write",
|
||||
"priority": 60,
|
||||
"enabled": true,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/storybook-reminder.py",
|
||||
"timeout": 3000,
|
||||
"continueOnError": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"description": "Generate session summary report",
|
||||
"priority": 100,
|
||||
"enabled": true,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/session-summary.js",
|
||||
"timeout": 10000,
|
||||
"continueOnError": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"description": "Auto-backup changes to git",
|
||||
"priority": 100,
|
||||
"enabled": true,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/git-backup.js",
|
||||
"timeout": 10000,
|
||||
"continueOnError": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
215
dss-claude-plugin/hooks/scripts/complexity-monitor.js
Executable file
215
dss-claude-plugin/hooks/scripts/complexity-monitor.js
Executable file
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* DSS Complexity Monitor Hook
|
||||
* Tracks code complexity metrics and warns on high-complexity code.
|
||||
* Written from scratch for DSS.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Configuration
|
||||
const DEFAULT_CONFIG = {
|
||||
complexity_monitor: {
|
||||
enabled: true,
|
||||
max_function_lines: 50,
|
||||
max_component_lines: 200,
|
||||
max_props: 10,
|
||||
max_nesting_depth: 4,
|
||||
warn_only: true
|
||||
}
|
||||
};
|
||||
|
||||
function loadConfig() {
|
||||
const configPath = path.join(process.env.HOME || '', '.dss', 'hooks-config.json');
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const userConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
return { ...DEFAULT_CONFIG, ...userConfig };
|
||||
}
|
||||
} catch (e) {
|
||||
// Use defaults
|
||||
}
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
function countLines(content) {
|
||||
return content.split('\n').length;
|
||||
}
|
||||
|
||||
function countProps(content) {
|
||||
// Count props in interface/type definition
|
||||
const propsMatch = content.match(/(?:interface|type)\s+\w*Props[^{]*\{([^}]+)\}/);
|
||||
if (propsMatch) {
|
||||
const propsContent = propsMatch[1];
|
||||
// Count semicolons or newlines with property definitions
|
||||
const props = propsContent.split(/[;\n]/).filter(line => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed && !trimmed.startsWith('//') && trimmed.includes(':');
|
||||
});
|
||||
return props.length;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function countNestingDepth(content) {
|
||||
let maxDepth = 0;
|
||||
let currentDepth = 0;
|
||||
|
||||
for (const char of content) {
|
||||
if (char === '{' || char === '(') {
|
||||
currentDepth++;
|
||||
maxDepth = Math.max(maxDepth, currentDepth);
|
||||
} else if (char === '}' || char === ')') {
|
||||
currentDepth = Math.max(0, currentDepth - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return maxDepth;
|
||||
}
|
||||
|
||||
function countFunctions(content) {
|
||||
const patterns = [
|
||||
/function\s+\w+\s*\([^)]*\)\s*\{/g,
|
||||
/const\s+\w+\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/g,
|
||||
/const\s+\w+\s*=\s*(?:async\s*)?function/g
|
||||
];
|
||||
|
||||
let count = 0;
|
||||
for (const pattern of patterns) {
|
||||
const matches = content.match(pattern);
|
||||
if (matches) count += matches.length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function analyzeComplexity(content, filePath, config) {
|
||||
const issues = [];
|
||||
const monitorConfig = config.complexity_monitor || {};
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
// Only analyze JS/TS files
|
||||
if (!['.js', '.jsx', '.ts', '.tsx'].includes(ext)) {
|
||||
return issues;
|
||||
}
|
||||
|
||||
const lines = countLines(content);
|
||||
const props = countProps(content);
|
||||
const nesting = countNestingDepth(content);
|
||||
const functions = countFunctions(content);
|
||||
|
||||
// Check component size (for tsx/jsx files)
|
||||
if (['.tsx', '.jsx'].includes(ext)) {
|
||||
if (lines > monitorConfig.max_component_lines) {
|
||||
issues.push({
|
||||
type: 'component_size',
|
||||
severity: 'medium',
|
||||
message: `Component has ${lines} lines (max: ${monitorConfig.max_component_lines})`,
|
||||
suggestion: 'Consider breaking into smaller components'
|
||||
});
|
||||
}
|
||||
|
||||
if (props > monitorConfig.max_props) {
|
||||
issues.push({
|
||||
type: 'prop_count',
|
||||
severity: 'medium',
|
||||
message: `Component has ${props} props (max: ${monitorConfig.max_props})`,
|
||||
suggestion: 'Consider grouping related props or using composition'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check nesting depth
|
||||
if (nesting > monitorConfig.max_nesting_depth) {
|
||||
issues.push({
|
||||
type: 'nesting_depth',
|
||||
severity: 'high',
|
||||
message: `Nesting depth of ${nesting} (max: ${monitorConfig.max_nesting_depth})`,
|
||||
suggestion: 'Extract nested logic into separate functions'
|
||||
});
|
||||
}
|
||||
|
||||
// Check function count (indicator of file doing too much)
|
||||
if (functions > 10) {
|
||||
issues.push({
|
||||
type: 'function_count',
|
||||
severity: 'low',
|
||||
message: `File contains ${functions} functions`,
|
||||
suggestion: 'Consider splitting into multiple modules'
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function formatOutput(issues, filePath) {
|
||||
if (issues.length === 0) return '';
|
||||
|
||||
const severityIcons = {
|
||||
high: '[HIGH]',
|
||||
medium: '[MED]',
|
||||
low: '[LOW]'
|
||||
};
|
||||
|
||||
const lines = [`\n=== DSS Complexity Monitor: ${filePath} ===\n`];
|
||||
|
||||
for (const issue of issues) {
|
||||
const icon = severityIcons[issue.severity] || '[?]';
|
||||
lines.push(`${icon} ${issue.message}`);
|
||||
lines.push(` Suggestion: ${issue.suggestion}\n`);
|
||||
}
|
||||
|
||||
lines.push('='.repeat(50));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.complexity_monitor?.enabled) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Read input from stdin
|
||||
let inputData;
|
||||
try {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
inputData = JSON.parse(Buffer.concat(chunks).toString());
|
||||
} catch (e) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const toolName = inputData.tool_name || '';
|
||||
const toolInput = inputData.tool_input || {};
|
||||
|
||||
if (!['Edit', 'Write'].includes(toolName)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const filePath = toolInput.file_path || '';
|
||||
let content = '';
|
||||
|
||||
if (toolName === 'Write') {
|
||||
content = toolInput.content || '';
|
||||
} else if (toolName === 'Edit') {
|
||||
content = toolInput.new_string || '';
|
||||
}
|
||||
|
||||
if (!content || !filePath) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const issues = analyzeComplexity(content, filePath, config);
|
||||
|
||||
if (issues.length > 0) {
|
||||
const output = formatOutput(issues, filePath);
|
||||
console.error(output);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(() => process.exit(0));
|
||||
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()
|
||||
182
dss-claude-plugin/hooks/scripts/git-backup.js
Executable file
182
dss-claude-plugin/hooks/scripts/git-backup.js
Executable file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* DSS Git Backup Hook
|
||||
* Automatically commits changes when Claude Code session ends.
|
||||
* Written from scratch for DSS.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Configuration
|
||||
const DEFAULT_CONFIG = {
|
||||
git_backup: {
|
||||
enabled: true,
|
||||
require_git_repo: true,
|
||||
commit_only_if_changes: true,
|
||||
include_timestamp: true,
|
||||
commit_prefix: 'auto-backup',
|
||||
show_logs: false
|
||||
}
|
||||
};
|
||||
|
||||
// Prevent duplicate execution (Claude Code bug workaround)
|
||||
const STATE_DIR = path.join(__dirname, '..', '.state');
|
||||
const LOCK_FILE = path.join(STATE_DIR, '.git-backup.lock');
|
||||
const LOCK_TIMEOUT_MS = 3000;
|
||||
|
||||
function loadConfig() {
|
||||
const configPath = path.join(process.env.HOME || '', '.dss', 'hooks-config.json');
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const userConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
return { ...DEFAULT_CONFIG, ...userConfig };
|
||||
}
|
||||
} catch (e) {
|
||||
// Use defaults
|
||||
}
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
function checkLock() {
|
||||
try {
|
||||
if (!fs.existsSync(STATE_DIR)) {
|
||||
fs.mkdirSync(STATE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(LOCK_FILE)) {
|
||||
const lastRun = parseInt(fs.readFileSync(LOCK_FILE, 'utf8'));
|
||||
if (!isNaN(lastRun) && (Date.now() - lastRun < LOCK_TIMEOUT_MS)) {
|
||||
return false; // Already ran recently
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(LOCK_FILE, Date.now().toString(), 'utf8');
|
||||
return true;
|
||||
} catch (e) {
|
||||
return true; // Proceed on error
|
||||
}
|
||||
}
|
||||
|
||||
function isGitRepo() {
|
||||
try {
|
||||
execSync('git rev-parse --is-inside-work-tree', { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasChanges() {
|
||||
try {
|
||||
const status = execSync('git status --porcelain', { encoding: 'utf8' });
|
||||
return status.trim().length > 0;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getChangeSummary() {
|
||||
try {
|
||||
const status = execSync('git status --short', { encoding: 'utf8' });
|
||||
const lines = status.trim().split('\n').filter(Boolean);
|
||||
|
||||
let added = 0, modified = 0, deleted = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const status = line.trim().charAt(0);
|
||||
if (status === 'A' || status === '?') added++;
|
||||
else if (status === 'M') modified++;
|
||||
else if (status === 'D') deleted++;
|
||||
}
|
||||
|
||||
return { added, modified, deleted, total: lines.length };
|
||||
} catch (e) {
|
||||
return { added: 0, modified: 0, deleted: 0, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function createBackup(config) {
|
||||
const backupConfig = config.git_backup || {};
|
||||
|
||||
try {
|
||||
// Stage all changes
|
||||
execSync('git add -A', { stdio: 'pipe' });
|
||||
|
||||
// Build commit message
|
||||
const parts = [backupConfig.commit_prefix || 'auto-backup'];
|
||||
|
||||
if (backupConfig.include_timestamp) {
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').replace(/\..+/, '');
|
||||
parts.push(timestamp);
|
||||
}
|
||||
|
||||
const summary = getChangeSummary();
|
||||
const summaryText = `(${summary.total} files: +${summary.added} ~${summary.modified} -${summary.deleted})`;
|
||||
|
||||
const commitMessage = `${parts.join(': ')} ${summaryText}\n\nGenerated by DSS Git Backup Hook`;
|
||||
|
||||
// Create commit
|
||||
execSync(`git commit -m "${commitMessage}"`, { stdio: 'pipe' });
|
||||
|
||||
// Get commit hash
|
||||
const commitHash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
|
||||
|
||||
return { success: true, hash: commitHash, files: summary.total };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function log(config, message) {
|
||||
if (config.git_backup?.show_logs) {
|
||||
console.log(JSON.stringify({
|
||||
systemMessage: message,
|
||||
continue: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
// Prevent duplicate execution
|
||||
if (!checkLock()) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Prevent hook recursion
|
||||
if (process.env.STOP_HOOK_ACTIVE === 'true') {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.git_backup?.enabled) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Check for git repo
|
||||
if (config.git_backup.require_git_repo && !isGitRepo()) {
|
||||
log(config, 'DSS Git Backup: Not a git repository, skipping');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Check for changes
|
||||
if (config.git_backup.commit_only_if_changes && !hasChanges()) {
|
||||
log(config, 'DSS Git Backup: No changes to commit');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Create backup
|
||||
const result = createBackup(config);
|
||||
|
||||
if (result.success) {
|
||||
log(config, `DSS Git Backup: Committed ${result.files} files (${result.hash})`);
|
||||
} else {
|
||||
log(config, `DSS Git Backup: Failed - ${result.error}`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
201
dss-claude-plugin/hooks/scripts/security-check.py
Executable file
201
dss-claude-plugin/hooks/scripts/security-check.py
Executable file
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DSS Security Check Hook
|
||||
Validates file edits for common security vulnerabilities.
|
||||
Written from scratch for DSS - no external dependencies.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Security patterns to detect
|
||||
SECURITY_PATTERNS = [
|
||||
{
|
||||
"id": "xss-innerhtml",
|
||||
"patterns": [".innerHTML =", ".innerHTML=", "innerHTML:"],
|
||||
"severity": "high",
|
||||
"message": "Potential XSS: innerHTML assignment detected. Use textContent for plain text or sanitize HTML with DOMPurify.",
|
||||
"file_types": [".js", ".jsx", ".ts", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "xss-dangerously",
|
||||
"patterns": ["dangerouslySetInnerHTML"],
|
||||
"severity": "high",
|
||||
"message": "Potential XSS: dangerouslySetInnerHTML detected. Ensure content is sanitized before rendering.",
|
||||
"file_types": [".js", ".jsx", ".ts", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "eval-usage",
|
||||
"patterns": ["eval(", "new Function("],
|
||||
"severity": "critical",
|
||||
"message": "Code injection risk: eval() or new Function() detected. These can execute arbitrary code.",
|
||||
"file_types": [".js", ".jsx", ".ts", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "document-write",
|
||||
"patterns": ["document.write("],
|
||||
"severity": "medium",
|
||||
"message": "Deprecated: document.write() detected. Use DOM manipulation methods instead.",
|
||||
"file_types": [".js", ".jsx", ".ts", ".tsx", ".html"]
|
||||
},
|
||||
{
|
||||
"id": "sql-injection",
|
||||
"patterns": ["execute(f\"", "execute(f'", "cursor.execute(\"", ".query(`${"],
|
||||
"severity": "critical",
|
||||
"message": "Potential SQL injection: String interpolation in SQL query. Use parameterized queries.",
|
||||
"file_types": [".py", ".js", ".ts"]
|
||||
},
|
||||
{
|
||||
"id": "hardcoded-secret",
|
||||
"patterns": ["password=", "api_key=", "secret=", "token=", "apiKey:"],
|
||||
"severity": "high",
|
||||
"message": "Potential hardcoded secret detected. Use environment variables instead.",
|
||||
"file_types": [".py", ".js", ".ts", ".jsx", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "python-pickle",
|
||||
"patterns": ["pickle.load", "pickle.loads"],
|
||||
"severity": "high",
|
||||
"message": "Insecure deserialization: pickle can execute arbitrary code. Use JSON for untrusted data.",
|
||||
"file_types": [".py"]
|
||||
},
|
||||
{
|
||||
"id": "python-shell",
|
||||
"patterns": ["os.system(", "subprocess.call(shell=True", "subprocess.run(shell=True"],
|
||||
"severity": "high",
|
||||
"message": "Shell injection risk: Use subprocess with shell=False and pass args as list.",
|
||||
"file_types": [".py"]
|
||||
},
|
||||
{
|
||||
"id": "react-ref-current",
|
||||
"patterns": ["ref.current.innerHTML"],
|
||||
"severity": "high",
|
||||
"message": "XSS via React ref: Avoid setting innerHTML on refs. Use state/props instead.",
|
||||
"file_types": [".jsx", ".tsx"]
|
||||
},
|
||||
{
|
||||
"id": "unsafe-regex",
|
||||
"patterns": ["new RegExp(", "RegExp("],
|
||||
"severity": "medium",
|
||||
"message": "Potential ReDoS: Dynamic regex from user input can cause denial of service.",
|
||||
"file_types": [".js", ".ts", ".jsx", ".tsx"]
|
||||
}
|
||||
]
|
||||
|
||||
def get_config():
|
||||
"""Load hook configuration."""
|
||||
config_path = Path.home() / ".dss" / "hooks-config.json"
|
||||
default_config = {
|
||||
"security_check": {
|
||||
"enabled": True,
|
||||
"block_on_critical": False,
|
||||
"warn_only": True,
|
||||
"ignored_patterns": []
|
||||
}
|
||||
}
|
||||
|
||||
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 check_content(content: str, file_path: str) -> list:
|
||||
"""Check content for security patterns."""
|
||||
issues = []
|
||||
file_ext = Path(file_path).suffix.lower()
|
||||
|
||||
for pattern_def in SECURITY_PATTERNS:
|
||||
# Skip if file type doesn't match
|
||||
if file_ext not in pattern_def.get("file_types", []):
|
||||
continue
|
||||
|
||||
for pattern in pattern_def["patterns"]:
|
||||
if pattern.lower() in content.lower():
|
||||
issues.append({
|
||||
"id": pattern_def["id"],
|
||||
"severity": pattern_def["severity"],
|
||||
"message": pattern_def["message"],
|
||||
"pattern": pattern
|
||||
})
|
||||
break # One match per pattern definition is enough
|
||||
|
||||
return issues
|
||||
|
||||
def format_output(issues: list, file_path: str) -> str:
|
||||
"""Format issues for display."""
|
||||
if not issues:
|
||||
return ""
|
||||
|
||||
severity_icons = {
|
||||
"critical": "[CRITICAL]",
|
||||
"high": "[HIGH]",
|
||||
"medium": "[MEDIUM]",
|
||||
"low": "[LOW]"
|
||||
}
|
||||
|
||||
lines = [f"\n=== DSS Security Check: {file_path} ===\n"]
|
||||
|
||||
for issue in issues:
|
||||
icon = severity_icons.get(issue["severity"], "[?]")
|
||||
lines.append(f"{icon} {issue['message']}")
|
||||
lines.append(f" Pattern: {issue['pattern']}\n")
|
||||
|
||||
lines.append("=" * 50)
|
||||
return "\n".join(lines)
|
||||
|
||||
def main():
|
||||
"""Main hook entry point."""
|
||||
config = get_config()
|
||||
|
||||
if not config.get("security_check", {}).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) # Allow tool to proceed if we can't parse
|
||||
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
|
||||
# Only check Edit and Write tools
|
||||
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 security issues
|
||||
issues = check_content(content, file_path)
|
||||
|
||||
if issues:
|
||||
output = format_output(issues, file_path)
|
||||
print(output, file=sys.stderr)
|
||||
|
||||
# Check if we should block on critical issues
|
||||
has_critical = any(i["severity"] == "critical" for i in issues)
|
||||
if has_critical and config.get("security_check", {}).get("block_on_critical", False):
|
||||
sys.exit(2) # Block the tool
|
||||
|
||||
sys.exit(0) # Allow tool to proceed
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
194
dss-claude-plugin/hooks/scripts/session-summary.js
Executable file
194
dss-claude-plugin/hooks/scripts/session-summary.js
Executable file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* DSS Session Summary Hook
|
||||
* Generates a summary report at the end of each Claude Code session.
|
||||
* Written from scratch for DSS.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Configuration
|
||||
const DEFAULT_CONFIG = {
|
||||
session_summary: {
|
||||
enabled: true,
|
||||
output_file: '.dss-session-summary.md',
|
||||
include_git_diff: true,
|
||||
include_file_list: true,
|
||||
max_diff_lines: 100
|
||||
}
|
||||
};
|
||||
|
||||
function loadConfig() {
|
||||
const configPath = path.join(process.env.HOME || '', '.dss', 'hooks-config.json');
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const userConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
return { ...DEFAULT_CONFIG, ...userConfig };
|
||||
}
|
||||
} catch (e) {
|
||||
// Use defaults
|
||||
}
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
function getGitInfo() {
|
||||
const info = {
|
||||
branch: '',
|
||||
status: '',
|
||||
diff: '',
|
||||
modifiedFiles: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Check if in git repo
|
||||
execSync('git rev-parse --is-inside-work-tree', { stdio: 'pipe' });
|
||||
|
||||
// Get branch
|
||||
info.branch = execSync('git branch --show-current', { encoding: 'utf8' }).trim();
|
||||
|
||||
// Get status
|
||||
info.status = execSync('git status --short', { encoding: 'utf8' }).trim();
|
||||
|
||||
// Get modified files
|
||||
const statusLines = info.status.split('\n').filter(Boolean);
|
||||
info.modifiedFiles = statusLines.map(line => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
return {
|
||||
status: parts[0],
|
||||
file: parts.slice(1).join(' ')
|
||||
};
|
||||
});
|
||||
|
||||
// Get diff summary
|
||||
try {
|
||||
info.diff = execSync('git diff --stat', { encoding: 'utf8' }).trim();
|
||||
} catch (e) {
|
||||
info.diff = '';
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a git repo or git not available
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
function getSessionStats() {
|
||||
// Try to read from session state if available
|
||||
const stats = {
|
||||
startTime: new Date().toISOString(),
|
||||
filesModified: 0,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0
|
||||
};
|
||||
|
||||
try {
|
||||
// Get diff stats from git
|
||||
const diffStat = execSync('git diff --numstat', { encoding: 'utf8' });
|
||||
const lines = diffStat.trim().split('\n').filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const [added, removed] = line.split('\t');
|
||||
stats.linesAdded += parseInt(added) || 0;
|
||||
stats.linesRemoved += parseInt(removed) || 0;
|
||||
stats.filesModified++;
|
||||
}
|
||||
} catch (e) {
|
||||
// Git not available
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
function generateReport(config) {
|
||||
const summaryConfig = config.session_summary || {};
|
||||
const gitInfo = getGitInfo();
|
||||
const stats = getSessionStats();
|
||||
|
||||
const timestamp = new Date().toLocaleString();
|
||||
const lines = [];
|
||||
|
||||
lines.push('# DSS Session Summary');
|
||||
lines.push(`\n**Generated:** ${timestamp}`);
|
||||
|
||||
if (gitInfo.branch) {
|
||||
lines.push(`**Branch:** ${gitInfo.branch}`);
|
||||
}
|
||||
|
||||
lines.push('\n## Changes Overview');
|
||||
lines.push('');
|
||||
lines.push(`- Files modified: ${stats.filesModified}`);
|
||||
lines.push(`- Lines added: +${stats.linesAdded}`);
|
||||
lines.push(`- Lines removed: -${stats.linesRemoved}`);
|
||||
|
||||
if (summaryConfig.include_file_list && gitInfo.modifiedFiles.length > 0) {
|
||||
lines.push('\n## Modified Files');
|
||||
lines.push('');
|
||||
lines.push('| Status | File |');
|
||||
lines.push('|--------|------|');
|
||||
|
||||
const statusLabels = {
|
||||
'M': 'Modified',
|
||||
'A': 'Added',
|
||||
'D': 'Deleted',
|
||||
'R': 'Renamed',
|
||||
'??': 'Untracked'
|
||||
};
|
||||
|
||||
for (const file of gitInfo.modifiedFiles.slice(0, 20)) {
|
||||
const label = statusLabels[file.status] || file.status;
|
||||
lines.push(`| ${label} | ${file.file} |`);
|
||||
}
|
||||
|
||||
if (gitInfo.modifiedFiles.length > 20) {
|
||||
lines.push(`| ... | +${gitInfo.modifiedFiles.length - 20} more files |`);
|
||||
}
|
||||
}
|
||||
|
||||
if (summaryConfig.include_git_diff && gitInfo.diff) {
|
||||
lines.push('\n## Diff Summary');
|
||||
lines.push('');
|
||||
lines.push('```');
|
||||
const diffLines = gitInfo.diff.split('\n');
|
||||
const maxLines = summaryConfig.max_diff_lines || 100;
|
||||
lines.push(diffLines.slice(0, maxLines).join('\n'));
|
||||
if (diffLines.length > maxLines) {
|
||||
lines.push(`... (${diffLines.length - maxLines} more lines)`);
|
||||
}
|
||||
lines.push('```');
|
||||
}
|
||||
|
||||
lines.push('\n---');
|
||||
lines.push('*Generated by DSS Session Summary Hook*');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.session_summary?.enabled) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
const report = generateReport(config);
|
||||
const outputFile = config.session_summary.output_file || '.dss-session-summary.md';
|
||||
const outputPath = path.join(process.cwd(), outputFile);
|
||||
|
||||
fs.writeFileSync(outputPath, report, 'utf8');
|
||||
|
||||
// Output confirmation
|
||||
console.log(JSON.stringify({
|
||||
systemMessage: `Session summary saved to ${outputFile}`,
|
||||
continue: true
|
||||
}));
|
||||
} catch (e) {
|
||||
// Fail silently
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
179
dss-claude-plugin/hooks/scripts/storybook-reminder.py
Executable file
179
dss-claude-plugin/hooks/scripts/storybook-reminder.py
Executable file
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DSS Storybook Reminder Hook
|
||||
Reminds developers to update Storybook stories when components change.
|
||||
Written from scratch for DSS.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def get_config():
|
||||
"""Load hook configuration."""
|
||||
config_path = Path.home() / ".dss" / "hooks-config.json"
|
||||
default_config = {
|
||||
"storybook_reminder": {
|
||||
"enabled": True,
|
||||
"component_patterns": ["**/components/**/*.tsx", "**/ui/**/*.tsx"],
|
||||
"story_extensions": [".stories.tsx", ".stories.jsx", ".stories.ts", ".stories.js"],
|
||||
"remind_on_new": True,
|
||||
"remind_on_props_change": True
|
||||
}
|
||||
}
|
||||
|
||||
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_component_file(file_path: str) -> bool:
|
||||
"""Check if file is a React component."""
|
||||
path = Path(file_path)
|
||||
|
||||
# Must be a tsx/jsx file
|
||||
if path.suffix.lower() not in [".tsx", ".jsx"]:
|
||||
return False
|
||||
|
||||
# Skip story files, test files, index files
|
||||
name = path.stem.lower()
|
||||
if any(x in name for x in [".stories", ".story", ".test", ".spec", "index"]):
|
||||
return False
|
||||
|
||||
# Check if in component-like directory
|
||||
parts = str(path).lower()
|
||||
component_dirs = ["components", "ui", "atoms", "molecules", "organisms", "templates"]
|
||||
return any(d in parts for d in component_dirs)
|
||||
|
||||
def find_story_file(component_path: str) -> tuple:
|
||||
"""Find corresponding story file for a component."""
|
||||
path = Path(component_path)
|
||||
base_name = path.stem
|
||||
parent = path.parent
|
||||
|
||||
story_extensions = [".stories.tsx", ".stories.jsx", ".stories.ts", ".stories.js"]
|
||||
|
||||
# Check same directory
|
||||
for ext in story_extensions:
|
||||
story_path = parent / f"{base_name}{ext}"
|
||||
if story_path.exists():
|
||||
return (True, str(story_path))
|
||||
|
||||
# Check __stories__ subdirectory
|
||||
stories_dir = parent / "__stories__"
|
||||
if stories_dir.exists():
|
||||
for ext in story_extensions:
|
||||
story_path = stories_dir / f"{base_name}{ext}"
|
||||
if story_path.exists():
|
||||
return (True, str(story_path))
|
||||
|
||||
# Check stories subdirectory
|
||||
stories_dir = parent / "stories"
|
||||
if stories_dir.exists():
|
||||
for ext in story_extensions:
|
||||
story_path = stories_dir / f"{base_name}{ext}"
|
||||
if story_path.exists():
|
||||
return (True, str(story_path))
|
||||
|
||||
return (False, None)
|
||||
|
||||
def detect_props_change(content: str) -> bool:
|
||||
"""Detect if content includes prop changes."""
|
||||
prop_patterns = [
|
||||
r"interface\s+\w+Props",
|
||||
r"type\s+\w+Props\s*=",
|
||||
r"Props\s*=\s*\{",
|
||||
r"defaultProps\s*=",
|
||||
r"propTypes\s*="
|
||||
]
|
||||
|
||||
for pattern in prop_patterns:
|
||||
if re.search(pattern, content):
|
||||
return True
|
||||
return False
|
||||
|
||||
def format_reminder(file_path: str, has_story: bool, story_path: str, props_changed: bool) -> str:
|
||||
"""Format the reminder message."""
|
||||
lines = [f"\n=== DSS Storybook Reminder ===\n"]
|
||||
|
||||
component_name = Path(file_path).stem
|
||||
|
||||
if not has_story:
|
||||
lines.append(f"[NEW] Component '{component_name}' has no Storybook story!")
|
||||
lines.append(f" Consider creating: {component_name}.stories.tsx")
|
||||
lines.append("")
|
||||
lines.append(" Quick template:")
|
||||
lines.append(f" import {{ {component_name} }} from './{component_name}';")
|
||||
lines.append(f" export default {{ title: 'Components/{component_name}' }};")
|
||||
lines.append(f" export const Default = () => <{component_name} />;")
|
||||
elif props_changed:
|
||||
lines.append(f"[UPDATE] Props changed in '{component_name}'")
|
||||
lines.append(f" Story file: {story_path}")
|
||||
lines.append(" Consider updating stories to reflect new props.")
|
||||
|
||||
lines.append("")
|
||||
lines.append("=" * 40)
|
||||
return "\n".join(lines)
|
||||
|
||||
def main():
|
||||
"""Main hook entry point."""
|
||||
config = get_config()
|
||||
|
||||
if not config.get("storybook_reminder", {}).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", "")
|
||||
|
||||
# Only check component files
|
||||
if not is_component_file(file_path):
|
||||
sys.exit(0)
|
||||
|
||||
# Get content
|
||||
if tool_name == "Write":
|
||||
content = tool_input.get("content", "")
|
||||
elif tool_name == "Edit":
|
||||
content = tool_input.get("new_string", "")
|
||||
else:
|
||||
content = ""
|
||||
|
||||
# Check for story file
|
||||
has_story, story_path = find_story_file(file_path)
|
||||
|
||||
# Check for props changes
|
||||
props_changed = detect_props_change(content) if content else False
|
||||
|
||||
reminder_config = config.get("storybook_reminder", {})
|
||||
|
||||
# Determine if we should show reminder
|
||||
should_remind = False
|
||||
if not has_story and reminder_config.get("remind_on_new", True):
|
||||
should_remind = True
|
||||
elif has_story and props_changed and reminder_config.get("remind_on_props_change", True):
|
||||
should_remind = True
|
||||
|
||||
if should_remind:
|
||||
output = format_reminder(file_path, has_story, story_path, props_changed)
|
||||
print(output, file=sys.stderr)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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