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:
419
tools/analyze/graph.py
Normal file
419
tools/analyze/graph.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""
|
||||
Dependency Graph Builder
|
||||
|
||||
Builds component and style dependency graphs for visualization
|
||||
and analysis of project structure.
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, Set, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphNode:
|
||||
"""A node in the dependency graph."""
|
||||
id: str
|
||||
name: str
|
||||
type: str # 'component', 'style', 'util', 'hook'
|
||||
path: str
|
||||
size: int = 0 # file size or importance metric
|
||||
children: List[str] = field(default_factory=list)
|
||||
parents: List[str] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'path': self.path,
|
||||
'size': self.size,
|
||||
'children': self.children,
|
||||
'parents': self.parents,
|
||||
'metadata': self.metadata,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphEdge:
|
||||
"""An edge in the dependency graph."""
|
||||
source: str
|
||||
target: str
|
||||
type: str # 'import', 'uses', 'styles'
|
||||
weight: int = 1
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'source': self.source,
|
||||
'target': self.target,
|
||||
'type': self.type,
|
||||
'weight': self.weight,
|
||||
}
|
||||
|
||||
|
||||
class DependencyGraph:
|
||||
"""
|
||||
Builds and analyzes dependency graphs for a project.
|
||||
|
||||
Tracks:
|
||||
- Component imports/exports
|
||||
- Style file dependencies
|
||||
- Component usage relationships
|
||||
"""
|
||||
|
||||
def __init__(self, root_path: str):
|
||||
self.root = Path(root_path).resolve()
|
||||
self.nodes: Dict[str, GraphNode] = {}
|
||||
self.edges: List[GraphEdge] = []
|
||||
|
||||
async def build(self, depth: int = 3) -> Dict[str, Any]:
|
||||
"""
|
||||
Build the full dependency graph.
|
||||
|
||||
Args:
|
||||
depth: Maximum depth for traversing dependencies
|
||||
|
||||
Returns:
|
||||
Graph representation with nodes and edges
|
||||
"""
|
||||
# Clear existing graph
|
||||
self.nodes.clear()
|
||||
self.edges.clear()
|
||||
|
||||
# Find all relevant files
|
||||
await self._scan_files()
|
||||
|
||||
# Build edges from imports
|
||||
await self._build_import_edges()
|
||||
|
||||
# Build edges from component usage
|
||||
await self._build_usage_edges()
|
||||
|
||||
return self.to_dict()
|
||||
|
||||
async def _scan_files(self) -> None:
|
||||
"""Scan project files and create nodes."""
|
||||
skip_dirs = {'node_modules', '.git', 'dist', 'build', '.next'}
|
||||
|
||||
# Component files
|
||||
for ext in ['*.jsx', '*.tsx']:
|
||||
for file_path in self.root.rglob(ext):
|
||||
if any(skip in file_path.parts for skip in skip_dirs):
|
||||
continue
|
||||
|
||||
rel_path = str(file_path.relative_to(self.root))
|
||||
node_id = self._path_to_id(rel_path)
|
||||
|
||||
self.nodes[node_id] = GraphNode(
|
||||
id=node_id,
|
||||
name=file_path.stem,
|
||||
type='component',
|
||||
path=rel_path,
|
||||
size=file_path.stat().st_size,
|
||||
)
|
||||
|
||||
# Style files
|
||||
for ext in ['*.css', '*.scss', '*.sass', '*.less']:
|
||||
for file_path in self.root.rglob(ext):
|
||||
if any(skip in file_path.parts for skip in skip_dirs):
|
||||
continue
|
||||
|
||||
rel_path = str(file_path.relative_to(self.root))
|
||||
node_id = self._path_to_id(rel_path)
|
||||
|
||||
self.nodes[node_id] = GraphNode(
|
||||
id=node_id,
|
||||
name=file_path.stem,
|
||||
type='style',
|
||||
path=rel_path,
|
||||
size=file_path.stat().st_size,
|
||||
)
|
||||
|
||||
# Utility/Hook files
|
||||
for ext in ['*.js', '*.ts']:
|
||||
for file_path in self.root.rglob(ext):
|
||||
if any(skip in file_path.parts for skip in skip_dirs):
|
||||
continue
|
||||
|
||||
name = file_path.stem.lower()
|
||||
rel_path = str(file_path.relative_to(self.root))
|
||||
node_id = self._path_to_id(rel_path)
|
||||
|
||||
# Classify file type
|
||||
if 'hook' in name or name.startswith('use'):
|
||||
node_type = 'hook'
|
||||
elif any(x in name for x in ['util', 'helper', 'lib']):
|
||||
node_type = 'util'
|
||||
else:
|
||||
continue # Skip other JS/TS files
|
||||
|
||||
self.nodes[node_id] = GraphNode(
|
||||
id=node_id,
|
||||
name=file_path.stem,
|
||||
type=node_type,
|
||||
path=rel_path,
|
||||
size=file_path.stat().st_size,
|
||||
)
|
||||
|
||||
async def _build_import_edges(self) -> None:
|
||||
"""Build edges from import statements."""
|
||||
import_pattern = re.compile(
|
||||
r'import\s+(?:\{[^}]+\}|\*\s+as\s+\w+|\w+)?\s*(?:,\s*\{[^}]+\})?\s*from\s+["\']([^"\']+)["\']',
|
||||
re.MULTILINE
|
||||
)
|
||||
|
||||
for node_id, node in self.nodes.items():
|
||||
if node.type not in ['component', 'hook', 'util']:
|
||||
continue
|
||||
|
||||
file_path = self.root / node.path
|
||||
if not file_path.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8', errors='ignore')
|
||||
|
||||
for match in import_pattern.finditer(content):
|
||||
import_path = match.group(1)
|
||||
|
||||
# Resolve relative imports
|
||||
target_id = self._resolve_import(node.path, import_path)
|
||||
|
||||
if target_id and target_id in self.nodes:
|
||||
# Add edge
|
||||
self.edges.append(GraphEdge(
|
||||
source=node_id,
|
||||
target=target_id,
|
||||
type='import',
|
||||
))
|
||||
|
||||
# Update parent/child relationships
|
||||
node.children.append(target_id)
|
||||
self.nodes[target_id].parents.append(node_id)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
async def _build_usage_edges(self) -> None:
|
||||
"""Build edges from component usage in JSX."""
|
||||
# Pattern to find JSX component usage
|
||||
jsx_pattern = re.compile(r'<([A-Z][A-Za-z0-9]*)')
|
||||
|
||||
# Build name -> id mapping for components
|
||||
name_to_id = {}
|
||||
for node_id, node in self.nodes.items():
|
||||
if node.type == 'component':
|
||||
name_to_id[node.name] = node_id
|
||||
|
||||
for node_id, node in self.nodes.items():
|
||||
if node.type != 'component':
|
||||
continue
|
||||
|
||||
file_path = self.root / node.path
|
||||
if not file_path.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8', errors='ignore')
|
||||
|
||||
used_components = set()
|
||||
for match in jsx_pattern.finditer(content):
|
||||
comp_name = match.group(1)
|
||||
if comp_name in name_to_id and name_to_id[comp_name] != node_id:
|
||||
used_components.add(name_to_id[comp_name])
|
||||
|
||||
for target_id in used_components:
|
||||
self.edges.append(GraphEdge(
|
||||
source=node_id,
|
||||
target=target_id,
|
||||
type='uses',
|
||||
))
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def _path_to_id(self, path: str) -> str:
|
||||
"""Convert file path to node ID."""
|
||||
# Remove extension and normalize
|
||||
path = re.sub(r'\.(jsx?|tsx?|css|scss|sass|less)$', '', path)
|
||||
return path.replace('/', '_').replace('\\', '_').replace('.', '_')
|
||||
|
||||
def _resolve_import(self, source_path: str, import_path: str) -> Optional[str]:
|
||||
"""Resolve import path to node ID."""
|
||||
if not import_path.startswith('.'):
|
||||
return None # Skip node_modules imports
|
||||
|
||||
source_dir = Path(source_path).parent
|
||||
|
||||
# Handle various import patterns
|
||||
if import_path.startswith('./'):
|
||||
resolved = source_dir / import_path[2:]
|
||||
elif import_path.startswith('../'):
|
||||
resolved = source_dir / import_path
|
||||
else:
|
||||
resolved = source_dir / import_path
|
||||
|
||||
# Try to resolve with extensions
|
||||
extensions = ['.tsx', '.ts', '.jsx', '.js', '.css', '.scss', '/index.tsx', '/index.ts', '/index.jsx', '/index.js']
|
||||
|
||||
resolved_str = str(resolved)
|
||||
for ext in extensions:
|
||||
test_id = self._path_to_id(resolved_str + ext)
|
||||
if test_id in self.nodes:
|
||||
return test_id
|
||||
|
||||
# Try without additional extension (if path already has one)
|
||||
test_id = self._path_to_id(resolved_str)
|
||||
if test_id in self.nodes:
|
||||
return test_id
|
||||
|
||||
return None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert graph to dictionary for serialization."""
|
||||
return {
|
||||
'nodes': [node.to_dict() for node in self.nodes.values()],
|
||||
'edges': [edge.to_dict() for edge in self.edges],
|
||||
'stats': {
|
||||
'total_nodes': len(self.nodes),
|
||||
'total_edges': len(self.edges),
|
||||
'components': len([n for n in self.nodes.values() if n.type == 'component']),
|
||||
'styles': len([n for n in self.nodes.values() if n.type == 'style']),
|
||||
'hooks': len([n for n in self.nodes.values() if n.type == 'hook']),
|
||||
'utils': len([n for n in self.nodes.values() if n.type == 'util']),
|
||||
}
|
||||
}
|
||||
|
||||
def to_json(self, pretty: bool = True) -> str:
|
||||
"""Convert graph to JSON string."""
|
||||
return json.dumps(self.to_dict(), indent=2 if pretty else None)
|
||||
|
||||
def get_component_tree(self) -> Dict[str, List[str]]:
|
||||
"""Get simplified component dependency tree."""
|
||||
tree = {}
|
||||
for node_id, node in self.nodes.items():
|
||||
if node.type == 'component':
|
||||
tree[node.name] = [
|
||||
self.nodes[child_id].name
|
||||
for child_id in node.children
|
||||
if child_id in self.nodes and self.nodes[child_id].type == 'component'
|
||||
]
|
||||
return tree
|
||||
|
||||
def find_orphans(self) -> List[str]:
|
||||
"""Find components with no parents (not imported anywhere)."""
|
||||
orphans = []
|
||||
for node_id, node in self.nodes.items():
|
||||
if node.type == 'component' and not node.parents:
|
||||
# Exclude entry points (index, App, etc.)
|
||||
if node.name.lower() not in ['app', 'index', 'main', 'root']:
|
||||
orphans.append(node.path)
|
||||
return orphans
|
||||
|
||||
def find_hubs(self, min_connections: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Find highly connected nodes (potential refactoring targets)."""
|
||||
hubs = []
|
||||
for node_id, node in self.nodes.items():
|
||||
connections = len(node.children) + len(node.parents)
|
||||
if connections >= min_connections:
|
||||
hubs.append({
|
||||
'name': node.name,
|
||||
'path': node.path,
|
||||
'type': node.type,
|
||||
'imports': len(node.children),
|
||||
'imported_by': len(node.parents),
|
||||
'total_connections': connections,
|
||||
})
|
||||
|
||||
hubs.sort(key=lambda x: x['total_connections'], reverse=True)
|
||||
return hubs
|
||||
|
||||
def find_circular_dependencies(self) -> List[List[str]]:
|
||||
"""Find circular dependency chains."""
|
||||
cycles = []
|
||||
visited = set()
|
||||
rec_stack = set()
|
||||
|
||||
def dfs(node_id: str, path: List[str]) -> None:
|
||||
visited.add(node_id)
|
||||
rec_stack.add(node_id)
|
||||
path.append(node_id)
|
||||
|
||||
for child_id in self.nodes.get(node_id, GraphNode('', '', '', '')).children:
|
||||
if child_id not in visited:
|
||||
dfs(child_id, path.copy())
|
||||
elif child_id in rec_stack:
|
||||
# Found cycle
|
||||
cycle_start = path.index(child_id)
|
||||
cycle = path[cycle_start:] + [child_id]
|
||||
cycles.append([self.nodes[n].name for n in cycle])
|
||||
|
||||
rec_stack.remove(node_id)
|
||||
|
||||
for node_id in self.nodes:
|
||||
if node_id not in visited:
|
||||
dfs(node_id, [])
|
||||
|
||||
return cycles
|
||||
|
||||
def get_subgraph(self, node_id: str, depth: int = 2) -> Dict[str, Any]:
|
||||
"""Get subgraph centered on a specific node."""
|
||||
if node_id not in self.nodes:
|
||||
return {'nodes': [], 'edges': []}
|
||||
|
||||
# BFS to find nodes within depth
|
||||
included_nodes = {node_id}
|
||||
frontier = {node_id}
|
||||
|
||||
for _ in range(depth):
|
||||
new_frontier = set()
|
||||
for nid in frontier:
|
||||
node = self.nodes.get(nid)
|
||||
if node:
|
||||
new_frontier.update(node.children)
|
||||
new_frontier.update(node.parents)
|
||||
included_nodes.update(new_frontier)
|
||||
frontier = new_frontier
|
||||
|
||||
# Filter nodes and edges
|
||||
subgraph_nodes = [
|
||||
self.nodes[nid].to_dict()
|
||||
for nid in included_nodes
|
||||
if nid in self.nodes
|
||||
]
|
||||
|
||||
subgraph_edges = [
|
||||
edge.to_dict()
|
||||
for edge in self.edges
|
||||
if edge.source in included_nodes and edge.target in included_nodes
|
||||
]
|
||||
|
||||
return {
|
||||
'nodes': subgraph_nodes,
|
||||
'edges': subgraph_edges,
|
||||
'center': node_id,
|
||||
'depth': depth,
|
||||
}
|
||||
|
||||
def get_style_dependencies(self) -> Dict[str, List[str]]:
|
||||
"""Get mapping of components to their style dependencies."""
|
||||
style_deps = {}
|
||||
|
||||
for node_id, node in self.nodes.items():
|
||||
if node.type != 'component':
|
||||
continue
|
||||
|
||||
style_children = [
|
||||
self.nodes[child_id].path
|
||||
for child_id in node.children
|
||||
if child_id in self.nodes and self.nodes[child_id].type == 'style'
|
||||
]
|
||||
|
||||
if style_children:
|
||||
style_deps[node.path] = style_children
|
||||
|
||||
return style_deps
|
||||
Reference in New Issue
Block a user