arch-researcher/dependency_analyzer.py

319 lines
12 KiB
Python
Raw Normal View History

2026-04-27 18:44:22 +00:00
"""
Модуль DependencyAnalyzer построение и анализ графа зависимостей.
Алгоритм:
1. Для C++ файлов извлекаются #include-директивы:
- через libclang AST (если установлен) точный разбор
- через regex (fallback) лексический разбор
2. Для JS файлов regex-парсинг require() и import ... from
3. #include / import разрешаются до имени модуля (директории)
4. Строится ориентированный граф зависимостей (networkx DiGraph)
5. Вычисляются метрики: coupling_in, coupling_out, instability
6. Обнаруживаются циклические зависимости (strongly connected components)
Метрика нестабильности (Instability):
I = C_out / (C_in + C_out), где
C_out исходящая связность (fan-out),
C_in входящая связность (fan-in).
I = 0 абсолютно стабильный модуль (ничего не импортирует)
I = 1 абсолютно нестабильный модуль (никто не зависит от него)
Ссылки:
Martin R., Clean Architecture, 2018 (принципы стабильности)
NetworkX: https://networkx.org
"""
import logging
import re
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
from config import AnalyzerConfig, DEFAULT_CONFIG
from models import ModuleMetrics
logger = logging.getLogger(__name__)
# Пытаемся подключить networkx
try:
import networkx as nx
_NX_AVAILABLE = True
except ImportError:
_NX_AVAILABLE = False
# Пытаемся подключить libclang
try:
import clang.cindex as clang
_CLANG_AVAILABLE = True
except ImportError:
_CLANG_AVAILABLE = False
# Regex для fallback-разбора #include
_RE_INCLUDE = re.compile(r'^\s*#\s*include\s+[<"]([^>"]+)[>"]', re.MULTILINE)
# Regex для JS import / require
_RE_JS_IMPORT = re.compile(
r'(?:import\s+.*?\s+from\s+|require\s*\(\s*)[\'"]([^\'"]+)[\'"]',
re.MULTILINE,
)
class DependencyAnalyzer:
"""
Строит граф зависимостей между модулями и вычисляет метрики связности.
"""
def __init__(self, config: AnalyzerConfig = DEFAULT_CONFIG):
self.config = config
if not _NX_AVAILABLE:
raise RuntimeError(
"Библиотека 'networkx' не установлена. "
"Выполните: pip install networkx"
)
# Инициализируем libclang если доступен и указан путь
self._clang_index: Optional[object] = None
if _CLANG_AVAILABLE:
try:
if config.libclang_path:
clang.Config.set_library_file(config.libclang_path)
self._clang_index = clang.Index.create()
logger.info("libclang инициализирован — используется AST-анализ для C++")
except Exception as exc:
logger.warning("libclang недоступен (%s), используется regex fallback", exc)
else:
logger.info("libclang не установлен — используется regex fallback для #include")
# ------------------------------------------------------------------
# Публичный API
# ------------------------------------------------------------------
def analyze(
self,
modules: Dict[str, ModuleMetrics],
project_root: str,
) -> Tuple[Dict[str, ModuleMetrics], List[List[str]]]:
"""
Строит граф зависимостей и заполняет coupling-метрики в модулях.
:returns: (обновлённые модули, список циклов зависимостей)
"""
root = Path(project_root).resolve()
# --- Построение файл → модуль ---
file_to_module = self._build_file_to_module_map(modules)
# --- Граф зависимостей ---
graph: nx.DiGraph = nx.DiGraph()
graph.add_nodes_from(modules.keys())
for module_name, module in modules.items():
deps = self._collect_module_dependencies(module, file_to_module, root)
module.dependencies = deps
for dep in deps:
if dep != module_name and dep in modules:
graph.add_edge(module_name, dep)
# --- Вычисляем coupling ---
for module_name, module in modules.items():
module.coupling_out = graph.out_degree(module_name)
module.coupling_in = graph.in_degree(module_name)
total = module.coupling_in + module.coupling_out
module.instability = module.coupling_out / total if total > 0 else 0.0
# --- Dependents (обратные зависимости) ---
for module_name, module in modules.items():
module.dependents = set(graph.predecessors(module_name))
# --- Циклические зависимости (SCC с размером > 1) ---
cycles = self._find_cycles(graph)
# Отмечаем модули, участвующие в циклах
cyclic_modules: Set[str] = set()
for cycle in cycles:
cyclic_modules.update(cycle)
for module_name in cyclic_modules:
if module_name in modules:
modules[module_name].has_cycles = True
logger.info(
"DependencyAnalyzer: рёбер в графе=%d, циклов=%d",
graph.number_of_edges(),
len(cycles),
)
return modules, cycles
# ------------------------------------------------------------------
# Извлечение зависимостей
# ------------------------------------------------------------------
def _collect_module_dependencies(
self,
module: ModuleMetrics,
file_to_module: Dict[str, str],
root: Path,
) -> Set[str]:
"""Собирает зависимости модуля из всех его файлов."""
deps: Set[str] = set()
for file_path in module.files:
file_deps = self._extract_file_dependencies(file_path, file_to_module, root)
deps.update(file_deps)
return deps
def _extract_file_dependencies(
self,
file_path: str,
file_to_module: Dict[str, str],
root: Path,
) -> Set[str]:
"""Извлекает зависимости из одного файла."""
suffix = Path(file_path).suffix.lower()
if suffix in self.config.cpp_extensions:
raw_includes = self._extract_cpp_includes(file_path)
return self._resolve_includes_to_modules(
raw_includes, file_path, file_to_module, root
)
if suffix in self.config.js_extensions:
raw_imports = self._extract_js_imports(file_path)
return self._resolve_js_imports_to_modules(
raw_imports, file_path, file_to_module, root
)
return set()
def _extract_cpp_includes(self, file_path: str) -> List[str]:
"""
Извлекает #include-директивы из C++ файла.
Предпочтительно через libclang AST, fallback regex.
"""
if self._clang_index is not None:
return self._extract_includes_clang(file_path)
return self._extract_includes_regex(file_path)
def _extract_includes_clang(self, file_path: str) -> List[str]:
"""Извлечение #include через libclang AST."""
includes: List[str] = []
try:
tu = self._clang_index.parse(
file_path,
args=["-std=c++17"],
options=clang.TranslationUnit.PARSE_SKIP_FUNCTION_BODIES,
)
for inc in tu.get_includes():
if inc.depth == 1: # только прямые зависимости
includes.append(inc.include.name)
except Exception as exc:
logger.debug("clang ошибка для %s: %s, использую regex", file_path, exc)
return self._extract_includes_regex(file_path)
return includes
def _extract_includes_regex(self, file_path: str) -> List[str]:
"""Fallback: извлечение #include через regex."""
try:
text = Path(file_path).read_text(encoding="utf-8", errors="replace")
except OSError:
return []
return _RE_INCLUDE.findall(text)
def _extract_js_imports(self, file_path: str) -> List[str]:
"""Извлечение import/require из JS/TS файла через regex."""
try:
text = Path(file_path).read_text(encoding="utf-8", errors="replace")
except OSError:
return []
return _RE_JS_IMPORT.findall(text)
# ------------------------------------------------------------------
# Разрешение имён в модули
# ------------------------------------------------------------------
def _resolve_includes_to_modules(
self,
includes: List[str],
source_file: str,
file_to_module: Dict[str, str],
root: Path,
) -> Set[str]:
"""
Пытается сопоставить #include с именем модуля в проекте.
Системные заголовки (без '/' и с < >) игнорируются.
"""
deps: Set[str] = set()
source_dir = Path(source_file).parent
for inc in includes:
# Пробуем относительно директории файла
candidate = (source_dir / inc).resolve()
key = str(candidate)
if key in file_to_module:
deps.add(file_to_module[key])
continue
# Пробуем относительно корня проекта
candidate = (root / inc).resolve()
key = str(candidate)
if key in file_to_module:
deps.add(file_to_module[key])
return deps
def _resolve_js_imports_to_modules(
self,
imports: List[str],
source_file: str,
file_to_module: Dict[str, str],
root: Path,
) -> Set[str]:
"""
Разрешает JS import-пути в имена модулей.
Внешние пакеты (не начинаются с '.') игнорируются.
"""
deps: Set[str] = set()
source_dir = Path(source_file).parent
for imp in imports:
if not imp.startswith("."):
continue # внешний пакет — не наш модуль
# Пробуем добавить расширения
base = (source_dir / imp).resolve()
candidates = [base]
for ext in self.config.js_extensions:
candidates.append(base.with_suffix(ext))
candidates.append(base / f"index{ext}")
for candidate in candidates:
key = str(candidate)
if key in file_to_module:
deps.add(file_to_module[key])
break
return deps
# ------------------------------------------------------------------
# Вспомогательные методы
# ------------------------------------------------------------------
@staticmethod
def _build_file_to_module_map(modules: Dict[str, ModuleMetrics]) -> Dict[str, str]:
"""Строит обратный индекс: абсолютный путь файла → имя модуля."""
mapping: Dict[str, str] = {}
for module_name, module in modules.items():
for file_path in module.files:
resolved = str(Path(file_path).resolve())
mapping[resolved] = module_name
return mapping
@staticmethod
def _find_cycles(graph: "nx.DiGraph") -> List[List[str]]:
"""Находит все циклы в графе через strongly connected components."""
cycles: List[List[str]] = []
for scc in nx.strongly_connected_components(graph):
if len(scc) > 1:
cycles.append(sorted(scc))
return cycles