arch-researcher/dependency_analyzer.py
2026-04-27 23:44:22 +05:00

319 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Модуль 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