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