init
This commit is contained in:
commit
320979f871
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
*.swp
|
||||
*__pycache__*
|
||||
|
||||
|
||||
112
README.md
Normal file
112
README.md
Normal 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
90
analyzer.py
Normal 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
257
code_analyzer.py
Normal 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
131
collector.py
Normal 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
74
config.py
Normal 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
258
decision_engine.py
Normal 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
318
dependency_analyzer.py
Normal 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
286
html_reporter.py
Normal 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> |
|
||||
Сгенерировано: {ts} |
|
||||
Системный риск: <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
106
json_reporter.py
Normal 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
249
legacy_report_demo.html
Normal 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> |
|
||||
Сгенерировано: 27.04.2026 18:39 |
|
||||
Системный риск: <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><root></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><root></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
279
legacy_report_demo.json
Normal 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
131
main.py
Normal 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
148
metrics_engine.py
Normal 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
120
models.py
Normal 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
41
reporter.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user