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
420 lines
14 KiB
Python
420 lines
14 KiB
Python
"""
|
|
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
|