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
|