""" 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