codeshift 0.5.0__py3-none-any.whl → 0.7.3__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.
- codeshift/__init__.py +1 -1
- codeshift/cli/commands/health.py +244 -0
- codeshift/cli/main.py +2 -0
- codeshift/health/__init__.py +50 -0
- codeshift/health/calculator.py +217 -0
- codeshift/health/metrics/__init__.py +63 -0
- codeshift/health/metrics/documentation.py +209 -0
- codeshift/health/metrics/freshness.py +180 -0
- codeshift/health/metrics/migration_readiness.py +142 -0
- codeshift/health/metrics/security.py +225 -0
- codeshift/health/metrics/test_coverage.py +191 -0
- codeshift/health/models.py +284 -0
- codeshift/health/report.py +310 -0
- {codeshift-0.5.0.dist-info → codeshift-0.7.3.dist-info}/METADATA +21 -14
- {codeshift-0.5.0.dist-info → codeshift-0.7.3.dist-info}/RECORD +19 -8
- {codeshift-0.5.0.dist-info → codeshift-0.7.3.dist-info}/WHEEL +0 -0
- {codeshift-0.5.0.dist-info → codeshift-0.7.3.dist-info}/entry_points.txt +0 -0
- {codeshift-0.5.0.dist-info → codeshift-0.7.3.dist-info}/licenses/LICENSE +0 -0
- {codeshift-0.5.0.dist-info → codeshift-0.7.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Security vulnerabilities metric calculator."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from codeshift.health.metrics import BaseMetricCalculator
|
|
10
|
+
from codeshift.health.models import (
|
|
11
|
+
DependencyHealth,
|
|
12
|
+
MetricCategory,
|
|
13
|
+
MetricResult,
|
|
14
|
+
SecurityVulnerability,
|
|
15
|
+
VulnerabilitySeverity,
|
|
16
|
+
)
|
|
17
|
+
from codeshift.scanner.dependency_parser import DependencyParser
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# PyPI API timeout
|
|
22
|
+
PYPI_TIMEOUT = 5.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SecurityCalculator(BaseMetricCalculator):
|
|
26
|
+
"""Calculates security score based on known vulnerabilities (25% weight).
|
|
27
|
+
|
|
28
|
+
Penalties:
|
|
29
|
+
- Critical: -25 points
|
|
30
|
+
- High: -15 points
|
|
31
|
+
- Medium: -8 points
|
|
32
|
+
- Low: -3 points
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def category(self) -> MetricCategory:
|
|
37
|
+
return MetricCategory.SECURITY
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def weight(self) -> float:
|
|
41
|
+
return 0.25
|
|
42
|
+
|
|
43
|
+
def calculate(
|
|
44
|
+
self,
|
|
45
|
+
project_path: Path,
|
|
46
|
+
dependencies: list[DependencyHealth] | None = None,
|
|
47
|
+
**kwargs: Any,
|
|
48
|
+
) -> MetricResult:
|
|
49
|
+
"""Calculate the security score.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
project_path: Path to the project
|
|
53
|
+
dependencies: Pre-populated dependency health list (optional)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
MetricResult with security score
|
|
57
|
+
"""
|
|
58
|
+
if dependencies is None:
|
|
59
|
+
dependencies = self._analyze_dependencies(project_path)
|
|
60
|
+
|
|
61
|
+
if not dependencies:
|
|
62
|
+
return self._create_result(
|
|
63
|
+
score=100,
|
|
64
|
+
description="No dependencies to analyze",
|
|
65
|
+
details={"dependency_count": 0, "vulnerability_count": 0},
|
|
66
|
+
recommendations=[],
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Collect all vulnerabilities
|
|
70
|
+
all_vulns: list[SecurityVulnerability] = []
|
|
71
|
+
vuln_counts = {
|
|
72
|
+
VulnerabilitySeverity.CRITICAL: 0,
|
|
73
|
+
VulnerabilitySeverity.HIGH: 0,
|
|
74
|
+
VulnerabilitySeverity.MEDIUM: 0,
|
|
75
|
+
VulnerabilitySeverity.LOW: 0,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for dep in dependencies:
|
|
79
|
+
for vuln in dep.vulnerabilities:
|
|
80
|
+
all_vulns.append(vuln)
|
|
81
|
+
vuln_counts[vuln.severity] += 1
|
|
82
|
+
|
|
83
|
+
# Calculate penalty
|
|
84
|
+
total_penalty = sum(count * severity.penalty for severity, count in vuln_counts.items())
|
|
85
|
+
score = max(0, 100 - total_penalty)
|
|
86
|
+
|
|
87
|
+
# Build recommendations
|
|
88
|
+
recommendations: list[str] = []
|
|
89
|
+
if vuln_counts[VulnerabilitySeverity.CRITICAL] > 0:
|
|
90
|
+
critical_pkgs = list(
|
|
91
|
+
{v.package for v in all_vulns if v.severity == VulnerabilitySeverity.CRITICAL}
|
|
92
|
+
)
|
|
93
|
+
recommendations.append(
|
|
94
|
+
f"URGENT: Fix critical vulnerabilities in: {', '.join(critical_pkgs)}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if vuln_counts[VulnerabilitySeverity.HIGH] > 0:
|
|
98
|
+
high_pkgs = list(
|
|
99
|
+
{v.package for v in all_vulns if v.severity == VulnerabilitySeverity.HIGH}
|
|
100
|
+
)
|
|
101
|
+
recommendations.append(
|
|
102
|
+
f"Address high severity vulnerabilities in: {', '.join(high_pkgs)}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if vuln_counts[VulnerabilitySeverity.MEDIUM] > 0:
|
|
106
|
+
recommendations.append(
|
|
107
|
+
f"Review {vuln_counts[VulnerabilitySeverity.MEDIUM]} medium severity vulnerabilities"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return self._create_result(
|
|
111
|
+
score=score,
|
|
112
|
+
description=(
|
|
113
|
+
f"{len(all_vulns)} vulnerabilities found"
|
|
114
|
+
if all_vulns
|
|
115
|
+
else "No known vulnerabilities"
|
|
116
|
+
),
|
|
117
|
+
details={
|
|
118
|
+
"total_vulnerabilities": len(all_vulns),
|
|
119
|
+
"critical": vuln_counts[VulnerabilitySeverity.CRITICAL],
|
|
120
|
+
"high": vuln_counts[VulnerabilitySeverity.HIGH],
|
|
121
|
+
"medium": vuln_counts[VulnerabilitySeverity.MEDIUM],
|
|
122
|
+
"low": vuln_counts[VulnerabilitySeverity.LOW],
|
|
123
|
+
"total_penalty": total_penalty,
|
|
124
|
+
},
|
|
125
|
+
recommendations=recommendations,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _analyze_dependencies(self, project_path: Path) -> list[DependencyHealth]:
|
|
129
|
+
"""Analyze project dependencies for security vulnerabilities.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
project_path: Path to the project
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
List of DependencyHealth objects with vulnerability data
|
|
136
|
+
"""
|
|
137
|
+
parser = DependencyParser(project_path)
|
|
138
|
+
dependencies = parser.parse_all()
|
|
139
|
+
|
|
140
|
+
results: list[DependencyHealth] = []
|
|
141
|
+
|
|
142
|
+
for dep in dependencies:
|
|
143
|
+
vulns = self._get_vulnerabilities(dep.name)
|
|
144
|
+
results.append(
|
|
145
|
+
DependencyHealth(
|
|
146
|
+
name=dep.name,
|
|
147
|
+
current_version=str(dep.min_version) if dep.min_version else None,
|
|
148
|
+
latest_version=None,
|
|
149
|
+
is_outdated=False,
|
|
150
|
+
vulnerabilities=vulns,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return results
|
|
155
|
+
|
|
156
|
+
def _get_vulnerabilities(self, package_name: str) -> list[SecurityVulnerability]:
|
|
157
|
+
"""Get known vulnerabilities for a package from PyPI.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
package_name: Name of the package
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
List of SecurityVulnerability objects
|
|
164
|
+
"""
|
|
165
|
+
vulns: list[SecurityVulnerability] = []
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
response = httpx.get(
|
|
169
|
+
f"https://pypi.org/pypi/{package_name}/json",
|
|
170
|
+
timeout=PYPI_TIMEOUT,
|
|
171
|
+
)
|
|
172
|
+
if response.status_code == 200:
|
|
173
|
+
data = response.json()
|
|
174
|
+
vulnerabilities = data.get("vulnerabilities", [])
|
|
175
|
+
|
|
176
|
+
for vuln_data in vulnerabilities:
|
|
177
|
+
severity_str = self._parse_severity(vuln_data)
|
|
178
|
+
try:
|
|
179
|
+
severity = VulnerabilitySeverity(severity_str.lower())
|
|
180
|
+
except ValueError:
|
|
181
|
+
severity = VulnerabilitySeverity.MEDIUM
|
|
182
|
+
|
|
183
|
+
fixed_in = None
|
|
184
|
+
if vuln_data.get("fixed_in"):
|
|
185
|
+
fixed_versions = vuln_data.get("fixed_in", [])
|
|
186
|
+
if fixed_versions:
|
|
187
|
+
fixed_in = fixed_versions[0]
|
|
188
|
+
|
|
189
|
+
vulns.append(
|
|
190
|
+
SecurityVulnerability(
|
|
191
|
+
package=package_name,
|
|
192
|
+
vulnerability_id=vuln_data.get("id", "unknown"),
|
|
193
|
+
severity=severity,
|
|
194
|
+
description=vuln_data.get("summary", vuln_data.get("details", ""))[
|
|
195
|
+
:200
|
|
196
|
+
],
|
|
197
|
+
fixed_in=fixed_in,
|
|
198
|
+
url=vuln_data.get("link"),
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
except Exception as e:
|
|
203
|
+
logger.debug(f"Failed to get vulnerabilities for {package_name}: {e}")
|
|
204
|
+
|
|
205
|
+
return vulns
|
|
206
|
+
|
|
207
|
+
def _parse_severity(self, vuln_data: dict) -> str:
|
|
208
|
+
"""Parse severity from vulnerability data.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
vuln_data: Vulnerability data dictionary
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Severity string (critical, high, medium, low)
|
|
215
|
+
"""
|
|
216
|
+
# Try to get severity from aliases (e.g., CVE data)
|
|
217
|
+
aliases = vuln_data.get("aliases", [])
|
|
218
|
+
for alias in aliases:
|
|
219
|
+
if "CRITICAL" in alias.upper():
|
|
220
|
+
return "critical"
|
|
221
|
+
elif "HIGH" in alias.upper():
|
|
222
|
+
return "high"
|
|
223
|
+
|
|
224
|
+
# Default to medium if not specified
|
|
225
|
+
return "medium"
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Test coverage metric calculator."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from codeshift.health.metrics import BaseMetricCalculator
|
|
9
|
+
from codeshift.health.models import MetricCategory, MetricResult
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestCoverageCalculator(BaseMetricCalculator):
|
|
15
|
+
"""Calculates test coverage score (15% weight).
|
|
16
|
+
|
|
17
|
+
Score is directly mapped from coverage percentage.
|
|
18
|
+
Returns 50 (neutral) if no coverage data is found.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def category(self) -> MetricCategory:
|
|
23
|
+
return MetricCategory.TEST_COVERAGE
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def weight(self) -> float:
|
|
27
|
+
return 0.15
|
|
28
|
+
|
|
29
|
+
def calculate(self, project_path: Path, **kwargs: Any) -> MetricResult:
|
|
30
|
+
"""Calculate the test coverage score.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
project_path: Path to the project
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
MetricResult with test coverage score
|
|
37
|
+
"""
|
|
38
|
+
coverage, source = self._get_coverage(project_path)
|
|
39
|
+
|
|
40
|
+
if coverage is None:
|
|
41
|
+
return self._create_result(
|
|
42
|
+
score=50, # Neutral score when no data
|
|
43
|
+
description="No coverage data found",
|
|
44
|
+
details={"coverage_found": False},
|
|
45
|
+
recommendations=[
|
|
46
|
+
"Run tests with coverage: pytest --cov",
|
|
47
|
+
"Generate coverage report: coverage run -m pytest && coverage report",
|
|
48
|
+
],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Direct mapping: coverage % = score
|
|
52
|
+
score = coverage * 100
|
|
53
|
+
|
|
54
|
+
recommendations: list[str] = []
|
|
55
|
+
if coverage < 0.5:
|
|
56
|
+
recommendations.append("Increase test coverage to at least 50%")
|
|
57
|
+
elif coverage < 0.8:
|
|
58
|
+
recommendations.append("Consider increasing test coverage to 80% or higher")
|
|
59
|
+
|
|
60
|
+
return self._create_result(
|
|
61
|
+
score=score,
|
|
62
|
+
description=f"{coverage:.0%} test coverage",
|
|
63
|
+
details={
|
|
64
|
+
"coverage_found": True,
|
|
65
|
+
"coverage_percentage": coverage * 100,
|
|
66
|
+
"source": source,
|
|
67
|
+
},
|
|
68
|
+
recommendations=recommendations,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _get_coverage(self, project_path: Path) -> tuple[float | None, str]:
|
|
72
|
+
"""Get test coverage from available sources.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
project_path: Path to the project
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Tuple of (coverage percentage as 0-1 or None, source description)
|
|
79
|
+
"""
|
|
80
|
+
# Try coverage.json first (pytest-cov JSON output)
|
|
81
|
+
coverage_json = project_path / "coverage.json"
|
|
82
|
+
if coverage_json.exists():
|
|
83
|
+
try:
|
|
84
|
+
data = json.loads(coverage_json.read_text())
|
|
85
|
+
totals = data.get("totals", {})
|
|
86
|
+
percent = totals.get("percent_covered", 0)
|
|
87
|
+
return percent / 100, "coverage.json"
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.debug(f"Failed to parse coverage.json: {e}")
|
|
90
|
+
|
|
91
|
+
# Try .coverage SQLite database
|
|
92
|
+
coverage_db = project_path / ".coverage"
|
|
93
|
+
if coverage_db.exists():
|
|
94
|
+
coverage = self._read_coverage_db(coverage_db)
|
|
95
|
+
if coverage is not None:
|
|
96
|
+
return coverage, ".coverage database"
|
|
97
|
+
|
|
98
|
+
# Try htmlcov/index.html for percentage
|
|
99
|
+
htmlcov_index = project_path / "htmlcov" / "index.html"
|
|
100
|
+
if htmlcov_index.exists():
|
|
101
|
+
coverage = self._parse_htmlcov(htmlcov_index)
|
|
102
|
+
if coverage is not None:
|
|
103
|
+
return coverage, "htmlcov"
|
|
104
|
+
|
|
105
|
+
# Try pytest-cov XML format
|
|
106
|
+
coverage_xml = project_path / "coverage.xml"
|
|
107
|
+
if coverage_xml.exists():
|
|
108
|
+
coverage = self._parse_coverage_xml(coverage_xml)
|
|
109
|
+
if coverage is not None:
|
|
110
|
+
return coverage, "coverage.xml"
|
|
111
|
+
|
|
112
|
+
return None, ""
|
|
113
|
+
|
|
114
|
+
def _read_coverage_db(self, db_path: Path) -> float | None:
|
|
115
|
+
"""Read coverage from SQLite database.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
db_path: Path to .coverage database
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Coverage percentage as 0-1 or None
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
import sqlite3
|
|
125
|
+
|
|
126
|
+
conn = sqlite3.connect(db_path)
|
|
127
|
+
cursor = conn.cursor()
|
|
128
|
+
|
|
129
|
+
# Get total lines and covered lines
|
|
130
|
+
cursor.execute(
|
|
131
|
+
"""
|
|
132
|
+
SELECT SUM(num_lines), SUM(num_hits)
|
|
133
|
+
FROM line_counts
|
|
134
|
+
"""
|
|
135
|
+
)
|
|
136
|
+
row = cursor.fetchone()
|
|
137
|
+
conn.close()
|
|
138
|
+
|
|
139
|
+
if row and row[0] and row[0] > 0:
|
|
140
|
+
total_lines = row[0]
|
|
141
|
+
covered_lines = row[1] or 0
|
|
142
|
+
return float(covered_lines / total_lines) if total_lines > 0 else None
|
|
143
|
+
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.debug(f"Failed to read .coverage database: {e}")
|
|
146
|
+
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def _parse_htmlcov(self, index_path: Path) -> float | None:
|
|
150
|
+
"""Parse coverage percentage from htmlcov index.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
index_path: Path to htmlcov/index.html
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Coverage percentage as 0-1 or None
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
import re
|
|
160
|
+
|
|
161
|
+
content = index_path.read_text()
|
|
162
|
+
# Look for patterns like "85%" or "coverage: 85"
|
|
163
|
+
match = re.search(r"(\d+(?:\.\d+)?)\s*%", content)
|
|
164
|
+
if match:
|
|
165
|
+
return float(match.group(1)) / 100
|
|
166
|
+
except Exception as e:
|
|
167
|
+
logger.debug(f"Failed to parse htmlcov: {e}")
|
|
168
|
+
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
def _parse_coverage_xml(self, xml_path: Path) -> float | None:
|
|
172
|
+
"""Parse coverage from Cobertura XML format.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
xml_path: Path to coverage.xml
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Coverage percentage as 0-1 or None
|
|
179
|
+
"""
|
|
180
|
+
try:
|
|
181
|
+
import re
|
|
182
|
+
|
|
183
|
+
content = xml_path.read_text()
|
|
184
|
+
# Look for line-rate="0.85" attribute
|
|
185
|
+
match = re.search(r'line-rate="(\d+(?:\.\d+)?)"', content)
|
|
186
|
+
if match:
|
|
187
|
+
return float(match.group(1))
|
|
188
|
+
except Exception as e:
|
|
189
|
+
logger.debug(f"Failed to parse coverage.xml: {e}")
|
|
190
|
+
|
|
191
|
+
return None
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Data models for the health score feature."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MetricCategory(Enum):
|
|
11
|
+
"""Categories of health metrics."""
|
|
12
|
+
|
|
13
|
+
FRESHNESS = "freshness"
|
|
14
|
+
SECURITY = "security"
|
|
15
|
+
MIGRATION_READINESS = "migration_readiness"
|
|
16
|
+
TEST_COVERAGE = "test_coverage"
|
|
17
|
+
DOCUMENTATION = "documentation"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HealthGrade(Enum):
|
|
21
|
+
"""Letter grade for overall health score."""
|
|
22
|
+
|
|
23
|
+
A = "A" # 90-100
|
|
24
|
+
B = "B" # 80-89
|
|
25
|
+
C = "C" # 70-79
|
|
26
|
+
D = "D" # 60-69
|
|
27
|
+
F = "F" # Below 60
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_score(cls, score: float) -> "HealthGrade":
|
|
31
|
+
"""Convert a numeric score to a letter grade.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
score: Numeric score from 0-100
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Corresponding letter grade
|
|
38
|
+
"""
|
|
39
|
+
if score >= 90:
|
|
40
|
+
return cls.A
|
|
41
|
+
elif score >= 80:
|
|
42
|
+
return cls.B
|
|
43
|
+
elif score >= 70:
|
|
44
|
+
return cls.C
|
|
45
|
+
elif score >= 60:
|
|
46
|
+
return cls.D
|
|
47
|
+
else:
|
|
48
|
+
return cls.F
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def color(self) -> str:
|
|
52
|
+
"""Get the display color for this grade."""
|
|
53
|
+
colors = {
|
|
54
|
+
HealthGrade.A: "green",
|
|
55
|
+
HealthGrade.B: "cyan",
|
|
56
|
+
HealthGrade.C: "yellow",
|
|
57
|
+
HealthGrade.D: "orange1",
|
|
58
|
+
HealthGrade.F: "red",
|
|
59
|
+
}
|
|
60
|
+
return colors.get(self, "white")
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def emoji(self) -> str:
|
|
64
|
+
"""Get the emoji for this grade."""
|
|
65
|
+
emojis = {
|
|
66
|
+
HealthGrade.A: "🟢",
|
|
67
|
+
HealthGrade.B: "🔵",
|
|
68
|
+
HealthGrade.C: "🟡",
|
|
69
|
+
HealthGrade.D: "🟠",
|
|
70
|
+
HealthGrade.F: "🔴",
|
|
71
|
+
}
|
|
72
|
+
return emojis.get(self, "⚪")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class VulnerabilitySeverity(Enum):
|
|
76
|
+
"""Severity levels for security vulnerabilities."""
|
|
77
|
+
|
|
78
|
+
CRITICAL = "critical"
|
|
79
|
+
HIGH = "high"
|
|
80
|
+
MEDIUM = "medium"
|
|
81
|
+
LOW = "low"
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def penalty(self) -> int:
|
|
85
|
+
"""Get the score penalty for this severity level."""
|
|
86
|
+
penalties = {
|
|
87
|
+
VulnerabilitySeverity.CRITICAL: 25,
|
|
88
|
+
VulnerabilitySeverity.HIGH: 15,
|
|
89
|
+
VulnerabilitySeverity.MEDIUM: 8,
|
|
90
|
+
VulnerabilitySeverity.LOW: 3,
|
|
91
|
+
}
|
|
92
|
+
return penalties.get(self, 0)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class SecurityVulnerability:
|
|
97
|
+
"""Represents a security vulnerability in a dependency."""
|
|
98
|
+
|
|
99
|
+
package: str
|
|
100
|
+
vulnerability_id: str
|
|
101
|
+
severity: VulnerabilitySeverity
|
|
102
|
+
description: str
|
|
103
|
+
fixed_in: str | None = None
|
|
104
|
+
url: str | None = None
|
|
105
|
+
|
|
106
|
+
def to_dict(self) -> dict[str, Any]:
|
|
107
|
+
"""Convert to dictionary."""
|
|
108
|
+
return {
|
|
109
|
+
"package": self.package,
|
|
110
|
+
"vulnerability_id": self.vulnerability_id,
|
|
111
|
+
"severity": self.severity.value,
|
|
112
|
+
"description": self.description,
|
|
113
|
+
"fixed_in": self.fixed_in,
|
|
114
|
+
"url": self.url,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class DependencyHealth:
|
|
120
|
+
"""Health information for a single dependency."""
|
|
121
|
+
|
|
122
|
+
name: str
|
|
123
|
+
current_version: str | None
|
|
124
|
+
latest_version: str | None
|
|
125
|
+
is_outdated: bool
|
|
126
|
+
major_versions_behind: int = 0
|
|
127
|
+
minor_versions_behind: int = 0
|
|
128
|
+
has_tier1_support: bool = False
|
|
129
|
+
has_tier2_support: bool = False
|
|
130
|
+
vulnerabilities: list[SecurityVulnerability] = field(default_factory=list)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def version_lag_penalty(self) -> int:
|
|
134
|
+
"""Calculate the penalty for version lag."""
|
|
135
|
+
# Major version lag: -15 points each
|
|
136
|
+
# Minor version lag: -5 points each (max 3)
|
|
137
|
+
major_penalty = self.major_versions_behind * 15
|
|
138
|
+
minor_penalty = min(self.minor_versions_behind, 3) * 5
|
|
139
|
+
return major_penalty + minor_penalty
|
|
140
|
+
|
|
141
|
+
def to_dict(self) -> dict[str, Any]:
|
|
142
|
+
"""Convert to dictionary."""
|
|
143
|
+
return {
|
|
144
|
+
"name": self.name,
|
|
145
|
+
"current_version": self.current_version,
|
|
146
|
+
"latest_version": self.latest_version,
|
|
147
|
+
"is_outdated": self.is_outdated,
|
|
148
|
+
"major_versions_behind": self.major_versions_behind,
|
|
149
|
+
"minor_versions_behind": self.minor_versions_behind,
|
|
150
|
+
"has_tier1_support": self.has_tier1_support,
|
|
151
|
+
"has_tier2_support": self.has_tier2_support,
|
|
152
|
+
"vulnerabilities": [v.to_dict() for v in self.vulnerabilities],
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class MetricResult:
|
|
158
|
+
"""Result from a single metric calculation."""
|
|
159
|
+
|
|
160
|
+
category: MetricCategory
|
|
161
|
+
score: float # 0-100
|
|
162
|
+
weight: float # 0.0-1.0
|
|
163
|
+
description: str
|
|
164
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
165
|
+
recommendations: list[str] = field(default_factory=list)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def weighted_score(self) -> float:
|
|
169
|
+
"""Calculate the weighted score contribution."""
|
|
170
|
+
return self.score * self.weight
|
|
171
|
+
|
|
172
|
+
def to_dict(self) -> dict[str, Any]:
|
|
173
|
+
"""Convert to dictionary."""
|
|
174
|
+
return {
|
|
175
|
+
"category": self.category.value,
|
|
176
|
+
"score": self.score,
|
|
177
|
+
"weight": self.weight,
|
|
178
|
+
"weighted_score": self.weighted_score,
|
|
179
|
+
"description": self.description,
|
|
180
|
+
"details": self.details,
|
|
181
|
+
"recommendations": self.recommendations,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass
|
|
186
|
+
class HealthScore:
|
|
187
|
+
"""Complete health score for a project."""
|
|
188
|
+
|
|
189
|
+
overall_score: float # 0-100
|
|
190
|
+
grade: HealthGrade
|
|
191
|
+
metrics: list[MetricResult] = field(default_factory=list)
|
|
192
|
+
dependencies: list[DependencyHealth] = field(default_factory=list)
|
|
193
|
+
vulnerabilities: list[SecurityVulnerability] = field(default_factory=list)
|
|
194
|
+
calculated_at: datetime = field(default_factory=datetime.now)
|
|
195
|
+
project_path: Path = field(default_factory=lambda: Path("."))
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def summary(self) -> str:
|
|
199
|
+
"""Get a summary string of the health score."""
|
|
200
|
+
return f"{self.grade.emoji} Grade {self.grade.value} ({self.overall_score:.1f}/100)"
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def top_recommendations(self) -> list[str]:
|
|
204
|
+
"""Get the top 5 recommendations across all metrics."""
|
|
205
|
+
all_recs: list[tuple[float, str]] = []
|
|
206
|
+
for metric in self.metrics:
|
|
207
|
+
# Weight recommendations by how much improvement they could provide
|
|
208
|
+
improvement_potential = 100 - metric.score
|
|
209
|
+
for rec in metric.recommendations:
|
|
210
|
+
all_recs.append((improvement_potential * metric.weight, rec))
|
|
211
|
+
|
|
212
|
+
# Sort by improvement potential and return top 5
|
|
213
|
+
all_recs.sort(key=lambda x: x[0], reverse=True)
|
|
214
|
+
seen: set[str] = set()
|
|
215
|
+
unique_recs: list[str] = []
|
|
216
|
+
for _, rec in all_recs:
|
|
217
|
+
if rec not in seen:
|
|
218
|
+
seen.add(rec)
|
|
219
|
+
unique_recs.append(rec)
|
|
220
|
+
if len(unique_recs) >= 5:
|
|
221
|
+
break
|
|
222
|
+
return unique_recs
|
|
223
|
+
|
|
224
|
+
def to_dict(self) -> dict[str, Any]:
|
|
225
|
+
"""Convert to dictionary."""
|
|
226
|
+
return {
|
|
227
|
+
"overall_score": self.overall_score,
|
|
228
|
+
"grade": self.grade.value,
|
|
229
|
+
"metrics": [m.to_dict() for m in self.metrics],
|
|
230
|
+
"dependencies": [d.to_dict() for d in self.dependencies],
|
|
231
|
+
"vulnerabilities": [v.to_dict() for v in self.vulnerabilities],
|
|
232
|
+
"calculated_at": self.calculated_at.isoformat(),
|
|
233
|
+
"project_path": str(self.project_path),
|
|
234
|
+
"recommendations": self.top_recommendations,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass
|
|
239
|
+
class HealthReport:
|
|
240
|
+
"""Health report comparing current score to previous."""
|
|
241
|
+
|
|
242
|
+
current: HealthScore
|
|
243
|
+
previous: HealthScore | None = None
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def trend(self) -> str:
|
|
247
|
+
"""Get the trend direction."""
|
|
248
|
+
if self.previous is None:
|
|
249
|
+
return "new"
|
|
250
|
+
|
|
251
|
+
diff = self.current.overall_score - self.previous.overall_score
|
|
252
|
+
if diff > 2:
|
|
253
|
+
return "improving"
|
|
254
|
+
elif diff < -2:
|
|
255
|
+
return "declining"
|
|
256
|
+
else:
|
|
257
|
+
return "stable"
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def trend_emoji(self) -> str:
|
|
261
|
+
"""Get the trend emoji."""
|
|
262
|
+
emojis = {
|
|
263
|
+
"improving": "📈",
|
|
264
|
+
"declining": "📉",
|
|
265
|
+
"stable": "➡️",
|
|
266
|
+
"new": "🆕",
|
|
267
|
+
}
|
|
268
|
+
return emojis.get(self.trend, "")
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def score_delta(self) -> float | None:
|
|
272
|
+
"""Get the score change from previous."""
|
|
273
|
+
if self.previous is None:
|
|
274
|
+
return None
|
|
275
|
+
return self.current.overall_score - self.previous.overall_score
|
|
276
|
+
|
|
277
|
+
def to_dict(self) -> dict[str, Any]:
|
|
278
|
+
"""Convert to dictionary."""
|
|
279
|
+
return {
|
|
280
|
+
"current": self.current.to_dict(),
|
|
281
|
+
"previous": self.previous.to_dict() if self.previous else None,
|
|
282
|
+
"trend": self.trend,
|
|
283
|
+
"score_delta": self.score_delta,
|
|
284
|
+
}
|