mfcqi 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mfcqi/__init__.py +12 -0
- mfcqi/__main__.py +12 -0
- mfcqi/analysis/__init__.py +1 -0
- mfcqi/analysis/config.py +97 -0
- mfcqi/analysis/diagnostics.py +79 -0
- mfcqi/analysis/engine.py +359 -0
- mfcqi/analysis/tools/__init__.py +1 -0
- mfcqi/analysis/tools/bandit_analyzer.py +136 -0
- mfcqi/analysis/tools/detect_secrets_analyzer.py +153 -0
- mfcqi/analysis/tools/pip_audit_analyzer.py +198 -0
- mfcqi/analysis/tools/pylint_analyzer.py +159 -0
- mfcqi/analysis/tools/ruff_analyzer.py +217 -0
- mfcqi/calculator.py +361 -0
- mfcqi/cli/__init__.py +3 -0
- mfcqi/cli/commands/__init__.py +3 -0
- mfcqi/cli/commands/analyze.py +152 -0
- mfcqi/cli/commands/analyze_helpers.py +170 -0
- mfcqi/cli/commands/badge.py +106 -0
- mfcqi/cli/commands/badge_templates.py +74 -0
- mfcqi/cli/commands/config.py +271 -0
- mfcqi/cli/commands/models.py +341 -0
- mfcqi/cli/main.py +44 -0
- mfcqi/cli/utils/__init__.py +3 -0
- mfcqi/cli/utils/config_manager.py +166 -0
- mfcqi/cli/utils/llm_handler.py +394 -0
- mfcqi/cli/utils/output.py +635 -0
- mfcqi/core/__init__.py +1 -0
- mfcqi/core/file_utils.py +74 -0
- mfcqi/core/metric.py +135 -0
- mfcqi/core/paradigm_detector.py +332 -0
- mfcqi/metrics/__init__.py +1 -0
- mfcqi/metrics/code_smell.py +198 -0
- mfcqi/metrics/cognitive.py +292 -0
- mfcqi/metrics/cohesion.py +176 -0
- mfcqi/metrics/complexity.py +263 -0
- mfcqi/metrics/coupling.py +226 -0
- mfcqi/metrics/dependency_security.py +156 -0
- mfcqi/metrics/dit.py +195 -0
- mfcqi/metrics/documentation.py +111 -0
- mfcqi/metrics/duplication.py +247 -0
- mfcqi/metrics/maintainability.py +109 -0
- mfcqi/metrics/mhf.py +129 -0
- mfcqi/metrics/rfc.py +184 -0
- mfcqi/metrics/secrets_exposure.py +153 -0
- mfcqi/metrics/security.py +747 -0
- mfcqi/metrics/type_safety.py +83 -0
- mfcqi/py.typed +0 -0
- mfcqi/quality_gates.py +154 -0
- mfcqi/smell_detection/__init__.py +25 -0
- mfcqi/smell_detection/aggregator.py +147 -0
- mfcqi/smell_detection/ast_test_smells.py +230 -0
- mfcqi/smell_detection/detector_base.py +65 -0
- mfcqi/smell_detection/models.py +87 -0
- mfcqi/smell_detection/pyexamine.py +265 -0
- mfcqi/templates/code_quality_analysis.j2 +127 -0
- mfcqi/templates/fallback_recommendations.j2 +126 -0
- mfcqi-0.0.1.dist-info/METADATA +524 -0
- mfcqi-0.0.1.dist-info/RECORD +61 -0
- mfcqi-0.0.1.dist-info/WHEEL +4 -0
- mfcqi-0.0.1.dist-info/entry_points.txt +2 -0
- mfcqi-0.0.1.dist-info/licenses/LICENSE +21 -0
mfcqi/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""MFCQI - Multi-Factor Code Quality Index."""
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = importlib.metadata.version("mfcqi")
|
|
7
|
+
except importlib.metadata.PackageNotFoundError:
|
|
8
|
+
__version__ = "0.0.1" # fallback for development
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def hello() -> str:
|
|
12
|
+
return "Hello from mfcqi!"
|
mfcqi/__main__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Main entry point for MFCQI CLI when run as python -m mfcqi."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
|
|
5
|
+
from mfcqi.cli.main import cli
|
|
6
|
+
|
|
7
|
+
# Suppress unhelpful warnings from dependencies
|
|
8
|
+
warnings.filterwarnings("ignore", category=SyntaxWarning, module="<unknown>")
|
|
9
|
+
warnings.filterwarnings("ignore", message="invalid escape sequence")
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__":
|
|
12
|
+
cli()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Analysis module for MFCQI library."""
|
mfcqi/analysis/config.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for LLM analysis.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AnalysisConfig(BaseModel):
|
|
12
|
+
"""Configuration for LLM analysis."""
|
|
13
|
+
|
|
14
|
+
model: str = "claude-3-5-sonnet-20241022"
|
|
15
|
+
temperature: float = 0.1
|
|
16
|
+
max_tokens: int = 8000
|
|
17
|
+
timeout: int = 60
|
|
18
|
+
openai_api_key: str | None = None
|
|
19
|
+
anthropic_api_key: str | None = None
|
|
20
|
+
|
|
21
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
22
|
+
"""Initialize configuration with environment variables."""
|
|
23
|
+
# Load from environment if not provided
|
|
24
|
+
if "model" not in kwargs:
|
|
25
|
+
kwargs["model"] = os.getenv("CQI_LLM_MODEL", "claude-3-5-sonnet-20241022")
|
|
26
|
+
|
|
27
|
+
if "openai_api_key" not in kwargs:
|
|
28
|
+
kwargs["openai_api_key"] = os.getenv("OPENAI_API_KEY")
|
|
29
|
+
|
|
30
|
+
if "anthropic_api_key" not in kwargs:
|
|
31
|
+
kwargs["anthropic_api_key"] = os.getenv("ANTHROPIC_API_KEY")
|
|
32
|
+
|
|
33
|
+
# Validate model and fallback if needed
|
|
34
|
+
supported_models = ["claude-3-5-sonnet-20241022", "gpt-4o", "gpt-4o-mini"]
|
|
35
|
+
|
|
36
|
+
if kwargs["model"] not in supported_models:
|
|
37
|
+
kwargs["model"] = "claude-3-5-sonnet-20241022"
|
|
38
|
+
|
|
39
|
+
super().__init__(**kwargs)
|
|
40
|
+
|
|
41
|
+
def get_api_key_for_model(self, model: str) -> str | None:
|
|
42
|
+
"""Get appropriate API key for the given model."""
|
|
43
|
+
if model.startswith("claude"):
|
|
44
|
+
return self.anthropic_api_key
|
|
45
|
+
elif model.startswith("gpt"):
|
|
46
|
+
return self.openai_api_key
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def validate_config(self) -> None:
|
|
50
|
+
"""Validate configuration."""
|
|
51
|
+
api_key = self.get_api_key_for_model(self.model)
|
|
52
|
+
if not api_key:
|
|
53
|
+
raise ValueError(f"No API key found for model {self.model}")
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, Any]:
|
|
56
|
+
"""Convert configuration to dictionary."""
|
|
57
|
+
return {
|
|
58
|
+
"model": self.model,
|
|
59
|
+
"temperature": self.temperature,
|
|
60
|
+
"max_tokens": self.max_tokens,
|
|
61
|
+
"timeout": self.timeout,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_dict(cls, config_dict: dict[str, Any]) -> "AnalysisConfig":
|
|
66
|
+
"""Create configuration from dictionary."""
|
|
67
|
+
return cls(**config_dict)
|
|
68
|
+
|
|
69
|
+
def get_supported_models(self) -> list[str]:
|
|
70
|
+
"""Get list of supported models."""
|
|
71
|
+
return ["claude-3-5-sonnet-20241022", "gpt-4o", "gpt-4o-mini"]
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_environment(cls) -> "AnalysisConfig":
|
|
75
|
+
"""Create configuration from environment with model priority."""
|
|
76
|
+
# Check available API keys and select appropriate model
|
|
77
|
+
anthropic_key = os.getenv("ANTHROPIC_API_KEY")
|
|
78
|
+
openai_key = os.getenv("OPENAI_API_KEY")
|
|
79
|
+
|
|
80
|
+
# Priority: Claude > GPT-4o > GPT-4o-mini
|
|
81
|
+
if anthropic_key:
|
|
82
|
+
model = "claude-3-5-sonnet-20241022"
|
|
83
|
+
elif openai_key:
|
|
84
|
+
model = "gpt-4o"
|
|
85
|
+
else:
|
|
86
|
+
model = "claude-3-5-sonnet-20241022" # Default
|
|
87
|
+
|
|
88
|
+
return cls(model=model)
|
|
89
|
+
|
|
90
|
+
def get_litellm_config(self) -> dict[str, Any]:
|
|
91
|
+
"""Get configuration dictionary for LiteLLM."""
|
|
92
|
+
return {
|
|
93
|
+
"model": self.model,
|
|
94
|
+
"temperature": self.temperature,
|
|
95
|
+
"max_tokens": self.max_tokens,
|
|
96
|
+
"timeout": self.timeout,
|
|
97
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Diagnostic models for MFCQI analysis - LSP compatible format.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from enum import IntEnum
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DiagnosticSeverity(IntEnum):
|
|
11
|
+
"""LSP DiagnosticSeverity enum values."""
|
|
12
|
+
|
|
13
|
+
ERROR = 1
|
|
14
|
+
WARNING = 2
|
|
15
|
+
INFORMATION = 3
|
|
16
|
+
HINT = 4
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Position(BaseModel):
|
|
20
|
+
"""LSP Position model."""
|
|
21
|
+
|
|
22
|
+
line: int
|
|
23
|
+
character: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Range(BaseModel):
|
|
27
|
+
"""LSP Range model."""
|
|
28
|
+
|
|
29
|
+
start: Position
|
|
30
|
+
end: Position
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DiagnosticRelatedInformation(BaseModel):
|
|
34
|
+
"""LSP DiagnosticRelatedInformation model."""
|
|
35
|
+
|
|
36
|
+
location_uri: str
|
|
37
|
+
location_range: Range
|
|
38
|
+
message: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Diagnostic(BaseModel):
|
|
42
|
+
"""LSP Diagnostic model."""
|
|
43
|
+
|
|
44
|
+
range: Range
|
|
45
|
+
message: str
|
|
46
|
+
severity: DiagnosticSeverity
|
|
47
|
+
code: str | None = None
|
|
48
|
+
source: str = "mfcqi"
|
|
49
|
+
related_information: list[DiagnosticRelatedInformation] | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DiagnosticsCollection(BaseModel):
|
|
53
|
+
"""Collection of diagnostics for a single file."""
|
|
54
|
+
|
|
55
|
+
file_path: str
|
|
56
|
+
diagnostics: list[Diagnostic]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_diagnostic(
|
|
60
|
+
file_path: str,
|
|
61
|
+
line: int,
|
|
62
|
+
message: str,
|
|
63
|
+
character: int = 0,
|
|
64
|
+
end_line: int | None = None,
|
|
65
|
+
end_character: int | None = None,
|
|
66
|
+
severity: DiagnosticSeverity = DiagnosticSeverity.ERROR,
|
|
67
|
+
code: str | None = None,
|
|
68
|
+
) -> Diagnostic:
|
|
69
|
+
"""Helper function to create diagnostics easily."""
|
|
70
|
+
if end_line is None:
|
|
71
|
+
end_line = line
|
|
72
|
+
if end_character is None:
|
|
73
|
+
end_character = character
|
|
74
|
+
|
|
75
|
+
start = Position(line=line, character=character)
|
|
76
|
+
end = Position(line=end_line, character=end_character)
|
|
77
|
+
range_obj = Range(start=start, end=end)
|
|
78
|
+
|
|
79
|
+
return Diagnostic(range=range_obj, message=message, severity=severity, code=code)
|
mfcqi/analysis/engine.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""LLM analysis engine with Jinja2 templates and tool context."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import litellm
|
|
7
|
+
from jinja2 import Environment, FileSystemLoader
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from mfcqi.analysis.config import AnalysisConfig
|
|
11
|
+
from mfcqi.analysis.diagnostics import (
|
|
12
|
+
DiagnosticsCollection,
|
|
13
|
+
)
|
|
14
|
+
from mfcqi.calculator import MFCQICalculator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AnalysisResult(BaseModel):
|
|
18
|
+
"""Result from LLM analysis."""
|
|
19
|
+
|
|
20
|
+
mfcqi_score: float
|
|
21
|
+
metric_scores: dict[str, float]
|
|
22
|
+
diagnostics: list[DiagnosticsCollection]
|
|
23
|
+
recommendations: list[str]
|
|
24
|
+
model_used: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LLMAnalysisEngine:
|
|
28
|
+
"""LLM analysis with context-aware recommendations."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, model: str | None = None, config: AnalysisConfig | None = None):
|
|
31
|
+
"""Initialize LLM analysis engine."""
|
|
32
|
+
if config:
|
|
33
|
+
self.config = config
|
|
34
|
+
else:
|
|
35
|
+
self.config = AnalysisConfig(model=model) if model else AnalysisConfig()
|
|
36
|
+
|
|
37
|
+
self.model_name = self.config.model
|
|
38
|
+
self.mfcqi_calculator = MFCQICalculator()
|
|
39
|
+
|
|
40
|
+
# Set up Jinja2 templates with autoescape for security
|
|
41
|
+
template_dir = Path(__file__).parent.parent / "templates"
|
|
42
|
+
self.env = Environment(
|
|
43
|
+
loader=FileSystemLoader(template_dir),
|
|
44
|
+
autoescape=True, # Enable autoescape to prevent XSS vulnerabilities
|
|
45
|
+
)
|
|
46
|
+
self.main_template = self.env.get_template("code_quality_analysis.j2")
|
|
47
|
+
self.fallback_template = self.env.get_template("fallback_recommendations.j2")
|
|
48
|
+
|
|
49
|
+
def analyze_with_cqi_data(
|
|
50
|
+
self,
|
|
51
|
+
codebase_path: str,
|
|
52
|
+
cqi_data: dict[str, Any],
|
|
53
|
+
recommendation_count: int = 50,
|
|
54
|
+
tool_outputs: dict[str, Any] | None = None,
|
|
55
|
+
) -> AnalysisResult:
|
|
56
|
+
"""Analyze with pre-calculated MFCQI data and REAL tool outputs."""
|
|
57
|
+
try:
|
|
58
|
+
self.config.validate_config()
|
|
59
|
+
|
|
60
|
+
# Build context with REAL tool outputs
|
|
61
|
+
context = self._build_context_with_real_data(
|
|
62
|
+
Path(codebase_path), cqi_data, tool_outputs or {}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Extract prioritized issues from tool outputs
|
|
66
|
+
prioritized_issues = self._extract_prioritized_issues(
|
|
67
|
+
tool_outputs or {}, recommendation_count, cqi_data
|
|
68
|
+
)
|
|
69
|
+
context["prioritized_issues"] = prioritized_issues
|
|
70
|
+
context["recommendation_count"] = len(prioritized_issues)
|
|
71
|
+
|
|
72
|
+
# Generate prompt from template
|
|
73
|
+
prompt = self.main_template.render(**context)
|
|
74
|
+
|
|
75
|
+
# Make LLM request
|
|
76
|
+
llm_response = self._make_llm_request(prompt)
|
|
77
|
+
|
|
78
|
+
# Parse recommendations
|
|
79
|
+
recommendations = self._parse_recommendations(llm_response, len(prioritized_issues))
|
|
80
|
+
|
|
81
|
+
return AnalysisResult(
|
|
82
|
+
mfcqi_score=cqi_data.get("mfcqi_score", 0.0),
|
|
83
|
+
metric_scores={k: v for k, v in cqi_data.items() if k != "mfcqi_score"},
|
|
84
|
+
diagnostics=[],
|
|
85
|
+
recommendations=recommendations,
|
|
86
|
+
model_used=self.model_name,
|
|
87
|
+
)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
# No fallbacks - fail properly
|
|
90
|
+
raise Exception(f"LLM analysis failed: {e}") from e
|
|
91
|
+
|
|
92
|
+
def _build_context_with_real_data(
|
|
93
|
+
self, codebase_path: Path, metrics: dict[str, Any], tool_outputs: dict[str, Any]
|
|
94
|
+
) -> dict[str, Any]:
|
|
95
|
+
"""Build context with REAL tool outputs, no fakes."""
|
|
96
|
+
from mfcqi.core.file_utils import get_python_files
|
|
97
|
+
|
|
98
|
+
py_files = get_python_files(codebase_path)
|
|
99
|
+
total_lines = sum(len(f.read_text().splitlines()) for f in py_files if f.exists())
|
|
100
|
+
|
|
101
|
+
# Categorize metrics by score
|
|
102
|
+
critical_metrics = []
|
|
103
|
+
for name, score in metrics.items():
|
|
104
|
+
if name != "mfcqi_score" and isinstance(score, (int, float)) and score < 0.3:
|
|
105
|
+
critical_metrics.append(
|
|
106
|
+
{
|
|
107
|
+
"name": name,
|
|
108
|
+
"score": score,
|
|
109
|
+
"tool_output": self._format_tool_output_for_metric(name, tool_outputs),
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Format real tool outputs for template
|
|
114
|
+
formatted_tool_outputs = self._format_tool_outputs(tool_outputs)
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"codebase_path": str(codebase_path),
|
|
118
|
+
"total_files": len(py_files),
|
|
119
|
+
"total_lines": total_lines,
|
|
120
|
+
"mfcqi_score": metrics.get("mfcqi_score", 0.0),
|
|
121
|
+
"metrics": {k: v for k, v in metrics.items() if k != "mfcqi_score"},
|
|
122
|
+
"critical_metrics": sorted(critical_metrics, key=lambda x: x["score"]),
|
|
123
|
+
"tool_outputs": formatted_tool_outputs,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def _extract_prioritized_issues(
|
|
127
|
+
self,
|
|
128
|
+
tool_outputs: dict[str, Any],
|
|
129
|
+
recommendation_count: int,
|
|
130
|
+
metrics: dict[str, Any] | None = None,
|
|
131
|
+
) -> list[dict[str, Any]]:
|
|
132
|
+
"""Extract and prioritize issues from tool outputs to generate N recommendations."""
|
|
133
|
+
all_issues = []
|
|
134
|
+
|
|
135
|
+
# Extract security issues from Bandit (HIGHEST priority)
|
|
136
|
+
if "bandit_issues" in tool_outputs:
|
|
137
|
+
for issue in tool_outputs["bandit_issues"]:
|
|
138
|
+
# Map Bandit severity to our priority (always treat as HIGH for security)
|
|
139
|
+
severity = issue.get("issue_severity", "MEDIUM")
|
|
140
|
+
if severity in ["CRITICAL", "HIGH"]:
|
|
141
|
+
priority = 4
|
|
142
|
+
severity_tag = "HIGH"
|
|
143
|
+
else:
|
|
144
|
+
priority = 3
|
|
145
|
+
severity_tag = "MEDIUM"
|
|
146
|
+
|
|
147
|
+
all_issues.append(
|
|
148
|
+
{
|
|
149
|
+
"priority": priority,
|
|
150
|
+
"severity": severity_tag,
|
|
151
|
+
"type": "security",
|
|
152
|
+
"file": issue.get("filename", "unknown"),
|
|
153
|
+
"line": issue.get("line_number", 0),
|
|
154
|
+
"issue": issue.get("test_name", "unknown"),
|
|
155
|
+
"description": issue.get("issue_text", ""),
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Extract complexity issues (HIGH priority for complexity > 15)
|
|
160
|
+
if "complex_functions" in tool_outputs:
|
|
161
|
+
for func in tool_outputs["complex_functions"]:
|
|
162
|
+
complexity = func.get("complexity", 0)
|
|
163
|
+
if complexity > 15:
|
|
164
|
+
priority = 3
|
|
165
|
+
severity = "HIGH"
|
|
166
|
+
elif complexity > 10:
|
|
167
|
+
priority = 2
|
|
168
|
+
severity = "MEDIUM"
|
|
169
|
+
else:
|
|
170
|
+
priority = 1
|
|
171
|
+
severity = "LOW"
|
|
172
|
+
|
|
173
|
+
all_issues.append(
|
|
174
|
+
{
|
|
175
|
+
"priority": priority,
|
|
176
|
+
"severity": severity,
|
|
177
|
+
"type": "complexity",
|
|
178
|
+
"file": func.get("file", "unknown"),
|
|
179
|
+
"line": func.get("line", 0),
|
|
180
|
+
"issue": f"High cyclomatic complexity in {func.get('name', 'unknown')}",
|
|
181
|
+
"description": f"Cyclomatic complexity: {complexity}",
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Add metric-based issues if we haven't reached the cap
|
|
186
|
+
if metrics:
|
|
187
|
+
for metric_name, score in metrics.items():
|
|
188
|
+
if metric_name != "mfcqi_score" and isinstance(score, (int, float)) and score < 0.6:
|
|
189
|
+
priority = 2 if score < 0.4 else 1
|
|
190
|
+
all_issues.append(
|
|
191
|
+
{
|
|
192
|
+
"priority": priority,
|
|
193
|
+
"severity": "MEDIUM" if score < 0.4 else "LOW",
|
|
194
|
+
"type": "metric",
|
|
195
|
+
"file": "project-wide",
|
|
196
|
+
"line": 0,
|
|
197
|
+
"issue": f"Low {metric_name.replace('_', ' ')} score",
|
|
198
|
+
"description": f"{metric_name}: {score:.3f}",
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Sort by priority (highest first) and take top N
|
|
203
|
+
all_issues.sort(key=lambda x: x["priority"], reverse=True)
|
|
204
|
+
return all_issues[:recommendation_count]
|
|
205
|
+
|
|
206
|
+
def _format_tool_output_for_metric(self, metric_name: str, tool_outputs: dict[str, Any]) -> str:
|
|
207
|
+
"""Format tool output for a specific metric."""
|
|
208
|
+
if metric_name == "security" and "bandit_issues" in tool_outputs:
|
|
209
|
+
issues = tool_outputs["bandit_issues"]
|
|
210
|
+
return f"Found {len(issues)} security vulnerabilities via Bandit"
|
|
211
|
+
elif metric_name == "halstead_volume" and f"{metric_name}_raw" in tool_outputs:
|
|
212
|
+
return f"Halstead Volume: {tool_outputs[f'{metric_name}_raw']:.0f}"
|
|
213
|
+
elif metric_name == "cyclomatic_complexity" and f"{metric_name}_raw" in tool_outputs:
|
|
214
|
+
return f"Average Cyclomatic Complexity: {tool_outputs[f'{metric_name}_raw']:.1f}"
|
|
215
|
+
return ""
|
|
216
|
+
|
|
217
|
+
def _format_tool_outputs(self, tool_outputs: dict[str, Any]) -> dict[str, Any]:
|
|
218
|
+
"""Format real tool outputs for template consumption."""
|
|
219
|
+
formatted: dict[str, Any] = {}
|
|
220
|
+
|
|
221
|
+
# Format Bandit issues if present
|
|
222
|
+
if "bandit_issues" in tool_outputs:
|
|
223
|
+
issues = tool_outputs["bandit_issues"]
|
|
224
|
+
|
|
225
|
+
# Count by severity
|
|
226
|
+
severity_counts = {"HIGH": 0, "MEDIUM": 0, "LOW": 0, "CRITICAL": 0}
|
|
227
|
+
for issue in issues:
|
|
228
|
+
severity = issue.get("issue_severity", "LOW")
|
|
229
|
+
severity_counts[severity] = severity_counts.get(severity, 0) + 1
|
|
230
|
+
|
|
231
|
+
# Get top issues
|
|
232
|
+
top_issues = sorted(
|
|
233
|
+
issues,
|
|
234
|
+
key=lambda x: {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1}.get(
|
|
235
|
+
x.get("issue_severity", "LOW"), 0
|
|
236
|
+
),
|
|
237
|
+
reverse=True,
|
|
238
|
+
)[:10]
|
|
239
|
+
|
|
240
|
+
formatted["bandit"] = {
|
|
241
|
+
"summary": f"Found {len(issues)} security issues",
|
|
242
|
+
"critical_count": severity_counts["CRITICAL"],
|
|
243
|
+
"high_count": severity_counts["HIGH"],
|
|
244
|
+
"medium_count": severity_counts["MEDIUM"],
|
|
245
|
+
"low_count": severity_counts["LOW"],
|
|
246
|
+
"top_issues": [
|
|
247
|
+
{
|
|
248
|
+
"test_name": issue.get("test_name", "unknown"),
|
|
249
|
+
"issue_text": issue.get("issue_text", ""),
|
|
250
|
+
"filename": issue.get("filename", "unknown"),
|
|
251
|
+
"line_number": issue.get("line_number", 0),
|
|
252
|
+
"severity": issue.get("issue_severity", "UNKNOWN"),
|
|
253
|
+
}
|
|
254
|
+
for issue in top_issues
|
|
255
|
+
],
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
# Format complexity data if present
|
|
259
|
+
if (
|
|
260
|
+
"cyclomatic_complexity_raw" in tool_outputs
|
|
261
|
+
or "halstead_volume_raw" in tool_outputs
|
|
262
|
+
or "complex_functions" in tool_outputs
|
|
263
|
+
):
|
|
264
|
+
complexity_data: dict[str, Any] = {"complex_functions": [], "high_volume_files": []}
|
|
265
|
+
formatted["complexity"] = complexity_data
|
|
266
|
+
|
|
267
|
+
# Use detailed function-level data if available
|
|
268
|
+
if "complex_functions" in tool_outputs:
|
|
269
|
+
complexity_data["complex_functions"] = tool_outputs["complex_functions"]
|
|
270
|
+
elif "cyclomatic_complexity_raw" in tool_outputs:
|
|
271
|
+
avg_cc = tool_outputs["cyclomatic_complexity_raw"]
|
|
272
|
+
if avg_cc > 10:
|
|
273
|
+
functions_list = complexity_data["complex_functions"]
|
|
274
|
+
if isinstance(functions_list, list):
|
|
275
|
+
functions_list.append(
|
|
276
|
+
{
|
|
277
|
+
"name": "Multiple functions",
|
|
278
|
+
"file": "Various files",
|
|
279
|
+
"complexity": round(avg_cc),
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if "halstead_volume_raw" in tool_outputs:
|
|
284
|
+
volume = tool_outputs["halstead_volume_raw"]
|
|
285
|
+
if volume > 1000:
|
|
286
|
+
files_list = complexity_data["high_volume_files"]
|
|
287
|
+
if isinstance(files_list, list):
|
|
288
|
+
files_list.append({"path": "Various files", "volume": round(volume)})
|
|
289
|
+
|
|
290
|
+
return formatted
|
|
291
|
+
|
|
292
|
+
def _make_llm_request(self, prompt: str) -> str:
|
|
293
|
+
"""Make request to LLM."""
|
|
294
|
+
try:
|
|
295
|
+
litellm_config = self.config.get_litellm_config()
|
|
296
|
+
response = litellm.completion(
|
|
297
|
+
messages=[{"role": "user", "content": prompt}], **litellm_config
|
|
298
|
+
)
|
|
299
|
+
content = response.choices[0].message.content
|
|
300
|
+
return content if isinstance(content, str) else str(content)
|
|
301
|
+
except Exception as e:
|
|
302
|
+
# NO FALLBACKS - let it fail properly
|
|
303
|
+
raise Exception(f"LLM request failed: {e}") from e
|
|
304
|
+
|
|
305
|
+
def _parse_recommendations(self, response: str, max_recommendations: int) -> list[str]:
|
|
306
|
+
"""Parse markdown-formatted LLM response into recommendations."""
|
|
307
|
+
import re
|
|
308
|
+
|
|
309
|
+
recommendations = []
|
|
310
|
+
|
|
311
|
+
# Split by ## headings (since LLM doesn't use --- separators consistently)
|
|
312
|
+
sections = re.split(r"\n(?=##\s*\[)", response)
|
|
313
|
+
|
|
314
|
+
for section in sections:
|
|
315
|
+
section = section.strip()
|
|
316
|
+
if not section or not section.startswith("##"):
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
# Extract heading with severity
|
|
320
|
+
heading_match = re.search(r"^##\s*\[(\w+)\]\s*(.+)$", section, re.MULTILINE)
|
|
321
|
+
if heading_match:
|
|
322
|
+
severity = heading_match.group(1).upper()
|
|
323
|
+
title = heading_match.group(2).strip()
|
|
324
|
+
|
|
325
|
+
# Extract description
|
|
326
|
+
desc_match = re.search(
|
|
327
|
+
r"\*\*Description:\*\*\s*(.+?)(?=\*\*|$)", section, re.DOTALL
|
|
328
|
+
)
|
|
329
|
+
description = desc_match.group(1).strip() if desc_match else ""
|
|
330
|
+
|
|
331
|
+
# Build formatted recommendation
|
|
332
|
+
if title and description:
|
|
333
|
+
# Truncate description to first sentence or 200 chars
|
|
334
|
+
if ". " in description:
|
|
335
|
+
description = description.split(". ")[0] + "."
|
|
336
|
+
elif len(description) > 200:
|
|
337
|
+
description = description[:200] + "..."
|
|
338
|
+
|
|
339
|
+
recommendations.append(f"[{severity}] {title}: {description}")
|
|
340
|
+
|
|
341
|
+
# If no markdown format found, try to parse as plain text recommendations
|
|
342
|
+
if not recommendations and response.strip():
|
|
343
|
+
# Split by numbered list items
|
|
344
|
+
lines = response.strip().split("\n")
|
|
345
|
+
for line in lines:
|
|
346
|
+
line = line.strip()
|
|
347
|
+
# Match patterns like "1. ", "- ", "* ", etc.
|
|
348
|
+
if re.match(r"^[\d\-\*•]\.\s", line):
|
|
349
|
+
# Remove the bullet/number
|
|
350
|
+
clean_line = re.sub(r"^[\d\-\*•]\.\s*", "", line)
|
|
351
|
+
# Check for severity markers
|
|
352
|
+
sev_match = re.match(r"^\[(\w+)\]\s*(.+)", clean_line)
|
|
353
|
+
if sev_match:
|
|
354
|
+
recommendations.append(clean_line)
|
|
355
|
+
else:
|
|
356
|
+
# Default to MEDIUM if no severity specified
|
|
357
|
+
recommendations.append(f"[MEDIUM] {clean_line}")
|
|
358
|
+
|
|
359
|
+
return recommendations[:max_recommendations]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Analysis tools package."""
|