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.
Files changed (61) hide show
  1. mfcqi/__init__.py +12 -0
  2. mfcqi/__main__.py +12 -0
  3. mfcqi/analysis/__init__.py +1 -0
  4. mfcqi/analysis/config.py +97 -0
  5. mfcqi/analysis/diagnostics.py +79 -0
  6. mfcqi/analysis/engine.py +359 -0
  7. mfcqi/analysis/tools/__init__.py +1 -0
  8. mfcqi/analysis/tools/bandit_analyzer.py +136 -0
  9. mfcqi/analysis/tools/detect_secrets_analyzer.py +153 -0
  10. mfcqi/analysis/tools/pip_audit_analyzer.py +198 -0
  11. mfcqi/analysis/tools/pylint_analyzer.py +159 -0
  12. mfcqi/analysis/tools/ruff_analyzer.py +217 -0
  13. mfcqi/calculator.py +361 -0
  14. mfcqi/cli/__init__.py +3 -0
  15. mfcqi/cli/commands/__init__.py +3 -0
  16. mfcqi/cli/commands/analyze.py +152 -0
  17. mfcqi/cli/commands/analyze_helpers.py +170 -0
  18. mfcqi/cli/commands/badge.py +106 -0
  19. mfcqi/cli/commands/badge_templates.py +74 -0
  20. mfcqi/cli/commands/config.py +271 -0
  21. mfcqi/cli/commands/models.py +341 -0
  22. mfcqi/cli/main.py +44 -0
  23. mfcqi/cli/utils/__init__.py +3 -0
  24. mfcqi/cli/utils/config_manager.py +166 -0
  25. mfcqi/cli/utils/llm_handler.py +394 -0
  26. mfcqi/cli/utils/output.py +635 -0
  27. mfcqi/core/__init__.py +1 -0
  28. mfcqi/core/file_utils.py +74 -0
  29. mfcqi/core/metric.py +135 -0
  30. mfcqi/core/paradigm_detector.py +332 -0
  31. mfcqi/metrics/__init__.py +1 -0
  32. mfcqi/metrics/code_smell.py +198 -0
  33. mfcqi/metrics/cognitive.py +292 -0
  34. mfcqi/metrics/cohesion.py +176 -0
  35. mfcqi/metrics/complexity.py +263 -0
  36. mfcqi/metrics/coupling.py +226 -0
  37. mfcqi/metrics/dependency_security.py +156 -0
  38. mfcqi/metrics/dit.py +195 -0
  39. mfcqi/metrics/documentation.py +111 -0
  40. mfcqi/metrics/duplication.py +247 -0
  41. mfcqi/metrics/maintainability.py +109 -0
  42. mfcqi/metrics/mhf.py +129 -0
  43. mfcqi/metrics/rfc.py +184 -0
  44. mfcqi/metrics/secrets_exposure.py +153 -0
  45. mfcqi/metrics/security.py +747 -0
  46. mfcqi/metrics/type_safety.py +83 -0
  47. mfcqi/py.typed +0 -0
  48. mfcqi/quality_gates.py +154 -0
  49. mfcqi/smell_detection/__init__.py +25 -0
  50. mfcqi/smell_detection/aggregator.py +147 -0
  51. mfcqi/smell_detection/ast_test_smells.py +230 -0
  52. mfcqi/smell_detection/detector_base.py +65 -0
  53. mfcqi/smell_detection/models.py +87 -0
  54. mfcqi/smell_detection/pyexamine.py +265 -0
  55. mfcqi/templates/code_quality_analysis.j2 +127 -0
  56. mfcqi/templates/fallback_recommendations.j2 +126 -0
  57. mfcqi-0.0.1.dist-info/METADATA +524 -0
  58. mfcqi-0.0.1.dist-info/RECORD +61 -0
  59. mfcqi-0.0.1.dist-info/WHEEL +4 -0
  60. mfcqi-0.0.1.dist-info/entry_points.txt +2 -0
  61. 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."""
@@ -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)
@@ -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."""