319 lines
12 KiB
Python
319 lines
12 KiB
Python
"""
|
||
Модуль 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
|