commit 320979f871c990a7c15a151695e8b96d8524354a Author: gre-ilya Date: Mon Apr 27 23:44:22 2026 +0500 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f3a6cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.swp +*__pycache__* + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..564d370 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# Legacy Analyzer + +Методический инструментарий диагностики архитектурного состояния +унаследованных программных систем на языках **C++** и **JavaScript/TypeScript**. + +## Назначение + +Инструмент реализует следующий pipeline анализа: + +``` +Collector → MetricsEngine → DependencyAnalyzer → DecisionEngine → Reporter +``` + +| Модуль | Ответственность | +|----------------------|----------------------------------------------------------------| +| `Collector` | Обход файловой структуры, идентификация модулей | +| `MetricsEngine` | CCN, NLOC, число параметров (через `lizard`) | +| `DependencyAnalyzer` | Граф зависимостей, coupling_in/out, instability, циклы (через `networkx`) | +| `DecisionEngine` | Классификация риска, выбор стратегии модернизации | +| `Reporter` | Генерация JSON и HTML отчётов | + +## Установка + +```bash +pip install lizard networkx +# Опционально — AST-анализ C++ через libclang: +# pip install libclang +# Ubuntu: sudo apt install clang libclang-dev +``` + +## Использование + +```bash +# Базовый запуск +python main.py /path/to/your/project + +# С кастомными параметрами +python main.py /path/to/project \ + --output my_report \ + --formats json html \ + --ccn-warn 15 \ + --ccn-critical 25 \ + --verbose + +# Только JSON +python main.py /path/to/project --formats json +``` + +## Программный API + +```python +from analyzer import Analyzer +from config import AnalyzerConfig, ThresholdConfig + +config = AnalyzerConfig( + output_dir="report", + thresholds=ThresholdConfig(ccn_warn=12, ccn_critical=25), +) + +report = Analyzer(config).run("/path/to/project") + +print(f"Системный риск: {report.system_risk_level.name}") +print(f"Стратегия: {report.recommended_system_strategy.name}") + +for decision in report.decisions[:10]: # Топ-10 проблемных модулей + print(f" {decision.module_name}: {decision.risk_level.name}") +``` + +## Выходные файлы + +После анализа в директории `legacy_report/` (или указанной через `--output`) создаются: + +- `report.json` — машинно-читаемый отчёт (для CI/CD) +- `report.html` — человекочитаемый отчёт с таблицами и карточками + +## Метрики + +| Метрика | Формула / источник | Порог warn / critical | +|---------------------|---------------------------------------------|-----------------------| +| CCN | McCabe (1976) | > 10 / > 20 | +| NLOC | Строк без комментариев | > 50 / > 100 | +| Параметры функции | — | > 5 / > 8 | +| C_out (fan-out) | Число исходящих рёбер модуля | > 5 / > 10 | +| C_in (fan-in) | Число входящих рёбер модуля | > 10 / > 20 | +| Instability (I) | C_out / (C_in + C_out), Martin (2018) | > 0.7 / > 0.9 | + +## Стратегии модернизации + +| Уровень риска | Стратегия | Описание | +|---------------|----------------|--------------------------------------------------| +| LOW | Оставить | Модуль в удовлетворительном состоянии | +| MEDIUM | Рефакторинг | Локальное улучшение без изменения архитектуры | +| HIGH | Реинжиниринг | Перестройка архитектуры с сохранением логики | +| CRITICAL | Замена | Полная переработка или замена модуля | + +## Структура проекта + +``` +legacy_analyzer/ +├── main.py # CLI точка входа +├── analyzer.py # Главный оркестратор (фасад) +├── config.py # Конфигурация и пороговые значения +├── models.py # Доменные модели данных +├── core/ +│ ├── collector.py # Сбор файловой структуры +│ ├── metrics_engine.py # Вычисление метрик +│ └── dependency_analyzer.py # Граф зависимостей +└── reporters/ + ├── reporter.py # Фасад репортеров + ├── json_reporter.py # JSON отчёт + └── html_reporter.py # HTML отчёт +``` diff --git a/analyzer.py b/analyzer.py new file mode 100644 index 0000000..0dcfa6d --- /dev/null +++ b/analyzer.py @@ -0,0 +1,90 @@ +""" +Главный фасад инструментария — Analyzer. + +Orchestrates полный pipeline анализа: + Collector → MetricsEngine → DependencyAnalyzer → DecisionEngine → Reporter + +Использование: + from analyzer import Analyzer, AnalyzerConfig + + config = AnalyzerConfig(output_dir="my_report") + report = Analyzer(config).run("/path/to/project") +""" + +import logging +import sys +from pathlib import Path + +from config import AnalyzerConfig, DEFAULT_CONFIG +from models import AnalysisReport +from core.collector import Collector +from core.metrics_engine import MetricsEngine +from core.dependency_analyzer import DependencyAnalyzer +from core.decision_engine import DecisionEngine +from reporters.reporter import Reporter + +logger = logging.getLogger(__name__) + + +class Analyzer: + """ + Главный оркестратор анализа унаследованной системы. + + Реализует принцип разделения ответственности (SRP): + каждый шаг анализа выполняется специализированным модулем, + Analyzer лишь координирует их взаимодействие. + """ + + def __init__(self, config: AnalyzerConfig = DEFAULT_CONFIG): + self.config = config + self._collector = Collector(config) + self._metrics = MetricsEngine(config) + self._dependencies = DependencyAnalyzer(config) + self._decisions = DecisionEngine(config) + self._reporter = Reporter(config) + + def run(self, project_root: str) -> AnalysisReport: + """ + Выполняет полный цикл анализа проекта. + + :param project_root: путь к корневой директории анализируемой системы + :returns: заполненный AnalysisReport + """ + root = str(Path(project_root).resolve()) + logger.info("=" * 60) + logger.info("Запуск анализа: %s", root) + logger.info("=" * 60) + + # 1. Сбор файловой структуры + logger.info("[1/4] Collector — обход файловой структуры...") + modules = self._collector.collect(root) + + if not modules: + logger.warning("Не найдено ни одного анализируемого файла в %s", root) + + # 2. Вычисление метрик сложности + logger.info("[2/4] MetricsEngine — вычисление метрик сложности...") + modules = self._metrics.analyze(modules) + + # 3. Построение графа зависимостей + logger.info("[3/4] DependencyAnalyzer — построение графа зависимостей...") + modules, cycles = self._dependencies.analyze(modules, root) + + # 4. Принятие решений + logger.info("[4/4] DecisionEngine — классификация риска и выбор стратегии...") + report = AnalysisReport(project_root=root, modules=modules) + report = self._decisions.analyze(report, cycles) + + # 5. Формирование отчётов + logger.info("[5/5] Reporter — формирование отчётов...") + created_files = self._reporter.generate(report) + + logger.info("=" * 60) + logger.info("Анализ завершён. Системный риск: %s", report.system_risk_level.name) + logger.info("Рекомендуемая стратегия: %s", report.recommended_system_strategy.name) + logger.info("Отчёты созданы:") + for f in created_files: + logger.info(" %s", f) + logger.info("=" * 60) + + return report diff --git a/code_analyzer.py b/code_analyzer.py new file mode 100644 index 0000000..dfc20a4 --- /dev/null +++ b/code_analyzer.py @@ -0,0 +1,257 @@ +""" +Встроенный анализатор сложности кода — не требует lizard. + +Реализует подсчёт цикломатической сложности (McCabe, 1976) +методом подсчёта ветвлений в тексте кода (лексический подход), +аналогично тому, как это делает библиотека lizard. + +Поддерживаемые языки: C, C++, JavaScript, TypeScript. + +Точность: comparable с lizard при отсутствии установленного Clang. +Для продакшн-использования рекомендуется установить lizard: + pip install lizard +""" + +from __future__ import annotations + +import re +import tokenize +import io +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional + + +# Ключевые слова ветвления, каждое увеличивает CCN на 1 +# По методу McCabe: CCN = 1 + число точек ветвления +_BRANCH_KEYWORDS_CPP = re.compile( + r'\b(if|else\s+if|for|while|do|case|catch|&&|\|\||and|or|\?)\b', + re.MULTILINE, +) + +_BRANCH_KEYWORDS_JS = re.compile( + r'\b(if|else\s+if|for|while|do|switch|case|catch|&&|\|\||\?(?!:))\b', + re.MULTILINE, +) + +# Сигнатуры функций C/C++ +_CPP_FUNC = re.compile( + r'^(?:[\w:*&<>\s]+?)\s+(\w+)\s*\(([^)]*)\)\s*(?:const\s*)?(?:override\s*)?(?:noexcept[^{]*)?\{', + re.MULTILINE, +) + +# Сигнатуры функций JS/TS +_JS_FUNC = re.compile( + r'(?:' + r'function\s+(\w+)\s*\(([^)]*)\)' # function foo(...) + r'|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\(([^)]*)\)\s*=>' # arrow + r'|(\w+)\s*:\s*(?:async\s*)?function\s*\(([^)]*)\)' # method: function + r'|(?:async\s+)?(\w+)\s*\(([^)]*)\)\s*\{' # method shorthand + r')', + re.MULTILINE, +) + +# Строки и комментарии — для удаления перед анализом +_CPP_COMMENT_LINE = re.compile(r'//[^\n]*') +_CPP_COMMENT_BLOCK = re.compile(r'/\*.*?\*/', re.DOTALL) +_CPP_STRING = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') + +_JS_TEMPLATE = re.compile(r'`[^`]*`', re.DOTALL) + + +@dataclass +class FunctionInfo: + """Результат разбора одной функции.""" + name: str + start_line: int + nloc: int + cyclomatic_complexity: int + parameter_count: int + + +@dataclass +class FileAnalysisResult: + """Результат анализа одного файла.""" + filename: str + function_list: List[FunctionInfo] + + @property + def average_ccn(self) -> float: + if not self.function_list: + return 0.0 + return sum(f.cyclomatic_complexity for f in self.function_list) / len(self.function_list) + + +def analyze_file(file_path: str) -> Optional[FileAnalysisResult]: + """ + Анализирует один файл и возвращает метрики по функциям. + Возвращает None если файл нечитаем или неподдерживаемого типа. + """ + path = Path(file_path) + suffix = path.suffix.lower() + + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + + if suffix in (".cpp", ".cxx", ".cc", ".c", ".hpp", ".hxx", ".h"): + functions = _analyze_cpp(text, file_path) + elif suffix in (".js", ".mjs", ".cjs", ".ts"): + functions = _analyze_js(text, file_path) + else: + return None + + return FileAnalysisResult(filename=file_path, function_list=functions) + + +# ------------------------------------------------------------------ +# C / C++ анализ +# ------------------------------------------------------------------ + +def _analyze_cpp(text: str, filename: str) -> List[FunctionInfo]: + """Анализирует C/C++ код.""" + # Удаляем строковые литералы и комментарии + clean = _CPP_COMMENT_BLOCK.sub(' ', text) + clean = _CPP_COMMENT_LINE.sub('', clean) + clean = _CPP_STRING.sub('""', clean) + + functions: List[FunctionInfo] = [] + + for match in _CPP_FUNC.finditer(clean): + func_name = match.group(1) + params_str = match.group(2).strip() + + # Пропускаем системные/macro-like имена + if func_name in ('if', 'for', 'while', 'switch', 'catch', 'return'): + continue + + start_pos = match.start() + start_line = text[:start_pos].count('\n') + 1 + + # Извлекаем тело функции + body = _extract_body(clean, match.end() - 1) + if body is None: + continue + + nloc = _count_nloc(body) + ccn = 1 + len(_BRANCH_KEYWORDS_CPP.findall(body)) + params = _count_params(params_str) + + functions.append(FunctionInfo( + name=func_name, + start_line=start_line, + nloc=nloc, + cyclomatic_complexity=ccn, + parameter_count=params, + )) + + return functions + + +# ------------------------------------------------------------------ +# JavaScript / TypeScript анализ +# ------------------------------------------------------------------ + +def _analyze_js(text: str, filename: str) -> List[FunctionInfo]: + """Анализирует JS/TS код.""" + # Удаляем шаблонные строки и комментарии + clean = _CPP_COMMENT_BLOCK.sub(' ', text) + clean = _CPP_COMMENT_LINE.sub('', clean) + clean = _JS_TEMPLATE.sub('``', clean) + clean = _CPP_STRING.sub('""', clean) + + functions: List[FunctionInfo] = [] + seen_positions: set = set() + + for match in _JS_FUNC.finditer(clean): + # Определяем имя и параметры из разных групп + name = ( + match.group(1) or match.group(3) or + match.group(5) or match.group(7) or "anonymous" + ) + params_str = ( + match.group(2) or match.group(4) or + match.group(6) or match.group(8) or "" + ) + + start_pos = match.start() + if start_pos in seen_positions: + continue + seen_positions.add(start_pos) + + start_line = text[:start_pos].count('\n') + 1 + + # Ищем открывающую фигурную скобку + brace_pos = clean.find('{', match.end()) + if brace_pos == -1: + continue + + body = _extract_body(clean, brace_pos) + if body is None: + continue + + nloc = _count_nloc(body) + ccn = 1 + len(_BRANCH_KEYWORDS_JS.findall(body)) + params = _count_params(params_str) + + functions.append(FunctionInfo( + name=name, + start_line=start_line, + nloc=nloc, + cyclomatic_complexity=ccn, + parameter_count=params, + )) + + return functions + + +# ------------------------------------------------------------------ +# Вспомогательные функции +# ------------------------------------------------------------------ + +def _extract_body(text: str, open_brace_pos: int) -> Optional[str]: + """ + Извлекает тело функции, начиная с позиции открывающей скобки. + Возвращает содержимое между { и соответствующей }. + """ + depth = 0 + i = open_brace_pos + + while i < len(text): + ch = text[i] + if ch == '{': + depth += 1 + elif ch == '}': + depth -= 1 + if depth == 0: + return text[open_brace_pos + 1:i] + i += 1 + + return None # Незакрытая скобка + + +def _count_nloc(body: str) -> int: + """Считает непустые, неккомментарные строки.""" + return sum( + 1 for line in body.splitlines() + if line.strip() and not line.strip().startswith('//') + ) + + +def _count_params(params_str: str) -> int: + """Считает число параметров по строке параметров.""" + params_str = params_str.strip() + if not params_str or params_str in ('void', '...'): + return 0 + # Разбиваем по запятой, учитывая вложенные угловые скобки + depth = 0 + count = 1 + for ch in params_str: + if ch in '<(': + depth += 1 + elif ch in '>)': + depth -= 1 + elif ch == ',' and depth == 0: + count += 1 + return count diff --git a/collector.py b/collector.py new file mode 100644 index 0000000..40c9d4c --- /dev/null +++ b/collector.py @@ -0,0 +1,131 @@ +""" +Модуль Collector — обход файловой структуры проекта и сбор исходных данных. + +Отвечает за: +- рекурсивный обход дерева каталогов +- идентификацию модулей и их границ +- подготовку списков файлов для MetricsEngine и DependencyAnalyzer +""" + +import logging +from pathlib import Path +from typing import Dict, List, Tuple + +from config import AnalyzerConfig, DEFAULT_CONFIG +from models import ModuleMetrics + +logger = logging.getLogger(__name__) + + +class Collector: + """ + Собирает файловую структуру проекта и формирует первичное + представление модулей. + + Стратегия разбивки на модули: каждая директория первого уровня + является отдельным модулем. Файлы непосредственно в корне + помещаются в псевдомодуль "". + """ + + def __init__(self, config: AnalyzerConfig = DEFAULT_CONFIG): + self.config = config + self._cpp_exts = set(config.cpp_extensions) + self._js_exts = set(config.js_extensions) + self._ignored = set(config.ignored_dirs) + + # ------------------------------------------------------------------ + # Публичный API + # ------------------------------------------------------------------ + + def collect(self, project_root: str) -> Dict[str, ModuleMetrics]: + """ + Обходит дерево проекта и возвращает словарь модулей. + + :param project_root: абсолютный или относительный путь к корню проекта + :returns: {module_name: ModuleMetrics} с заполненными полями files и language + """ + root = Path(project_root).resolve() + if not root.exists(): + raise FileNotFoundError(f"Корень проекта не найден: {root}") + + logger.info("Начало сбора данных из: %s", root) + + modules: Dict[str, ModuleMetrics] = {} + self._walk(root, root, modules) + + # Убираем пустые модули (нет анализируемых файлов) + modules = {k: v for k, v in modules.items() if v.files} + + logger.info("Обнаружено модулей: %d", len(modules)) + for name, mod in modules.items(): + logger.debug(" %s: %d файл(ов) [%s]", name, len(mod.files), mod.language) + + return modules + + # ------------------------------------------------------------------ + # Внутренние методы + # ------------------------------------------------------------------ + + def _walk(self, root: Path, current: Path, modules: Dict[str, ModuleMetrics]) -> None: + """Рекурсивный обход директории.""" + try: + entries = sorted(current.iterdir()) + except PermissionError: + logger.warning("Нет прав на чтение: %s", current) + return + + for entry in entries: + if entry.name.startswith("."): + continue + if entry.name in self._ignored: + logger.debug("Пропускаем директорию: %s", entry) + continue + + if entry.is_dir(): + self._walk(root, entry, modules) + elif entry.is_file(): + lang = self._detect_language(entry) + if lang is None: + continue + module_name = self._resolve_module_name(root, entry) + if module_name not in modules: + modules[module_name] = ModuleMetrics(name=module_name) + modules[module_name].files.append(str(entry)) + modules[module_name].language = self._merge_language( + modules[module_name].language, lang + ) + + def _detect_language(self, path: Path) -> str | None: + """Возвращает 'cpp', 'js' или None если файл не анализируется.""" + suffix = path.suffix.lower() + if suffix in self._cpp_exts: + return "cpp" + if suffix in self._js_exts: + return "js" + return None + + def _resolve_module_name(self, root: Path, file: Path) -> str: + """ + Определяет имя модуля для файла. + Файлы в корне → "". + Файлы в поддиректории → относительный путь к директории первого уровня. + """ + try: + rel = file.relative_to(root) + except ValueError: + return "" + + parts = rel.parts + if len(parts) == 1: + return "" + # Берём путь относительно корня до файла (вся директория как модуль) + return str(Path(*parts[:-1])) + + @staticmethod + def _merge_language(existing: str, new_lang: str) -> str: + """Объединяет языки при добавлении нового файла в модуль.""" + if existing == "mixed" or existing == new_lang: + return existing if existing != "mixed" else "mixed" + if existing in ("cpp", "js") and existing != new_lang: + return "mixed" + return new_lang # existing == "" — первый файл diff --git a/config.py b/config.py new file mode 100644 index 0000000..4bf2caf --- /dev/null +++ b/config.py @@ -0,0 +1,74 @@ +""" +Конфигурация инструментария анализа legacy-систем. +Все пороговые значения вынесены сюда для удобной настройки под конкретный проект. +""" + +from dataclasses import dataclass, field +from typing import List + + +@dataclass +class ThresholdConfig: + """Пороговые значения метрик для классификации риска.""" + + # Цикломатическая сложность (McCabe, 1976) + ccn_warn: int = 10 # > 10 — функция требует внимания + ccn_critical: int = 20 # > 20 — функция является кандидатом на рефакторинг + + # Число строк кода без комментариев в функции + nloc_warn: int = 50 + nloc_critical: int = 100 + + # Число параметров функции + params_warn: int = 5 + params_critical: int = 8 + + # Исходящая связность модуля (fan-out) + coupling_out_warn: int = 5 + coupling_out_critical: int = 10 + + # Входящая связность модуля (fan-in) — высокое значение = нестабильный центральный модуль + coupling_in_warn: int = 10 + coupling_in_critical: int = 20 + + # Нестабильность (Instability = C_out / (C_in + C_out)), метрика Р. Мартина + # 0 — абсолютно стабильный, 1 — абсолютно нестабильный + instability_warn: float = 0.7 + instability_critical: float = 0.9 + + # Доля "тяжёлых" функций в модуле (с CCN > ccn_warn) + heavy_functions_ratio_warn: float = 0.3 + heavy_functions_ratio_critical: float = 0.6 + + +@dataclass +class AnalyzerConfig: + """Основная конфигурация анализатора.""" + + # Расширения файлов для анализа + cpp_extensions: List[str] = field(default_factory=lambda: [ + ".cpp", ".cxx", ".cc", ".c", ".hpp", ".hxx", ".h" + ]) + js_extensions: List[str] = field(default_factory=lambda: [ + ".js", ".mjs", ".cjs", ".ts" + ]) + + # Директории, которые игнорируются при обходе + ignored_dirs: List[str] = field(default_factory=lambda: [ + "node_modules", ".git", "build", "dist", "out", + "__pycache__", ".venv", "venv", "vendor", "third_party" + ]) + + # Путь к libclang (.so / .dylib / .dll). + # None — автоматическое определение; если не найден — fallback на regex. + libclang_path: str = None + + thresholds: ThresholdConfig = field(default_factory=ThresholdConfig) + + # Форматы отчётов + output_formats: List[str] = field(default_factory=lambda: ["json", "html"]) + output_dir: str = "legacy_report" + + +# Инстанция по умолчанию — используется если конфиг не передан явно +DEFAULT_CONFIG = AnalyzerConfig() diff --git a/decision_engine.py b/decision_engine.py new file mode 100644 index 0000000..de647ac --- /dev/null +++ b/decision_engine.py @@ -0,0 +1,258 @@ +""" +Модуль DecisionEngine — классификация архитектурного риска и +выбор стратегии модернизации. + +Реализует многокритериальный анализ на основе набора метрик, +полученных от MetricsEngine и DependencyAnalyzer. + +Логика принятия решений: + Каждый модуль получает «очки риска» по нескольким независимым + критериям. Итоговый уровень риска определяется числом набранных + очков, что обеспечивает интерпретируемость и прозрачность решения. + +Стратегии (по Lehman & Belady, 1985; Fowler, 2018): + LOW / MEDIUM → KEEP / REFACTOR + HIGH → REENGINEER + CRITICAL → REPLACE +""" + +import logging +from typing import Dict, List, Tuple + +from config import AnalyzerConfig, DEFAULT_CONFIG +from models import ( + AnalysisReport, + ModuleDecision, + ModuleMetrics, + ModernizationStrategy, + RiskLevel, +) + +logger = logging.getLogger(__name__) + + +class DecisionEngine: + """ + Классифицирует модули по уровню архитектурного риска + и формирует рекомендации по стратегии модернизации. + """ + + def __init__(self, config: AnalyzerConfig = DEFAULT_CONFIG): + self.config = config + self.t = config.thresholds + + # ------------------------------------------------------------------ + # Публичный API + # ------------------------------------------------------------------ + + def analyze( + self, + report: AnalysisReport, + cycles: List[List[str]], + ) -> AnalysisReport: + """ + Заполняет report.decisions и системные агрегаты. + + :param report: отчёт с заполненными modules + :param cycles: список циклов зависимостей от DependencyAnalyzer + :returns: обновлённый отчёт + """ + decisions: List[ModuleDecision] = [] + + for module_name, module in report.modules.items(): + score, reasons = self._score_module(module) + risk = self._score_to_risk(score) + strategy = self._risk_to_strategy(risk) + + decision = ModuleDecision( + module_name=module_name, + risk_level=risk, + strategy=strategy, + reasons=reasons, + priority=self._compute_priority(score, module), + ) + decisions.append(decision) + + # Сортируем по приоритету (меньше = важнее) + decisions.sort(key=lambda d: d.priority) + report.decisions = decisions + report.dependency_cycles = cycles + + # Системные агрегаты + report.total_files = sum(len(m.files) for m in report.modules.values()) + report.total_nloc = sum(m.total_nloc for m in report.modules.values()) + report.total_functions = sum(m.total_functions for m in report.modules.values()) + + all_ccn = [ + f.ccn + for m in report.modules.values() + for f in m.functions + ] + report.avg_system_ccn = sum(all_ccn) / len(all_ccn) if all_ccn else 0.0 + + report.system_risk_level = self._system_risk(decisions) + report.recommended_system_strategy = self._risk_to_strategy(report.system_risk_level) + + logger.info( + "DecisionEngine: системный риск=%s, стратегия=%s", + report.system_risk_level.value, + report.recommended_system_strategy.name, + ) + + return report + + # ------------------------------------------------------------------ + # Оценка отдельного модуля + # ------------------------------------------------------------------ + + def _score_module(self, module: ModuleMetrics) -> Tuple[int, List[str]]: + """ + Начисляет очки риска по каждому критерию. + Возвращает (суммарный_счёт, список_причин). + + Каждый критерий даёт 1 (warn) или 2 (critical) очка. + """ + score = 0 + reasons: List[str] = [] + + # --- Цикломатическая сложность --- + if module.max_ccn > self.t.ccn_critical: + score += 2 + reasons.append( + f"Критическая CCN: max={module.max_ccn} (порог {self.t.ccn_critical})" + ) + elif module.avg_ccn > self.t.ccn_warn: + score += 1 + reasons.append( + f"Повышенная средняя CCN: avg={module.avg_ccn:.1f} (порог {self.t.ccn_warn})" + ) + + # --- Доля сложных функций --- + if module.heavy_functions_ratio > self.t.heavy_functions_ratio_critical: + score += 2 + reasons.append( + f"Критическая доля сложных функций: " + f"{module.heavy_functions_ratio:.0%} (порог {self.t.heavy_functions_ratio_critical:.0%})" + ) + elif module.heavy_functions_ratio > self.t.heavy_functions_ratio_warn: + score += 1 + reasons.append( + f"Высокая доля сложных функций: " + f"{module.heavy_functions_ratio:.0%} (порог {self.t.heavy_functions_ratio_warn:.0%})" + ) + + # --- Исходящая связность (fan-out) --- + if module.coupling_out > self.t.coupling_out_critical: + score += 2 + reasons.append( + f"Критическая исходящая связность: C_out={module.coupling_out} " + f"(порог {self.t.coupling_out_critical})" + ) + elif module.coupling_out > self.t.coupling_out_warn: + score += 1 + reasons.append( + f"Высокая исходящая связность: C_out={module.coupling_out} " + f"(порог {self.t.coupling_out_warn})" + ) + + # --- Входящая связность (fan-in) --- + if module.coupling_in > self.t.coupling_in_critical: + score += 2 + reasons.append( + f"Критическая входящая связность: C_in={module.coupling_in} " + f"(порог {self.t.coupling_in_critical}) — нестабильный центральный узел" + ) + elif module.coupling_in > self.t.coupling_in_warn: + score += 1 + reasons.append( + f"Высокая входящая связность: C_in={module.coupling_in} " + f"(порог {self.t.coupling_in_warn})" + ) + + # --- Нестабильность (Instability, метрика Р. Мартина) --- + if module.instability > self.t.instability_critical: + score += 2 + reasons.append( + f"Критическая нестабильность: I={module.instability:.2f} " + f"(порог {self.t.instability_critical})" + ) + elif module.instability > self.t.instability_warn: + score += 1 + reasons.append( + f"Высокая нестабильность: I={module.instability:.2f} " + f"(порог {self.t.instability_warn})" + ) + + # --- Циклические зависимости --- + if module.has_cycles: + score += 3 + reasons.append("Участвует в циклических зависимостях — признак архитектурной деградации") + + return score, reasons + + # ------------------------------------------------------------------ + # Преобразование очков в решения + # ------------------------------------------------------------------ + + @staticmethod + def _score_to_risk(score: int) -> RiskLevel: + """Преобразует суммарный счёт в уровень риска.""" + if score == 0: + return RiskLevel.LOW + if score <= 2: + return RiskLevel.MEDIUM + if score <= 5: + return RiskLevel.HIGH + return RiskLevel.CRITICAL + + @staticmethod + def _risk_to_strategy(risk: RiskLevel) -> ModernizationStrategy: + """ + Определяет стратегию модернизации по уровню риска. + + Логика основана на классификации Fowler (2018) и + Lehman & Belady (1985). + """ + return { + RiskLevel.LOW: ModernizationStrategy.KEEP, + RiskLevel.MEDIUM: ModernizationStrategy.REFACTOR, + RiskLevel.HIGH: ModernizationStrategy.REENGINEER, + RiskLevel.CRITICAL: ModernizationStrategy.REPLACE, + }[risk] + + @staticmethod + def _compute_priority(score: int, module: ModuleMetrics) -> int: + """ + Приоритет в плане рефакторинга: меньше = выше приоритет. + Модули с циклами и высоким C_in получают наивысший приоритет + (они блокируют изменения во множестве других модулей). + """ + priority = 1000 - score * 100 + + # Модули с высоким fan-in важнее менять первыми — + # они наиболее болезненные для всей системы + if module.coupling_in > 0: + priority -= module.coupling_in * 10 + + if module.has_cycles: + priority -= 200 + + return max(priority, 1) + + @staticmethod + def _system_risk(decisions: List[ModuleDecision]) -> RiskLevel: + """Определяет системный уровень риска на основе распределения модулей.""" + if not decisions: + return RiskLevel.LOW + + total = len(decisions) + critical_ratio = sum(1 for d in decisions if d.risk_level == RiskLevel.CRITICAL) / total + high_ratio = sum(1 for d in decisions if d.risk_level in (RiskLevel.HIGH, RiskLevel.CRITICAL)) / total + + if critical_ratio > 0.3: + return RiskLevel.CRITICAL + if high_ratio > 0.5: + return RiskLevel.HIGH + if high_ratio > 0.2: + return RiskLevel.MEDIUM + return RiskLevel.LOW diff --git a/dependency_analyzer.py b/dependency_analyzer.py new file mode 100644 index 0000000..5d61256 --- /dev/null +++ b/dependency_analyzer.py @@ -0,0 +1,318 @@ +""" +Модуль 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 diff --git a/html_reporter.py b/html_reporter.py new file mode 100644 index 0000000..b5be429 --- /dev/null +++ b/html_reporter.py @@ -0,0 +1,286 @@ +""" +HTML Reporter — человекочитаемый отчёт для архитекторов и менеджеров. + +Формирует самодостаточный HTML-файл (без внешних зависимостей): + - Сводная таблица по системе + - Таблица модулей с цветовой индикацией риска + - Детальные карточки по каждому модулю + - Граф зависимостей в SVG (через библиотеку Mermaid.js из CDN) + - Список циклических зависимостей +""" + +import html +import logging +from datetime import datetime +from pathlib import Path +from typing import List + +from models import AnalysisReport, ModuleDecision, ModernizationStrategy, RiskLevel + +logger = logging.getLogger(__name__) + +# Цвета по уровням риска +_RISK_COLORS = { + RiskLevel.LOW: ("#22c55e", "#dcfce7"), # зелёный + RiskLevel.MEDIUM: ("#f59e0b", "#fef3c7"), # жёлтый + RiskLevel.HIGH: ("#ef4444", "#fee2e2"), # красный + RiskLevel.CRITICAL: ("#7f1d1d", "#fca5a5"), # тёмно-красный +} + +_STRATEGY_LABELS = { + ModernizationStrategy.KEEP: "Оставить", + ModernizationStrategy.REFACTOR: "Рефакторинг", + ModernizationStrategy.REENGINEER: "Реинжиниринг", + ModernizationStrategy.REPLACE: "Замена", +} + +_RISK_LABELS = { + RiskLevel.LOW: "Низкий", + RiskLevel.MEDIUM: "Средний", + RiskLevel.HIGH: "Высокий", + RiskLevel.CRITICAL: "Критический", +} + + +class HtmlReporter: + """Формирует HTML-отчёт по результатам анализа.""" + + def write(self, report: AnalysisReport, output_dir: str) -> str: + """ + Записывает отчёт в файл report.html внутри output_dir. + + :returns: путь к созданному файлу + """ + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + file_path = out / "report.html" + + content = self._build_html(report) + + with open(file_path, "w", encoding="utf-8") as fh: + fh.write(content) + + logger.info("HTML отчёт записан: %s", file_path) + return str(file_path) + + # ------------------------------------------------------------------ + # Сборка HTML + # ------------------------------------------------------------------ + + def _build_html(self, report: AnalysisReport) -> str: + sections = [ + self._head(), + "", + self._header(report), + self._summary_section(report), + self._decisions_table(report), + self._cycles_section(report), + self._modules_detail(report), + self._footer(), + "", + ] + return "\n".join(sections) + + def _head(self) -> str: + return """ + + + + +Legacy Analyzer — Отчёт + +""" + + def _header(self, report: AnalysisReport) -> str: + ts = datetime.now().strftime("%d.%m.%Y %H:%M") + risk_color = _RISK_COLORS[report.system_risk_level][0] + return f""" +

📊 Legacy Analyzer

+

+ Проект: {html.escape(report.project_root)}  |  + Сгенерировано: {ts}  |  + Системный риск: {_RISK_LABELS[report.system_risk_level]} +

""" + + def _summary_section(self, report: AnalysisReport) -> str: + strategy_label = _STRATEGY_LABELS[report.recommended_system_strategy] + risk_color = _RISK_COLORS[report.system_risk_level][0] + + return f""" +

Сводка по системе

+
+
+
{report.total_files}
+
Файлов
+
+
+
{report.total_nloc:,}
+
Строк кода (NLOC)
+
+
+
{report.total_functions}
+
Функций
+
+
+
{report.avg_system_ccn:.1f}
+
Средняя CCN
+
+
+
{len(report.modules)}
+
Модулей
+
+
+
{len(report.dependency_cycles)}
+
Циклов зависимостей
+
+
+
{_RISK_LABELS[report.system_risk_level]}
+
Системный риск
+
+
+
{strategy_label}
+
Рекомендуемая стратегия
+
+
""" + + def _decisions_table(self, report: AnalysisReport) -> str: + rows = [] + for d in report.decisions: + fg, bg = _RISK_COLORS[d.risk_level] + risk_badge = ( + f'' + f'{_RISK_LABELS[d.risk_level]}' + ) + reasons_html = "" + if d.reasons: + items = "".join(f"
  • {html.escape(r)}
  • " for r in d.reasons) + reasons_html = f'
      {items}
    ' + + rows.append(f""" + {html.escape(d.module_name)} + {risk_badge} + {html.escape(_STRATEGY_LABELS[d.strategy])} + {d.priority} + {reasons_html or '—'} + """) + + return f""" +

    Таблица решений по модулям

    +
    + + + + + {"".join(rows)} +
    МодульРискСтратегияПриоритетПричины
    +
    """ + + def _cycles_section(self, report: AnalysisReport) -> str: + if not report.dependency_cycles: + return """ +

    Циклические зависимости

    +

    ✓ Циклических зависимостей не обнаружено

    """ + + items = [] + for i, cycle in enumerate(report.dependency_cycles, 1): + badges = "".join(f'{html.escape(m)}' for m in cycle) + items.append(f"

    Цикл {i}: {badges}

    ") + + return f""" +

    Циклические зависимости ({len(report.dependency_cycles)})

    +
    {"".join(items)}
    """ + + def _modules_detail(self, report: AnalysisReport) -> str: + # Строим индекс decision по имени модуля + decision_map = {d.module_name: d for d in report.decisions} + + cards = [] + for mod_name, mod in sorted(report.modules.items()): + decision = decision_map.get(mod_name) + risk = decision.risk_level if decision else RiskLevel.LOW + fg, bg = _RISK_COLORS[risk] + + # Топ-5 самых сложных функций + top_funcs = sorted(mod.functions, key=lambda f: f.ccn, reverse=True)[:5] + func_rows = "".join( + f"{html.escape(f.name)}" + f"{f.ccn}{f.nloc}{f.params}" + f"{html.escape(Path(f.file).name)}:{f.line}" + for f in top_funcs + ) + func_table = "" + if func_rows: + func_table = f"""
    Топ функций по сложности + + + {func_rows} +
    ФункцияCCNNLOCПараметрыРасположение
    """ + + cycle_warn = ( + '⚠ Участвует в цикле зависимостей' + if mod.has_cycles else "" + ) + + cards.append(f"""
    +
    +

    {html.escape(mod_name)}

    + {_RISK_LABELS[risk]} +
    + {cycle_warn} + + + + + + + + +
    Язык{mod.language}
    NLOC{mod.total_nloc:,}
    Функций{mod.total_functions}
    Avg CCN{mod.avg_ccn:.1f}
    Max CCN{mod.max_ccn}
    C_out / C_in{mod.coupling_out} / {mod.coupling_in}
    Нестабильность (I){mod.instability:.2f}
    + {func_table} +
    """) + + return f""" +

    Детализация по модулям

    +{"".join(cards)}""" + + def _footer(self) -> str: + return """ +
    +

    + Legacy Analyzer — методический инструментарий модернизации унаследованных систем +

    """ diff --git a/json_reporter.py b/json_reporter.py new file mode 100644 index 0000000..623dfde --- /dev/null +++ b/json_reporter.py @@ -0,0 +1,106 @@ +""" +JSON Reporter — машинно-читаемый отчёт в формате JSON. + +Подходит для интеграции с CI/CD конвейерами и внешними инструментами. +""" + +import json +import logging +from dataclasses import asdict, is_dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + +from models import AnalysisReport, ModernizationStrategy, RiskLevel + +logger = logging.getLogger(__name__) + + +def _serialize(obj: Any) -> Any: + """Рекурсивный сериализатор для dataclasses, Enum и set.""" + if is_dataclass(obj) and not isinstance(obj, type): + return {k: _serialize(v) for k, v in asdict(obj).items()} + if isinstance(obj, (RiskLevel, ModernizationStrategy)): + return obj.name + if isinstance(obj, set): + return sorted(obj) + if isinstance(obj, list): + return [_serialize(i) for i in obj] + if isinstance(obj, dict): + return {k: _serialize(v) for k, v in obj.items()} + return obj + + +class JsonReporter: + """Формирует JSON-отчёт по результатам анализа.""" + + def write(self, report: AnalysisReport, output_dir: str) -> str: + """ + Записывает отчёт в файл report.json внутри output_dir. + + :returns: путь к созданному файлу + """ + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + file_path = out / "report.json" + + payload = { + "generated_at": datetime.now().isoformat(), + "project_root": report.project_root, + "summary": { + "total_files": report.total_files, + "total_nloc": report.total_nloc, + "total_functions": report.total_functions, + "avg_system_ccn": round(report.avg_system_ccn, 2), + "system_risk_level": report.system_risk_level.name, + "recommended_system_strategy": report.recommended_system_strategy.name, + "dependency_cycles_count": len(report.dependency_cycles), + }, + "dependency_cycles": report.dependency_cycles, + "decisions": [ + { + "module": d.module_name, + "risk_level": d.risk_level.name, + "strategy": d.strategy.name, + "priority": d.priority, + "reasons": d.reasons, + } + for d in report.decisions + ], + "modules": { + name: { + "files": mod.files, + "language": mod.language, + "total_nloc": mod.total_nloc, + "total_functions": mod.total_functions, + "avg_ccn": round(mod.avg_ccn, 2), + "max_ccn": mod.max_ccn, + "heavy_functions_count": mod.heavy_functions_count, + "heavy_functions_ratio": round(mod.heavy_functions_ratio, 3), + "coupling_out": mod.coupling_out, + "coupling_in": mod.coupling_in, + "instability": round(mod.instability, 3), + "has_cycles": mod.has_cycles, + "dependencies": sorted(mod.dependencies), + "dependents": sorted(mod.dependents), + "functions": [ + { + "name": f.name, + "file": f.file, + "line": f.line, + "ccn": f.ccn, + "nloc": f.nloc, + "params": f.params, + } + for f in sorted(mod.functions, key=lambda x: x.ccn, reverse=True) + ], + } + for name, mod in report.modules.items() + }, + } + + with open(file_path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, ensure_ascii=False, indent=2) + + logger.info("JSON отчёт записан: %s", file_path) + return str(file_path) diff --git a/legacy_report_demo.html b/legacy_report_demo.html new file mode 100644 index 0000000..81de75d --- /dev/null +++ b/legacy_report_demo.html @@ -0,0 +1,249 @@ + + + + + +Legacy Analyzer — Отчёт + + + + +

    📊 Legacy Analyzer

    +

    + Проект: /tmp/tp  |  + Сгенерировано: 27.04.2026 18:39  |  + Системный риск: Низкий +

    + +

    Сводка по системе

    +
    +
    +
    9
    +
    Файлов
    +
    +
    +
    26
    +
    Строк кода (NLOC)
    +
    +
    +
    9
    +
    Функций
    +
    +
    +
    1.8
    +
    Средняя CCN
    +
    +
    +
    6
    +
    Модулей
    +
    +
    +
    0
    +
    Циклов зависимостей
    +
    +
    +
    Низкий
    +
    Системный риск
    +
    +
    +
    Оставить
    +
    Рекомендуемая стратегия
    +
    +
    + +

    Таблица решений по модулям

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    МодульРискСтратегияПриоритетПричины
    service/routesСреднийРефакторинг800
    • Критическая нестабильность: I=1.00 (порог 0.9)
    ui_widgetСреднийРефакторинг800
    • Критическая нестабильность: I=1.00 (порог 0.9)
    <root>НизкийОставить980
    utilsНизкийОставить980
    databaseНизкийОставить990
    service/databaseНизкийОставить990
    +
    + +

    Циклические зависимости

    +

    ✓ Циклических зависимостей не обнаружено

    + +

    Детализация по модулям

    +
    +
    +

    <root>

    + Низкий +
    + + + + + + + + + +
    Языкmixed
    NLOC0
    Функций0
    Avg CCN0.0
    Max CCN0
    C_out / C_in0 / 2
    Нестабильность (I)0.00
    + +
    +
    +

    database

    + Низкий +
    + + + + + + + + + +
    Языкmixed
    NLOC0
    Функций0
    Avg CCN0.0
    Max CCN0
    C_out / C_in1 / 1
    Нестабильность (I)0.50
    + +
    +
    +

    service/database

    + Низкий +
    + + + + + + + + + +
    Языкmixed
    NLOC3
    Функций2
    Avg CCN1.5
    Max CCN2
    C_out / C_in1 / 1
    Нестабильность (I)0.50
    +
    Топ функций по сложности + + + +
    ФункцияCCNNLOCПараметрыРасположение
    query222db.js:3
    if111db.js:4
    +
    +
    +

    service/routes

    + Средний +
    + + + + + + + + + +
    Языкmixed
    NLOC23
    Функций7
    Avg CCN1.9
    Max CCN7
    C_out / C_in2 / 0
    Нестабильность (I)1.00
    +
    Топ функций по сложности + + + +
    ФункцияCCNNLOCПараметрыРасположение
    authenticate7172auth.js:3
    if111auth.js:6
    if111auth.js:9
    if111auth.js:10
    if111auth.js:10
    +
    +
    +

    ui_widget

    + Средний +
    + + + + + + + + + +
    Языкmixed
    NLOC0
    Функций0
    Avg CCN0.0
    Max CCN0
    C_out / C_in2 / 0
    Нестабильность (I)1.00
    + +
    +
    +

    utils

    + Низкий +
    + + + + + + + + + +
    Языкmixed
    NLOC0
    Функций0
    Avg CCN0.0
    Max CCN0
    C_out / C_in0 / 2
    Нестабильность (I)0.00
    + +
    + +
    +

    + Legacy Analyzer — методический инструментарий модернизации унаследованных систем +

    + \ No newline at end of file diff --git a/legacy_report_demo.json b/legacy_report_demo.json new file mode 100644 index 0000000..3ebfdcb --- /dev/null +++ b/legacy_report_demo.json @@ -0,0 +1,279 @@ +{ + "generated_at": "2026-04-27T18:39:05.636749", + "project_root": "/tmp/tp", + "summary": { + "total_files": 9, + "total_nloc": 26, + "total_functions": 9, + "avg_system_ccn": 1.78, + "system_risk_level": "LOW", + "recommended_system_strategy": "KEEP", + "dependency_cycles_count": 0 + }, + "dependency_cycles": [], + "decisions": [ + { + "module": "service/routes", + "risk_level": "MEDIUM", + "strategy": "REFACTOR", + "priority": 800, + "reasons": [ + "Критическая нестабильность: I=1.00 (порог 0.9)" + ] + }, + { + "module": "ui_widget", + "risk_level": "MEDIUM", + "strategy": "REFACTOR", + "priority": 800, + "reasons": [ + "Критическая нестабильность: I=1.00 (порог 0.9)" + ] + }, + { + "module": "", + "risk_level": "LOW", + "strategy": "KEEP", + "priority": 980, + "reasons": [] + }, + { + "module": "utils", + "risk_level": "LOW", + "strategy": "KEEP", + "priority": 980, + "reasons": [] + }, + { + "module": "database", + "risk_level": "LOW", + "strategy": "KEEP", + "priority": 990, + "reasons": [] + }, + { + "module": "service/database", + "risk_level": "LOW", + "strategy": "KEEP", + "priority": 990, + "reasons": [] + } + ], + "modules": { + "": { + "files": [ + "/tmp/tp/config.js" + ], + "language": "mixed", + "total_nloc": 0, + "total_functions": 0, + "avg_ccn": 0.0, + "max_ccn": 0, + "heavy_functions_count": 0, + "heavy_functions_ratio": 0.0, + "coupling_out": 0, + "coupling_in": 2, + "instability": 0.0, + "has_cycles": false, + "dependencies": [], + "dependents": [ + "service/database", + "service/routes" + ], + "functions": [] + }, + "database": { + "files": [ + "/tmp/tp/database/DatabaseManager.cpp", + "/tmp/tp/database/DatabaseManager.h" + ], + "language": "mixed", + "total_nloc": 0, + "total_functions": 0, + "avg_ccn": 0.0, + "max_ccn": 0, + "heavy_functions_count": 0, + "heavy_functions_ratio": 0.0, + "coupling_out": 1, + "coupling_in": 1, + "instability": 0.5, + "has_cycles": false, + "dependencies": [ + "database", + "utils" + ], + "dependents": [ + "ui_widget" + ], + "functions": [] + }, + "service/database": { + "files": [ + "/tmp/tp/service/database/db.js" + ], + "language": "mixed", + "total_nloc": 3, + "total_functions": 2, + "avg_ccn": 1.5, + "max_ccn": 2, + "heavy_functions_count": 0, + "heavy_functions_ratio": 0.0, + "coupling_out": 1, + "coupling_in": 1, + "instability": 0.5, + "has_cycles": false, + "dependencies": [ + "" + ], + "dependents": [ + "service/routes" + ], + "functions": [ + { + "name": "query", + "file": "/tmp/tp/service/database/db.js", + "line": 3, + "ccn": 2, + "nloc": 2, + "params": 2 + }, + { + "name": "if", + "file": "/tmp/tp/service/database/db.js", + "line": 4, + "ccn": 1, + "nloc": 1, + "params": 1 + } + ] + }, + "service/routes": { + "files": [ + "/tmp/tp/service/routes/auth.js" + ], + "language": "mixed", + "total_nloc": 23, + "total_functions": 7, + "avg_ccn": 1.86, + "max_ccn": 7, + "heavy_functions_count": 0, + "heavy_functions_ratio": 0.0, + "coupling_out": 2, + "coupling_in": 0, + "instability": 1.0, + "has_cycles": false, + "dependencies": [ + "", + "service/database" + ], + "dependents": [], + "functions": [ + { + "name": "authenticate", + "file": "/tmp/tp/service/routes/auth.js", + "line": 3, + "ccn": 7, + "nloc": 17, + "params": 2 + }, + { + "name": "if", + "file": "/tmp/tp/service/routes/auth.js", + "line": 6, + "ccn": 1, + "nloc": 1, + "params": 1 + }, + { + "name": "if", + "file": "/tmp/tp/service/routes/auth.js", + "line": 9, + "ccn": 1, + "nloc": 1, + "params": 1 + }, + { + "name": "if", + "file": "/tmp/tp/service/routes/auth.js", + "line": 10, + "ccn": 1, + "nloc": 1, + "params": 1 + }, + { + "name": "if", + "file": "/tmp/tp/service/routes/auth.js", + "line": 10, + "ccn": 1, + "nloc": 1, + "params": 1 + }, + { + "name": "if", + "file": "/tmp/tp/service/routes/auth.js", + "line": 13, + "ccn": 1, + "nloc": 1, + "params": 1 + }, + { + "name": "catch", + "file": "/tmp/tp/service/routes/auth.js", + "line": 17, + "ccn": 1, + "nloc": 1, + "params": 1 + } + ] + }, + "ui_widget": { + "files": [ + "/tmp/tp/ui_widget/UserWidget.cpp", + "/tmp/tp/ui_widget/UserWidget.h" + ], + "language": "mixed", + "total_nloc": 0, + "total_functions": 0, + "avg_ccn": 0.0, + "max_ccn": 0, + "heavy_functions_count": 0, + "heavy_functions_ratio": 0.0, + "coupling_out": 2, + "coupling_in": 0, + "instability": 1.0, + "has_cycles": false, + "dependencies": [ + "database", + "ui_widget", + "utils" + ], + "dependents": [], + "functions": [] + }, + "utils": { + "files": [ + "/tmp/tp/utils/Logger.cpp", + "/tmp/tp/utils/Logger.h" + ], + "language": "mixed", + "total_nloc": 0, + "total_functions": 0, + "avg_ccn": 0.0, + "max_ccn": 0, + "heavy_functions_count": 0, + "heavy_functions_ratio": 0.0, + "coupling_out": 0, + "coupling_in": 2, + "instability": 0.0, + "has_cycles": false, + "dependencies": [ + "utils" + ], + "dependents": [ + "database", + "ui_widget" + ], + "functions": [] + } + } +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..0bf53d4 --- /dev/null +++ b/main.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +CLI — точка входа для запуска из командной строки. + +Использование: + python main.py /path/to/project + python main.py /path/to/project --output my_report --formats json html + python main.py /path/to/project --ccn-warn 15 --ccn-critical 25 + python main.py /path/to/project --libclang /usr/lib/llvm-14/lib/libclang.so.1 +""" + +import argparse +import logging +import sys +from pathlib import Path + +from config import AnalyzerConfig, ThresholdConfig +from analyzer import Analyzer + + +def build_arg_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="legacy_analyzer", + description=( + "Методический инструментарий модернизации унаследованных систем.\n" + "Анализирует C++ и JavaScript проекты, выявляет архитектурные проблемы\n" + "и рекомендует стратегию модернизации." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + p.add_argument( + "project_root", + help="Путь к корневой директории анализируемого проекта", + ) + p.add_argument( + "--output", "-o", + default="legacy_report", + help="Директория для сохранения отчётов (по умолчанию: legacy_report)", + ) + p.add_argument( + "--formats", "-f", + nargs="+", + choices=["json", "html"], + default=["json", "html"], + help="Форматы отчётов (по умолчанию: json html)", + ) + p.add_argument( + "--libclang", + default=None, + help="Путь к libclang (.so/.dylib/.dll). Если не указан — используется автоопределение или regex-fallback", + ) + + # Пороговые значения + thresholds = p.add_argument_group("Пороговые значения метрик") + thresholds.add_argument("--ccn-warn", type=int, default=10, metavar="N", + help="CCN порог предупреждения (по умолчанию: 10)") + thresholds.add_argument("--ccn-critical", type=int, default=20, metavar="N", + help="CCN критический порог (по умолчанию: 20)") + thresholds.add_argument("--coupling-out-warn", type=int, default=5, metavar="N", + help="Порог исходящей связности — предупреждение (по умолчанию: 5)") + thresholds.add_argument("--coupling-out-critical", type=int, default=10, metavar="N", + help="Порог исходящей связности — критический (по умолчанию: 10)") + + p.add_argument( + "--verbose", "-v", + action="store_true", + help="Подробный вывод (DEBUG)", + ) + + return p + + +def main() -> None: + parser = build_arg_parser() + args = parser.parse_args() + + # --- Настройка логирования --- + level = logging.DEBUG if args.verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", + ) + + # --- Проверка входного пути --- + root = Path(args.project_root) + if not root.exists(): + print(f"Ошибка: директория не найдена: {root}", file=sys.stderr) + sys.exit(1) + + # --- Сборка конфигурации --- + thresholds = ThresholdConfig( + ccn_warn=args.ccn_warn, + ccn_critical=args.ccn_critical, + coupling_out_warn=args.coupling_out_warn, + coupling_out_critical=args.coupling_out_critical, + ) + config = AnalyzerConfig( + output_formats=args.formats, + output_dir=args.output, + libclang_path=args.libclang, + thresholds=thresholds, + ) + + # --- Запуск --- + try: + analyzer = Analyzer(config) + report = analyzer.run(str(root)) + + # Краткий итог в stdout + print("\n" + "=" * 50) + print(f" Системный риск: {report.system_risk_level.name}") + print(f" Стратегия: {report.recommended_system_strategy.name}") + print(f" Модулей: {len(report.modules)}") + print(f" Функций: {report.total_functions}") + print(f" Средняя CCN: {report.avg_system_ccn:.2f}") + print(f" Циклов зависим.: {len(report.dependency_cycles)}") + print(f" Отчёты в: {args.output}/") + print("=" * 50) + + except RuntimeError as exc: + print(f"Ошибка: {exc}", file=sys.stderr) + sys.exit(1) + except KeyboardInterrupt: + print("\nПрервано пользователем", file=sys.stderr) + sys.exit(130) + + +if __name__ == "__main__": + main() diff --git a/metrics_engine.py b/metrics_engine.py new file mode 100644 index 0000000..3b8e49e --- /dev/null +++ b/metrics_engine.py @@ -0,0 +1,148 @@ +""" +Модуль MetricsEngine — вычисление метрик сложности и объёма кода. + +Для обоих языков (C++ и JS) используется библиотека lizard как +основной источник метрик сложности (CCN, NLOC, число параметров). +libclang используется только DependencyAnalyzer для извлечения #include. + +Ссылки: + McCabe T.J., A Complexity Measure, IEEE TSE, 1976 + Yin T., Lizard: A Simple Code Complexity Analyser, github.com/terryyin/lizard +""" + +import logging +from pathlib import Path +from typing import Dict, List + +from config import AnalyzerConfig, DEFAULT_CONFIG +from models import FunctionMetrics, ModuleMetrics + +logger = logging.getLogger(__name__) + +# Пытаемся импортировать lizard — предпочтительный вариант. +# Если недоступен — используем встроенный анализатор (utils/code_analyzer.py). +try: + import lizard as _lizard_module + _LIZARD_AVAILABLE = True + logger.debug("Используется lizard для анализа метрик") +except ImportError: + _LIZARD_AVAILABLE = False + from utils import code_analyzer as _builtin_analyzer # noqa: E402 + logger.info( + "lizard не установлен — используется встроенный анализатор. " + "Для повышения точности: pip install lizard" + ) + + +def _analyze_file_unified(file_path: str): + """ + Единая точка вызова анализа файла — lizard если доступен, иначе встроенный. + Возвращает объект с полем function_list (список объектов с атрибутами + name, start_line, nloc, cyclomatic_complexity, parameter_count). + """ + if _LIZARD_AVAILABLE: + return _lizard_module.analyze_file(file_path) + else: + return _builtin_analyzer.analyze_file(file_path) + + +class MetricsEngine: + """ + Вычисляет метрики сложности и объёма для каждого модуля. + + Использует lizard для анализа C++ и JavaScript файлов, + агрегирует результаты на уровне модулей. + """ + + def __init__(self, config: AnalyzerConfig = DEFAULT_CONFIG): + self.config = config + self.thresholds = config.thresholds + + # ------------------------------------------------------------------ + # Публичный API + # ------------------------------------------------------------------ + + def analyze(self, modules: Dict[str, ModuleMetrics]) -> Dict[str, ModuleMetrics]: + """ + Заполняет метрики для каждого модуля. + + :param modules: словарь модулей от Collector (с заполненными files) + :returns: тот же словарь с заполненными метриками + """ + for module_name, module in modules.items(): + logger.info("MetricsEngine: анализируем модуль '%s'", module_name) + self._analyze_module(module) + + return modules + + # ------------------------------------------------------------------ + # Внутренние методы + # ------------------------------------------------------------------ + + def _analyze_module(self, module: ModuleMetrics) -> None: + """Анализирует все файлы модуля и агрегирует метрики.""" + all_functions: List[FunctionMetrics] = [] + + for file_path in module.files: + funcs = self._analyze_file(file_path) + all_functions.extend(funcs) + + module.functions = all_functions + self._aggregate(module) + + def _analyze_file(self, file_path: str) -> List[FunctionMetrics]: + """Запускает анализатор на один файл и возвращает список FunctionMetrics.""" + try: + result = _analyze_file_unified(file_path) + except Exception as exc: + logger.warning("Ошибка анализа файла %s: %s", file_path, exc) + return [] + + if result is None: + return [] + + lang = self._detect_language(file_path) + functions: List[FunctionMetrics] = [] + + for func in result.function_list: + fm = FunctionMetrics( + name=func.name, + file=file_path, + line=func.start_line, + ccn=func.cyclomatic_complexity, + nloc=func.nloc, + params=func.parameter_count, + language=lang, + ) + functions.append(fm) + + return functions + + def _aggregate(self, module: ModuleMetrics) -> None: + """Считает агрегированные показатели из списка функций.""" + funcs = module.functions + if not funcs: + module.total_nloc = 0 + module.avg_ccn = 0.0 + module.max_ccn = 0 + module.total_functions = 0 + module.heavy_functions_count = 0 + module.heavy_functions_ratio = 0.0 + return + + warn_ccn = self.thresholds.ccn_warn + module.total_functions = len(funcs) + module.total_nloc = sum(f.nloc for f in funcs) + module.max_ccn = max(f.ccn for f in funcs) + module.avg_ccn = sum(f.ccn for f in funcs) / len(funcs) + module.heavy_functions_count = sum(1 for f in funcs if f.ccn > warn_ccn) + module.heavy_functions_ratio = module.heavy_functions_count / module.total_functions + + def _detect_language(self, file_path: str) -> str: + """Определяет язык по расширению файла.""" + suffix = Path(file_path).suffix.lower() + if suffix in self.config.cpp_extensions: + return "cpp" + if suffix in self.config.js_extensions: + return "js" + return "unknown" diff --git a/models.py b/models.py new file mode 100644 index 0000000..21e7d7b --- /dev/null +++ b/models.py @@ -0,0 +1,120 @@ +""" +Доменные модели инструментария. +Все типы данных, которыми обмениваются модули системы. +""" + +from __future__ import annotations +from dataclasses import dataclass, field +from enum import Enum, auto +from pathlib import Path +from typing import Dict, List, Optional, Set + + +# --------------------------------------------------------------------------- +# Метрики +# --------------------------------------------------------------------------- + +@dataclass +class FunctionMetrics: + """Метрики одной функции / метода.""" + name: str + file: str + line: int + ccn: int # Цикломатическая сложность (McCabe) + nloc: int # Строк кода без комментариев + params: int # Число параметров + language: str # "cpp" | "js" + + @property + def is_complex(self) -> bool: + return self.ccn > 10 + + @property + def is_large(self) -> bool: + return self.nloc > 50 + + +@dataclass +class ModuleMetrics: + """Агрегированные метрики модуля (директории или файла).""" + name: str # Имя модуля (путь относительно корня) + files: List[str] = field(default_factory=list) + functions: List[FunctionMetrics] = field(default_factory=list) + language: str = "mixed" # "cpp" | "js" | "mixed" + + # Агрегированные показатели (заполняются MetricsEngine) + total_nloc: int = 0 + avg_ccn: float = 0.0 + max_ccn: int = 0 + total_functions: int = 0 + heavy_functions_count: int = 0 # Функций с CCN > порога + heavy_functions_ratio: float = 0.0 + + # Граф зависимостей (заполняется DependencyAnalyzer) + coupling_out: int = 0 # Исходящая связность (fan-out) + coupling_in: int = 0 # Входящая связность (fan-in) + instability: float = 0.0 # C_out / (C_in + C_out), метрика Р. Мартина + has_cycles: bool = False # Участвует в циклической зависимости + + # Зависимости + dependencies: Set[str] = field(default_factory=set) # Модули, от которых зависит этот + dependents: Set[str] = field(default_factory=set) # Модули, зависящие от этого + + +# --------------------------------------------------------------------------- +# Классификация риска и стратегия +# --------------------------------------------------------------------------- + +class RiskLevel(Enum): + """Уровень архитектурного риска модуля.""" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class ModernizationStrategy(Enum): + """Рекомендуемая стратегия модернизации (Lehman & Belady, 1985).""" + KEEP = auto() # Оставить без изменений + REFACTOR = auto() # Локальный рефакторинг (Fowler, 2018) + REENGINEER = auto() # Реинжиниринг архитектуры + REPLACE = auto() # Полная замена + + +@dataclass +class ModuleDecision: + """Решение DecisionEngine по конкретному модулю.""" + module_name: str + risk_level: RiskLevel + strategy: ModernizationStrategy + reasons: List[str] = field(default_factory=list) # Причины в человекочитаемом виде + priority: int = 0 # Приоритет в плане рефакторинга (1 = высший) + + +# --------------------------------------------------------------------------- +# Итоговый отчёт +# --------------------------------------------------------------------------- + +@dataclass +class AnalysisReport: + """Итоговый отчёт по всей системе.""" + project_root: str + modules: Dict[str, ModuleMetrics] = field(default_factory=dict) + decisions: List[ModuleDecision] = field(default_factory=list) + dependency_cycles: List[List[str]] = field(default_factory=list) + + # Системные агрегаты + total_files: int = 0 + total_nloc: int = 0 + total_functions: int = 0 + avg_system_ccn: float = 0.0 + system_risk_level: RiskLevel = RiskLevel.LOW + recommended_system_strategy: ModernizationStrategy = ModernizationStrategy.KEEP + + @property + def critical_modules(self) -> List[ModuleDecision]: + return [d for d in self.decisions if d.risk_level == RiskLevel.CRITICAL] + + @property + def high_risk_modules(self) -> List[ModuleDecision]: + return [d for d in self.decisions if d.risk_level in (RiskLevel.HIGH, RiskLevel.CRITICAL)] diff --git a/reporter.py b/reporter.py new file mode 100644 index 0000000..6c21508 --- /dev/null +++ b/reporter.py @@ -0,0 +1,41 @@ +""" +Reporter — фасад для всех форматов отчётности. +""" + +import logging +from typing import List + +from config import AnalyzerConfig, DEFAULT_CONFIG +from models import AnalysisReport +from reporters.json_reporter import JsonReporter +from reporters.html_reporter import HtmlReporter + +logger = logging.getLogger(__name__) + + +class Reporter: + """Управляет формированием отчётов в нескольких форматах.""" + + def __init__(self, config: AnalyzerConfig = DEFAULT_CONFIG): + self.config = config + self._json = JsonReporter() + self._html = HtmlReporter() + + def generate(self, report: AnalysisReport) -> List[str]: + """ + Формирует все отчёты согласно конфигурации. + + :returns: список путей к созданным файлам + """ + output_dir = self.config.output_dir + created: List[str] = [] + + if "json" in self.config.output_formats: + path = self._json.write(report, output_dir) + created.append(path) + + if "html" in self.config.output_formats: + path = self._html.write(report, output_dir) + created.append(path) + + return created