""" Project dependency graph utilities. Provides functions to: - Build a dependency graph from parsed projects - Get transitive dependencies - Classify dependencies as internal or external to a module """ import logging from pathlib import Path from typing import Optional from .models import CsprojProject logger = logging.getLogger(__name__) def build_dependency_graph( projects: list[CsprojProject], ) -> dict[Path, set[Path]]: """ Build a dependency graph from a list of projects. Args: projects: List of parsed CsprojProject objects Returns: Dictionary mapping project path to set of dependency paths """ graph: dict[Path, set[Path]] = {} for project in projects: graph[project.path] = set(project.project_references) return graph def get_transitive_dependencies( project_path: Path, graph: dict[Path, set[Path]], visited: Optional[set[Path]] = None, ) -> set[Path]: """ Get all transitive dependencies for a project. Handles circular dependencies gracefully by tracking visited nodes. Args: project_path: Path to the project graph: Dependency graph from build_dependency_graph visited: Set of already visited paths (for cycle detection) Returns: Set of all transitive dependency paths """ if visited is None: visited = set() if project_path in visited: return set() # Cycle detected visited.add(project_path) all_deps: set[Path] = set() direct_deps = graph.get(project_path, set()) all_deps.update(direct_deps) for dep in direct_deps: transitive = get_transitive_dependencies(dep, graph, visited.copy()) all_deps.update(transitive) return all_deps def classify_dependencies( project: CsprojProject, module_dir: Path, src_root: Path, ) -> dict[str, list[Path]]: """ Classify project dependencies as internal or external. Args: project: The project to analyze module_dir: Root directory of the module src_root: Root of the src/ directory Returns: Dictionary with keys: - 'internal': Dependencies within module_dir - '__Libraries': Dependencies from src/__Libraries/ - '': Dependencies from other modules """ result: dict[str, list[Path]] = {"internal": []} module_dir = module_dir.resolve() src_root = src_root.resolve() for ref_path in project.project_references: ref_path = ref_path.resolve() # Check if internal to module try: ref_path.relative_to(module_dir) result["internal"].append(ref_path) continue except ValueError: pass # External - classify by source module category = _get_external_category(ref_path, src_root) if category not in result: result[category] = [] result[category].append(ref_path) return result def _get_external_category(ref_path: Path, src_root: Path) -> str: """ Determine the category for an external dependency. Args: ref_path: Path to the referenced project src_root: Root of the src/ directory Returns: Category name (e.g., '__Libraries', 'Authority', 'Scanner') """ try: rel_path = ref_path.relative_to(src_root) except ValueError: # Outside of src/ - use 'External' return "External" parts = rel_path.parts if len(parts) == 0: return "External" # First part is the module or __Libraries/__Tests etc. first_part = parts[0] if first_part == "__Libraries": return "__Libraries" elif first_part == "__Tests": return "__Tests" elif first_part == "__Analyzers": return "__Analyzers" else: # It's a module name return first_part def collect_all_external_dependencies( projects: list[CsprojProject], module_dir: Path, src_root: Path, project_map: dict[Path, CsprojProject], ) -> dict[str, list[CsprojProject]]: """ Collect all external dependencies for a module's projects. Includes transitive dependencies. Args: projects: List of projects in the module module_dir: Root directory of the module src_root: Root of the src/ directory project_map: Map from path to CsprojProject for all known projects Returns: Dictionary mapping category to list of external CsprojProject objects """ # Build dependency graph for all known projects all_projects = list(project_map.values()) graph = build_dependency_graph(all_projects) module_dir = module_dir.resolve() src_root = src_root.resolve() # Collect all external dependencies external_deps: dict[str, set[Path]] = {} for project in projects: # Get all transitive dependencies all_deps = get_transitive_dependencies(project.path, graph) for dep_path in all_deps: dep_path = dep_path.resolve() # Skip if internal to module try: dep_path.relative_to(module_dir) continue except ValueError: pass # External - classify category = _get_external_category(dep_path, src_root) if category not in external_deps: external_deps[category] = set() external_deps[category].add(dep_path) # Convert paths to CsprojProject objects result: dict[str, list[CsprojProject]] = {} for category, paths in external_deps.items(): result[category] = [] for path in sorted(paths): if path in project_map: result[category].append(project_map[path]) else: logger.warning(f"External dependency not in project map: {path}") return result def get_module_projects( module_dir: Path, all_projects: list[CsprojProject], ) -> list[CsprojProject]: """ Get all projects that belong to a module. Args: module_dir: Root directory of the module all_projects: List of all projects Returns: List of projects within the module directory """ module_dir = module_dir.resolve() result: list[CsprojProject] = [] for project in all_projects: try: project.path.relative_to(module_dir) result.append(project) except ValueError: pass return result def detect_circular_dependencies( graph: dict[Path, set[Path]], ) -> list[list[Path]]: """ Detect circular dependencies in the project graph. Args: graph: Dependency graph Returns: List of cycles (each cycle is a list of paths) """ cycles: list[list[Path]] = [] visited: set[Path] = set() rec_stack: set[Path] = set() def dfs(node: Path, path: list[Path]) -> None: visited.add(node) rec_stack.add(node) path.append(node) for neighbor in graph.get(node, set()): if neighbor not in visited: dfs(neighbor, path.copy()) elif neighbor in rec_stack: # Found a cycle cycle_start = path.index(neighbor) cycle = path[cycle_start:] + [neighbor] cycles.append(cycle) rec_stack.remove(node) for node in graph: if node not in visited: dfs(node, []) return cycles