283 lines
7.3 KiB
Python
283 lines
7.3 KiB
Python
"""
|
|
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/
|
|
- '<ModuleName>': 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
|