codeshift 0.5.0__py3-none-any.whl → 0.7.0__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.
@@ -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
+ }