Files
dss/tools/analyze/graph.py
Digital Production Factory 276ed71f31 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
2025-12-09 18:45:48 -03:00

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