Files
git.stella-ops.org/tools/slntools/lib/dependency_graph.py
StellaOps Bot cec4265a40 save progress
2025-12-28 01:40:52 +02:00

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