codeshift 0.4.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.
- codeshift/__init__.py +1 -1
- codeshift/cli/commands/auth.py +41 -25
- codeshift/cli/commands/health.py +244 -0
- codeshift/cli/commands/upgrade.py +68 -55
- 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/knowledge/generator.py +6 -0
- codeshift/knowledge_base/libraries/aiohttp.yaml +3 -3
- codeshift/knowledge_base/libraries/httpx.yaml +4 -4
- codeshift/knowledge_base/libraries/pytest.yaml +1 -1
- codeshift/knowledge_base/models.py +1 -0
- codeshift/migrator/transforms/marshmallow_transformer.py +50 -0
- codeshift/migrator/transforms/pydantic_v1_to_v2.py +191 -22
- codeshift/scanner/code_scanner.py +22 -2
- codeshift/utils/api_client.py +144 -4
- codeshift/utils/credential_store.py +393 -0
- codeshift/utils/llm_client.py +111 -9
- {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/METADATA +4 -1
- {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/RECORD +32 -20
- {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/WHEEL +0 -0
- {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/entry_points.txt +0 -0
- {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/top_level.txt +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Report generation for health scores (JSON and HTML)."""
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from codeshift.health.models import HealthReport, HealthScore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def generate_json_report(report: HealthReport | HealthScore, pretty: bool = True) -> str:
|
|
13
|
+
"""Generate a JSON report.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
report: HealthReport or HealthScore to serialize
|
|
17
|
+
pretty: Whether to pretty-print the JSON
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
JSON string
|
|
21
|
+
"""
|
|
22
|
+
if isinstance(report, HealthScore):
|
|
23
|
+
data = report.to_dict()
|
|
24
|
+
else:
|
|
25
|
+
data = report.to_dict()
|
|
26
|
+
|
|
27
|
+
if pretty:
|
|
28
|
+
return json.dumps(data, indent=2, default=_json_serializer)
|
|
29
|
+
return json.dumps(data, default=_json_serializer)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def save_json_report(report: HealthReport | HealthScore, output_path: Path) -> None:
|
|
33
|
+
"""Save a JSON report to a file.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
report: HealthReport or HealthScore to serialize
|
|
37
|
+
output_path: Path to save the report
|
|
38
|
+
"""
|
|
39
|
+
json_content = generate_json_report(report)
|
|
40
|
+
output_path.write_text(json_content)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def generate_html_report(report: HealthReport | HealthScore) -> str:
|
|
44
|
+
"""Generate an HTML report.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
report: HealthReport or HealthScore to render
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
HTML string
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(report, HealthScore):
|
|
53
|
+
score = report
|
|
54
|
+
trend_info = ""
|
|
55
|
+
else:
|
|
56
|
+
score = report.current
|
|
57
|
+
if report.previous:
|
|
58
|
+
delta = report.score_delta or 0
|
|
59
|
+
sign = "+" if delta >= 0 else ""
|
|
60
|
+
trend_info = (
|
|
61
|
+
f'<span class="trend {report.trend}">{report.trend_emoji} {sign}{delta:.1f}</span>'
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
trend_info = '<span class="trend new">New baseline</span>'
|
|
65
|
+
|
|
66
|
+
# Build metrics rows
|
|
67
|
+
metrics_rows = ""
|
|
68
|
+
for metric in score.metrics:
|
|
69
|
+
grade_class = _get_score_class(metric.score)
|
|
70
|
+
metrics_rows += f"""
|
|
71
|
+
<tr>
|
|
72
|
+
<td>{_format_category(metric.category.value)}</td>
|
|
73
|
+
<td class="{grade_class}">{metric.score:.1f}</td>
|
|
74
|
+
<td>{metric.weight * 100:.0f}%</td>
|
|
75
|
+
<td>{html.escape(metric.description)}</td>
|
|
76
|
+
</tr>
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
# Build recommendations list
|
|
80
|
+
recs_html = ""
|
|
81
|
+
for rec in score.top_recommendations:
|
|
82
|
+
recs_html += f"<li>{html.escape(rec)}</li>\n"
|
|
83
|
+
|
|
84
|
+
# Build dependencies table
|
|
85
|
+
deps_rows = ""
|
|
86
|
+
for dep in score.dependencies:
|
|
87
|
+
status = "✓" if not dep.is_outdated else "↑"
|
|
88
|
+
status_class = "up-to-date" if not dep.is_outdated else "outdated"
|
|
89
|
+
tier = "Tier 1" if dep.has_tier1_support else ("Tier 2" if dep.has_tier2_support else "-")
|
|
90
|
+
vuln_count = len(dep.vulnerabilities)
|
|
91
|
+
vuln_class = "vuln-none" if vuln_count == 0 else "vuln-some"
|
|
92
|
+
|
|
93
|
+
deps_rows += f"""
|
|
94
|
+
<tr>
|
|
95
|
+
<td>{html.escape(dep.name)}</td>
|
|
96
|
+
<td>{html.escape(dep.current_version or 'unknown')}</td>
|
|
97
|
+
<td>{html.escape(dep.latest_version or 'unknown')}</td>
|
|
98
|
+
<td class="{status_class}">{status}</td>
|
|
99
|
+
<td>{tier}</td>
|
|
100
|
+
<td class="{vuln_class}">{vuln_count}</td>
|
|
101
|
+
</tr>
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
# Build vulnerabilities section
|
|
105
|
+
vulns_html = ""
|
|
106
|
+
if score.vulnerabilities:
|
|
107
|
+
vulns_rows = ""
|
|
108
|
+
for vuln in score.vulnerabilities:
|
|
109
|
+
vulns_rows += f"""
|
|
110
|
+
<tr class="severity-{vuln.severity.value}">
|
|
111
|
+
<td>{html.escape(vuln.package)}</td>
|
|
112
|
+
<td>{html.escape(vuln.vulnerability_id)}</td>
|
|
113
|
+
<td>{vuln.severity.value.upper()}</td>
|
|
114
|
+
<td>{html.escape(vuln.description[:100])}...</td>
|
|
115
|
+
<td>{html.escape(vuln.fixed_in or '-')}</td>
|
|
116
|
+
</tr>
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
vulns_html = f"""
|
|
120
|
+
<section class="vulnerabilities">
|
|
121
|
+
<h2>Security Vulnerabilities</h2>
|
|
122
|
+
<table>
|
|
123
|
+
<thead>
|
|
124
|
+
<tr>
|
|
125
|
+
<th>Package</th>
|
|
126
|
+
<th>ID</th>
|
|
127
|
+
<th>Severity</th>
|
|
128
|
+
<th>Description</th>
|
|
129
|
+
<th>Fixed In</th>
|
|
130
|
+
</tr>
|
|
131
|
+
</thead>
|
|
132
|
+
<tbody>
|
|
133
|
+
{vulns_rows}
|
|
134
|
+
</tbody>
|
|
135
|
+
</table>
|
|
136
|
+
</section>
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
grade_class = score.grade.value.lower()
|
|
140
|
+
|
|
141
|
+
return f"""<!DOCTYPE html>
|
|
142
|
+
<html lang="en">
|
|
143
|
+
<head>
|
|
144
|
+
<meta charset="UTF-8">
|
|
145
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
146
|
+
<title>Codeshift Health Report</title>
|
|
147
|
+
<style>
|
|
148
|
+
:root {{
|
|
149
|
+
--color-a: #22c55e;
|
|
150
|
+
--color-b: #06b6d4;
|
|
151
|
+
--color-c: #eab308;
|
|
152
|
+
--color-d: #f97316;
|
|
153
|
+
--color-f: #ef4444;
|
|
154
|
+
}}
|
|
155
|
+
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
156
|
+
body {{
|
|
157
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
158
|
+
line-height: 1.6;
|
|
159
|
+
color: #1f2937;
|
|
160
|
+
background: #f9fafb;
|
|
161
|
+
padding: 2rem;
|
|
162
|
+
}}
|
|
163
|
+
.container {{ max-width: 1200px; margin: 0 auto; }}
|
|
164
|
+
h1 {{ margin-bottom: 0.5rem; }}
|
|
165
|
+
h2 {{ margin: 2rem 0 1rem; border-bottom: 2px solid #e5e7eb; padding-bottom: 0.5rem; }}
|
|
166
|
+
.header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }}
|
|
167
|
+
.score-card {{
|
|
168
|
+
background: white;
|
|
169
|
+
border-radius: 12px;
|
|
170
|
+
padding: 2rem;
|
|
171
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
172
|
+
text-align: center;
|
|
173
|
+
}}
|
|
174
|
+
.grade {{ font-size: 4rem; font-weight: bold; }}
|
|
175
|
+
.grade.a {{ color: var(--color-a); }}
|
|
176
|
+
.grade.b {{ color: var(--color-b); }}
|
|
177
|
+
.grade.c {{ color: var(--color-c); }}
|
|
178
|
+
.grade.d {{ color: var(--color-d); }}
|
|
179
|
+
.grade.f {{ color: var(--color-f); }}
|
|
180
|
+
.overall-score {{ font-size: 1.5rem; color: #6b7280; }}
|
|
181
|
+
.trend {{ font-size: 1rem; margin-top: 0.5rem; display: block; }}
|
|
182
|
+
.trend.improving {{ color: var(--color-a); }}
|
|
183
|
+
.trend.declining {{ color: var(--color-f); }}
|
|
184
|
+
.trend.stable {{ color: #6b7280; }}
|
|
185
|
+
table {{ width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }}
|
|
186
|
+
th, td {{ padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid #e5e7eb; }}
|
|
187
|
+
th {{ background: #f3f4f6; font-weight: 600; }}
|
|
188
|
+
tr:last-child td {{ border-bottom: none; }}
|
|
189
|
+
.excellent {{ color: var(--color-a); font-weight: 600; }}
|
|
190
|
+
.good {{ color: var(--color-b); font-weight: 600; }}
|
|
191
|
+
.fair {{ color: var(--color-c); font-weight: 600; }}
|
|
192
|
+
.poor {{ color: var(--color-d); font-weight: 600; }}
|
|
193
|
+
.critical {{ color: var(--color-f); font-weight: 600; }}
|
|
194
|
+
.up-to-date {{ color: var(--color-a); }}
|
|
195
|
+
.outdated {{ color: var(--color-d); }}
|
|
196
|
+
.vuln-none {{ color: var(--color-a); }}
|
|
197
|
+
.vuln-some {{ color: var(--color-f); font-weight: 600; }}
|
|
198
|
+
.severity-critical td {{ background: #fef2f2; }}
|
|
199
|
+
.severity-high td {{ background: #fff7ed; }}
|
|
200
|
+
.recommendations {{ background: white; border-radius: 8px; padding: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }}
|
|
201
|
+
.recommendations ul {{ padding-left: 1.5rem; }}
|
|
202
|
+
.recommendations li {{ margin: 0.5rem 0; }}
|
|
203
|
+
.meta {{ color: #6b7280; font-size: 0.875rem; margin-top: 2rem; }}
|
|
204
|
+
</style>
|
|
205
|
+
</head>
|
|
206
|
+
<body>
|
|
207
|
+
<div class="container">
|
|
208
|
+
<div class="header">
|
|
209
|
+
<div>
|
|
210
|
+
<h1>Codeshift Health Report</h1>
|
|
211
|
+
<p>{html.escape(str(score.project_path))}</p>
|
|
212
|
+
</div>
|
|
213
|
+
<div class="score-card">
|
|
214
|
+
<div class="grade {grade_class}">{score.grade.value}</div>
|
|
215
|
+
<div class="overall-score">{score.overall_score:.1f}/100</div>
|
|
216
|
+
{trend_info}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<section>
|
|
221
|
+
<h2>Metrics Breakdown</h2>
|
|
222
|
+
<table>
|
|
223
|
+
<thead>
|
|
224
|
+
<tr>
|
|
225
|
+
<th>Category</th>
|
|
226
|
+
<th>Score</th>
|
|
227
|
+
<th>Weight</th>
|
|
228
|
+
<th>Details</th>
|
|
229
|
+
</tr>
|
|
230
|
+
</thead>
|
|
231
|
+
<tbody>
|
|
232
|
+
{metrics_rows}
|
|
233
|
+
</tbody>
|
|
234
|
+
</table>
|
|
235
|
+
</section>
|
|
236
|
+
|
|
237
|
+
<section class="recommendations">
|
|
238
|
+
<h2>Recommendations</h2>
|
|
239
|
+
<ul>
|
|
240
|
+
{recs_html if recs_html else "<li>No recommendations - your project is in great shape!</li>"}
|
|
241
|
+
</ul>
|
|
242
|
+
</section>
|
|
243
|
+
|
|
244
|
+
<section>
|
|
245
|
+
<h2>Dependencies ({len(score.dependencies)})</h2>
|
|
246
|
+
<table>
|
|
247
|
+
<thead>
|
|
248
|
+
<tr>
|
|
249
|
+
<th>Package</th>
|
|
250
|
+
<th>Current</th>
|
|
251
|
+
<th>Latest</th>
|
|
252
|
+
<th>Status</th>
|
|
253
|
+
<th>Migration Support</th>
|
|
254
|
+
<th>Vulnerabilities</th>
|
|
255
|
+
</tr>
|
|
256
|
+
</thead>
|
|
257
|
+
<tbody>
|
|
258
|
+
{deps_rows if deps_rows else "<tr><td colspan='6'>No dependencies found</td></tr>"}
|
|
259
|
+
</tbody>
|
|
260
|
+
</table>
|
|
261
|
+
</section>
|
|
262
|
+
|
|
263
|
+
{vulns_html}
|
|
264
|
+
|
|
265
|
+
<p class="meta">
|
|
266
|
+
Generated by Codeshift on {score.calculated_at.strftime('%Y-%m-%d %H:%M:%S')}
|
|
267
|
+
</p>
|
|
268
|
+
</div>
|
|
269
|
+
</body>
|
|
270
|
+
</html>
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def save_html_report(report: HealthReport | HealthScore, output_path: Path) -> None:
|
|
275
|
+
"""Save an HTML report to a file.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
report: HealthReport or HealthScore to render
|
|
279
|
+
output_path: Path to save the report
|
|
280
|
+
"""
|
|
281
|
+
html_content = generate_html_report(report)
|
|
282
|
+
output_path.write_text(html_content)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _json_serializer(obj: Any) -> Any:
|
|
286
|
+
"""Custom JSON serializer for non-standard types."""
|
|
287
|
+
if isinstance(obj, datetime):
|
|
288
|
+
return obj.isoformat()
|
|
289
|
+
if isinstance(obj, Path):
|
|
290
|
+
return str(obj)
|
|
291
|
+
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _get_score_class(score: float) -> str:
|
|
295
|
+
"""Get CSS class based on score."""
|
|
296
|
+
if score >= 90:
|
|
297
|
+
return "excellent"
|
|
298
|
+
elif score >= 80:
|
|
299
|
+
return "good"
|
|
300
|
+
elif score >= 70:
|
|
301
|
+
return "fair"
|
|
302
|
+
elif score >= 60:
|
|
303
|
+
return "poor"
|
|
304
|
+
else:
|
|
305
|
+
return "critical"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _format_category(category: str) -> str:
|
|
309
|
+
"""Format category name for display."""
|
|
310
|
+
return category.replace("_", " ").title()
|
codeshift/knowledge/generator.py
CHANGED
|
@@ -69,7 +69,7 @@ breaking_changes:
|
|
|
69
69
|
|
|
70
70
|
# connector_owner default change
|
|
71
71
|
- symbol: "ClientSession(connector=..., connector_owner=None)"
|
|
72
|
-
change_type:
|
|
72
|
+
change_type: behavior_changed
|
|
73
73
|
severity: medium
|
|
74
74
|
from_version: "3.7"
|
|
75
75
|
to_version: "3.9"
|
|
@@ -133,7 +133,7 @@ breaking_changes:
|
|
|
133
133
|
|
|
134
134
|
# Middleware signature changes (old-style to new-style)
|
|
135
135
|
- symbol: "@middleware"
|
|
136
|
-
change_type:
|
|
136
|
+
change_type: signature_changed
|
|
137
137
|
severity: high
|
|
138
138
|
from_version: "3.7"
|
|
139
139
|
to_version: "3.9"
|
|
@@ -154,7 +154,7 @@ breaking_changes:
|
|
|
154
154
|
transform_name: ws_connect_timeout_rename
|
|
155
155
|
|
|
156
156
|
- symbol: "ws_connect(receive_timeout=...)"
|
|
157
|
-
change_type:
|
|
157
|
+
change_type: behavior_changed
|
|
158
158
|
severity: low
|
|
159
159
|
from_version: "3.7"
|
|
160
160
|
to_version: "3.9"
|
|
@@ -119,7 +119,7 @@ breaking_changes:
|
|
|
119
119
|
|
|
120
120
|
# Response.iter_lines() behavior change
|
|
121
121
|
- symbol: "Response.iter_lines()"
|
|
122
|
-
change_type:
|
|
122
|
+
change_type: behavior_changed
|
|
123
123
|
severity: medium
|
|
124
124
|
from_version: "0.23"
|
|
125
125
|
to_version: "0.24"
|
|
@@ -130,7 +130,7 @@ breaking_changes:
|
|
|
130
130
|
|
|
131
131
|
# NetRC authentication change
|
|
132
132
|
- symbol: "trust_env=True"
|
|
133
|
-
change_type:
|
|
133
|
+
change_type: behavior_changed
|
|
134
134
|
severity: medium
|
|
135
135
|
from_version: "0.23"
|
|
136
136
|
to_version: "0.24"
|
|
@@ -141,7 +141,7 @@ breaking_changes:
|
|
|
141
141
|
|
|
142
142
|
# Query parameter encoding change
|
|
143
143
|
- symbol: "params encoding"
|
|
144
|
-
change_type:
|
|
144
|
+
change_type: behavior_changed
|
|
145
145
|
severity: low
|
|
146
146
|
from_version: "0.23"
|
|
147
147
|
to_version: "0.24"
|
|
@@ -173,7 +173,7 @@ breaking_changes:
|
|
|
173
173
|
|
|
174
174
|
# Follow redirects default change
|
|
175
175
|
- symbol: "follow_redirects"
|
|
176
|
-
change_type:
|
|
176
|
+
change_type: behavior_changed
|
|
177
177
|
severity: high
|
|
178
178
|
from_version: "0.19"
|
|
179
179
|
to_version: "0.20"
|