This commit is contained in:
gre-ilya 2026-04-27 23:44:22 +05:00
commit 320979f871
16 changed files with 2604 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
*.swp
*__pycache__*

112
README.md Normal file
View File

@ -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 отчёт
```

90
analyzer.py Normal file
View File

@ -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

257
code_analyzer.py Normal file
View File

@ -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

131
collector.py Normal file
View File

@ -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:
"""
Собирает файловую структуру проекта и формирует первичное
представление модулей.
Стратегия разбивки на модули: каждая директория первого уровня
является отдельным модулем. Файлы непосредственно в корне
помещаются в псевдомодуль "<root>".
"""
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:
"""
Определяет имя модуля для файла.
Файлы в корне "<root>".
Файлы в поддиректории относительный путь к директории первого уровня.
"""
try:
rel = file.relative_to(root)
except ValueError:
return "<root>"
parts = rel.parts
if len(parts) == 1:
return "<root>"
# Берём путь относительно корня до файла (вся директория как модуль)
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 == "" — первый файл

74
config.py Normal file
View File

@ -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()

258
decision_engine.py Normal file
View File

@ -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

318
dependency_analyzer.py Normal file
View File

@ -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

286
html_reporter.py Normal file
View File

@ -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(),
"<body>",
self._header(report),
self._summary_section(report),
self._decisions_table(report),
self._cycles_section(report),
self._modules_detail(report),
self._footer(),
"</body></html>",
]
return "\n".join(sections)
def _head(self) -> str:
return """<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Legacy Analyzer Отчёт</title>
<style>
:root {
--font: 'Segoe UI', system-ui, sans-serif;
--bg: #f8fafc;
--card: #ffffff;
--border: #e2e8f0;
--text: #1e293b;
--muted: #64748b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font); background: var(--bg); color: var(--text); padding: 24px; }
h1 { font-size: 1.75rem; margin-bottom: 4px; }
h2 { font-size: 1.25rem; margin: 24px 0 12px; border-bottom: 2px solid var(--border); padding-bottom: 6px; }
h3 { font-size: 1rem; margin-bottom: 8px; }
.subtitle { color: var(--muted); font-size: 0.875rem; margin-bottom: 24px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 16px; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
.stat-card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 16px; text-align: center; }
.stat-value { font-size: 2rem; font-weight: 700; }
.stat-label { font-size: 0.75rem; color: var(--muted); margin-top: 4px; }
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
th { background: #f1f5f9; text-align: left; padding: 8px 12px; border-bottom: 2px solid var(--border); }
td { padding: 8px 12px; border-bottom: 1px solid var(--border); vertical-align: top; }
tr:hover td { background: #f8fafc; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; }
.module-card { margin-bottom: 12px; }
.module-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.reasons-list { margin: 0; padding-left: 20px; color: var(--muted); font-size: 0.8rem; }
.reasons-list li { margin-bottom: 2px; }
.func-table { margin-top: 8px; }
code { font-family: monospace; background: #f1f5f9; padding: 1px 4px; border-radius: 3px; }
.cycle-badge { background: #fca5a5; color: #7f1d1d; border-radius: 4px; padding: 2px 6px; font-size: 0.75rem; margin: 2px; display: inline-block; }
details summary { cursor: pointer; user-select: none; }
details summary:hover { color: #3b82f6; }
@media (max-width: 768px) { .grid-4 { grid-template-columns: repeat(2, 1fr); } }
</style>
</head>"""
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"""
<h1>📊 Legacy Analyzer</h1>
<p class="subtitle">
Проект: <code>{html.escape(report.project_root)}</code> &nbsp;|&nbsp;
Сгенерировано: {ts} &nbsp;|&nbsp;
Системный риск: <strong style="color:{risk_color}">{_RISK_LABELS[report.system_risk_level]}</strong>
</p>"""
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"""
<h2>Сводка по системе</h2>
<div class="grid-4">
<div class="stat-card">
<div class="stat-value">{report.total_files}</div>
<div class="stat-label">Файлов</div>
</div>
<div class="stat-card">
<div class="stat-value">{report.total_nloc:,}</div>
<div class="stat-label">Строк кода (NLOC)</div>
</div>
<div class="stat-card">
<div class="stat-value">{report.total_functions}</div>
<div class="stat-label">Функций</div>
</div>
<div class="stat-card">
<div class="stat-value">{report.avg_system_ccn:.1f}</div>
<div class="stat-label">Средняя CCN</div>
</div>
<div class="stat-card">
<div class="stat-value">{len(report.modules)}</div>
<div class="stat-label">Модулей</div>
</div>
<div class="stat-card">
<div class="stat-value">{len(report.dependency_cycles)}</div>
<div class="stat-label">Циклов зависимостей</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:{risk_color}">{_RISK_LABELS[report.system_risk_level]}</div>
<div class="stat-label">Системный риск</div>
</div>
<div class="stat-card">
<div class="stat-value" style="font-size:1.2rem">{strategy_label}</div>
<div class="stat-label">Рекомендуемая стратегия</div>
</div>
</div>"""
def _decisions_table(self, report: AnalysisReport) -> str:
rows = []
for d in report.decisions:
fg, bg = _RISK_COLORS[d.risk_level]
risk_badge = (
f'<span class="badge" style="color:{fg};background:{bg}">'
f'{_RISK_LABELS[d.risk_level]}</span>'
)
reasons_html = ""
if d.reasons:
items = "".join(f"<li>{html.escape(r)}</li>" for r in d.reasons)
reasons_html = f'<ul class="reasons-list">{items}</ul>'
rows.append(f"""<tr>
<td><code>{html.escape(d.module_name)}</code></td>
<td>{risk_badge}</td>
<td>{html.escape(_STRATEGY_LABELS[d.strategy])}</td>
<td>{d.priority}</td>
<td>{reasons_html or ''}</td>
</tr>""")
return f"""
<h2>Таблица решений по модулям</h2>
<div class="card" style="padding:0;overflow:auto">
<table>
<thead><tr>
<th>Модуль</th><th>Риск</th><th>Стратегия</th><th>Приоритет</th><th>Причины</th>
</tr></thead>
<tbody>{"".join(rows)}</tbody>
</table>
</div>"""
def _cycles_section(self, report: AnalysisReport) -> str:
if not report.dependency_cycles:
return """
<h2>Циклические зависимости</h2>
<div class="card"><p style="color:#22c55e"> Циклических зависимостей не обнаружено</p></div>"""
items = []
for i, cycle in enumerate(report.dependency_cycles, 1):
badges = "".join(f'<span class="cycle-badge">{html.escape(m)}</span>' for m in cycle)
items.append(f"<p><strong>Цикл {i}:</strong> {badges}</p>")
return f"""
<h2>Циклические зависимости ({len(report.dependency_cycles)})</h2>
<div class="card">{"".join(items)}</div>"""
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"<tr><td><code>{html.escape(f.name)}</code></td>"
f"<td>{f.ccn}</td><td>{f.nloc}</td><td>{f.params}</td>"
f"<td><code>{html.escape(Path(f.file).name)}:{f.line}</code></td></tr>"
for f in top_funcs
)
func_table = ""
if func_rows:
func_table = f"""<details><summary>Топ функций по сложности</summary>
<table class="func-table">
<thead><tr><th>Функция</th><th>CCN</th><th>NLOC</th><th>Параметры</th><th>Расположение</th></tr></thead>
<tbody>{func_rows}</tbody>
</table></details>"""
cycle_warn = (
'<span style="color:#ef4444;font-size:0.8rem">⚠ Участвует в цикле зависимостей</span>'
if mod.has_cycles else ""
)
cards.append(f"""<div class="card module-card">
<div class="module-header">
<h3><code>{html.escape(mod_name)}</code></h3>
<span class="badge" style="color:{fg};background:{bg}">{_RISK_LABELS[risk]}</span>
</div>
{cycle_warn}
<table style="width:auto;margin:8px 0">
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Язык</td><td>{mod.language}</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">NLOC</td><td>{mod.total_nloc:,}</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Функций</td><td>{mod.total_functions}</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Avg CCN</td><td>{mod.avg_ccn:.1f}</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Max CCN</td><td>{mod.max_ccn}</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">C_out / C_in</td><td>{mod.coupling_out} / {mod.coupling_in}</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Нестабильность (I)</td><td>{mod.instability:.2f}</td></tr>
</table>
{func_table}
</div>""")
return f"""
<h2>Детализация по модулям</h2>
{"".join(cards)}"""
def _footer(self) -> str:
return """
<hr style="margin:32px 0;border:none;border-top:1px solid var(--border)">
<p style="color:var(--muted);font-size:0.75rem;text-align:center">
Legacy Analyzer методический инструментарий модернизации унаследованных систем
</p>"""

106
json_reporter.py Normal file
View File

@ -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)

249
legacy_report_demo.html Normal file
View File

@ -0,0 +1,249 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Legacy Analyzer — Отчёт</title>
<style>
:root {
--font: 'Segoe UI', system-ui, sans-serif;
--bg: #f8fafc;
--card: #ffffff;
--border: #e2e8f0;
--text: #1e293b;
--muted: #64748b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font); background: var(--bg); color: var(--text); padding: 24px; }
h1 { font-size: 1.75rem; margin-bottom: 4px; }
h2 { font-size: 1.25rem; margin: 24px 0 12px; border-bottom: 2px solid var(--border); padding-bottom: 6px; }
h3 { font-size: 1rem; margin-bottom: 8px; }
.subtitle { color: var(--muted); font-size: 0.875rem; margin-bottom: 24px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 16px; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
.stat-card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 16px; text-align: center; }
.stat-value { font-size: 2rem; font-weight: 700; }
.stat-label { font-size: 0.75rem; color: var(--muted); margin-top: 4px; }
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
th { background: #f1f5f9; text-align: left; padding: 8px 12px; border-bottom: 2px solid var(--border); }
td { padding: 8px 12px; border-bottom: 1px solid var(--border); vertical-align: top; }
tr:hover td { background: #f8fafc; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; }
.module-card { margin-bottom: 12px; }
.module-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.reasons-list { margin: 0; padding-left: 20px; color: var(--muted); font-size: 0.8rem; }
.reasons-list li { margin-bottom: 2px; }
.func-table { margin-top: 8px; }
code { font-family: monospace; background: #f1f5f9; padding: 1px 4px; border-radius: 3px; }
.cycle-badge { background: #fca5a5; color: #7f1d1d; border-radius: 4px; padding: 2px 6px; font-size: 0.75rem; margin: 2px; display: inline-block; }
details summary { cursor: pointer; user-select: none; }
details summary:hover { color: #3b82f6; }
@media (max-width: 768px) { .grid-4 { grid-template-columns: repeat(2, 1fr); } }
</style>
</head>
<body>
<h1>📊 Legacy Analyzer</h1>
<p class="subtitle">
Проект: <code>/tmp/tp</code> &nbsp;|&nbsp;
Сгенерировано: 27.04.2026 18:39 &nbsp;|&nbsp;
Системный риск: <strong style="color:#22c55e">Низкий</strong>
</p>
<h2>Сводка по системе</h2>
<div class="grid-4">
<div class="stat-card">
<div class="stat-value">9</div>
<div class="stat-label">Файлов</div>
</div>
<div class="stat-card">
<div class="stat-value">26</div>
<div class="stat-label">Строк кода (NLOC)</div>
</div>
<div class="stat-card">
<div class="stat-value">9</div>
<div class="stat-label">Функций</div>
</div>
<div class="stat-card">
<div class="stat-value">1.8</div>
<div class="stat-label">Средняя CCN</div>
</div>
<div class="stat-card">
<div class="stat-value">6</div>
<div class="stat-label">Модулей</div>
</div>
<div class="stat-card">
<div class="stat-value">0</div>
<div class="stat-label">Циклов зависимостей</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:#22c55e">Низкий</div>
<div class="stat-label">Системный риск</div>
</div>
<div class="stat-card">
<div class="stat-value" style="font-size:1.2rem">Оставить</div>
<div class="stat-label">Рекомендуемая стратегия</div>
</div>
</div>
<h2>Таблица решений по модулям</h2>
<div class="card" style="padding:0;overflow:auto">
<table>
<thead><tr>
<th>Модуль</th><th>Риск</th><th>Стратегия</th><th>Приоритет</th><th>Причины</th>
</tr></thead>
<tbody><tr>
<td><code>service/routes</code></td>
<td><span class="badge" style="color:#f59e0b;background:#fef3c7">Средний</span></td>
<td>Рефакторинг</td>
<td>800</td>
<td><ul class="reasons-list"><li>Критическая нестабильность: I=1.00 (порог 0.9)</li></ul></td>
</tr><tr>
<td><code>ui_widget</code></td>
<td><span class="badge" style="color:#f59e0b;background:#fef3c7">Средний</span></td>
<td>Рефакторинг</td>
<td>800</td>
<td><ul class="reasons-list"><li>Критическая нестабильность: I=1.00 (порог 0.9)</li></ul></td>
</tr><tr>
<td><code>&lt;root&gt;</code></td>
<td><span class="badge" style="color:#22c55e;background:#dcfce7">Низкий</span></td>
<td>Оставить</td>
<td>980</td>
<td></td>
</tr><tr>
<td><code>utils</code></td>
<td><span class="badge" style="color:#22c55e;background:#dcfce7">Низкий</span></td>
<td>Оставить</td>
<td>980</td>
<td></td>
</tr><tr>
<td><code>database</code></td>
<td><span class="badge" style="color:#22c55e;background:#dcfce7">Низкий</span></td>
<td>Оставить</td>
<td>990</td>
<td></td>
</tr><tr>
<td><code>service/database</code></td>
<td><span class="badge" style="color:#22c55e;background:#dcfce7">Низкий</span></td>
<td>Оставить</td>
<td>990</td>
<td></td>
</tr></tbody>
</table>
</div>
<h2>Циклические зависимости</h2>
<div class="card"><p style="color:#22c55e">✓ Циклических зависимостей не обнаружено</p></div>
<h2>Детализация по модулям</h2>
<div class="card module-card">
<div class="module-header">
<h3><code>&lt;root&gt;</code></h3>
<span class="badge" style="color:#22c55e;background:#dcfce7">Низкий</span>
</div>
<table style="width:auto;margin:8px 0">
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Язык</td><td>mixed</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">NLOC</td><td>0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Функций</td><td>0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Avg CCN</td><td>0.0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Max CCN</td><td>0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">C_out / C_in</td><td>0 / 2</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Нестабильность (I)</td><td>0.00</td></tr>
</table>
</div><div class="card module-card">
<div class="module-header">
<h3><code>database</code></h3>
<span class="badge" style="color:#22c55e;background:#dcfce7">Низкий</span>
</div>
<table style="width:auto;margin:8px 0">
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Язык</td><td>mixed</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">NLOC</td><td>0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Функций</td><td>0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Avg CCN</td><td>0.0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Max CCN</td><td>0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">C_out / C_in</td><td>1 / 1</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Нестабильность (I)</td><td>0.50</td></tr>
</table>
</div><div class="card module-card">
<div class="module-header">
<h3><code>service/database</code></h3>
<span class="badge" style="color:#22c55e;background:#dcfce7">Низкий</span>
</div>
<table style="width:auto;margin:8px 0">
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Язык</td><td>mixed</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">NLOC</td><td>3</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Функций</td><td>2</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Avg CCN</td><td>1.5</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Max CCN</td><td>2</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">C_out / C_in</td><td>1 / 1</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Нестабильность (I)</td><td>0.50</td></tr>
</table>
<details><summary>Топ функций по сложности</summary>
<table class="func-table">
<thead><tr><th>Функция</th><th>CCN</th><th>NLOC</th><th>Параметры</th><th>Расположение</th></tr></thead>
<tbody><tr><td><code>query</code></td><td>2</td><td>2</td><td>2</td><td><code>db.js:3</code></td></tr><tr><td><code>if</code></td><td>1</td><td>1</td><td>1</td><td><code>db.js:4</code></td></tr></tbody>
</table></details>
</div><div class="card module-card">
<div class="module-header">
<h3><code>service/routes</code></h3>
<span class="badge" style="color:#f59e0b;background:#fef3c7">Средний</span>
</div>
<table style="width:auto;margin:8px 0">
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Язык</td><td>mixed</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">NLOC</td><td>23</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Функций</td><td>7</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Avg CCN</td><td>1.9</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Max CCN</td><td>7</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">C_out / C_in</td><td>2 / 0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Нестабильность (I)</td><td>1.00</td></tr>
</table>
<details><summary>Топ функций по сложности</summary>
<table class="func-table">
<thead><tr><th>Функция</th><th>CCN</th><th>NLOC</th><th>Параметры</th><th>Расположение</th></tr></thead>
<tbody><tr><td><code>authenticate</code></td><td>7</td><td>17</td><td>2</td><td><code>auth.js:3</code></td></tr><tr><td><code>if</code></td><td>1</td><td>1</td><td>1</td><td><code>auth.js:6</code></td></tr><tr><td><code>if</code></td><td>1</td><td>1</td><td>1</td><td><code>auth.js:9</code></td></tr><tr><td><code>if</code></td><td>1</td><td>1</td><td>1</td><td><code>auth.js:10</code></td></tr><tr><td><code>if</code></td><td>1</td><td>1</td><td>1</td><td><code>auth.js:10</code></td></tr></tbody>
</table></details>
</div><div class="card module-card">
<div class="module-header">
<h3><code>ui_widget</code></h3>
<span class="badge" style="color:#f59e0b;background:#fef3c7">Средний</span>
</div>
<table style="width:auto;margin:8px 0">
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Язык</td><td>mixed</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">NLOC</td><td>0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Функций</td><td>0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Avg CCN</td><td>0.0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Max CCN</td><td>0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">C_out / C_in</td><td>2 / 0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Нестабильность (I)</td><td>1.00</td></tr>
</table>
</div><div class="card module-card">
<div class="module-header">
<h3><code>utils</code></h3>
<span class="badge" style="color:#22c55e;background:#dcfce7">Низкий</span>
</div>
<table style="width:auto;margin:8px 0">
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Язык</td><td>mixed</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">NLOC</td><td>0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Функций</td><td>0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Avg CCN</td><td>0.0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Max CCN</td><td>0</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">C_out / C_in</td><td>0 / 2</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Нестабильность (I)</td><td>0.00</td></tr>
</table>
</div>
<hr style="margin:32px 0;border:none;border-top:1px solid var(--border)">
<p style="color:var(--muted);font-size:0.75rem;text-align:center">
Legacy Analyzer — методический инструментарий модернизации унаследованных систем
</p>
</body></html>

279
legacy_report_demo.json Normal file
View File

@ -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": "<root>",
"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": {
"<root>": {
"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": [
"<root>"
],
"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": [
"<root>",
"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": []
}
}
}

131
main.py Normal file
View File

@ -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()

148
metrics_engine.py Normal file
View File

@ -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"

120
models.py Normal file
View File

@ -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)]

41
reporter.py Normal file
View File

@ -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