arch-researcher/metrics_engine.py

149 lines
6.0 KiB
Python
Raw Normal View History

2026-04-27 18:44:22 +00:00
"""
Модуль 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"