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.
Files changed (32) hide show
  1. codeshift/__init__.py +1 -1
  2. codeshift/cli/commands/auth.py +41 -25
  3. codeshift/cli/commands/health.py +244 -0
  4. codeshift/cli/commands/upgrade.py +68 -55
  5. codeshift/cli/main.py +2 -0
  6. codeshift/health/__init__.py +50 -0
  7. codeshift/health/calculator.py +217 -0
  8. codeshift/health/metrics/__init__.py +63 -0
  9. codeshift/health/metrics/documentation.py +209 -0
  10. codeshift/health/metrics/freshness.py +180 -0
  11. codeshift/health/metrics/migration_readiness.py +142 -0
  12. codeshift/health/metrics/security.py +225 -0
  13. codeshift/health/metrics/test_coverage.py +191 -0
  14. codeshift/health/models.py +284 -0
  15. codeshift/health/report.py +310 -0
  16. codeshift/knowledge/generator.py +6 -0
  17. codeshift/knowledge_base/libraries/aiohttp.yaml +3 -3
  18. codeshift/knowledge_base/libraries/httpx.yaml +4 -4
  19. codeshift/knowledge_base/libraries/pytest.yaml +1 -1
  20. codeshift/knowledge_base/models.py +1 -0
  21. codeshift/migrator/transforms/marshmallow_transformer.py +50 -0
  22. codeshift/migrator/transforms/pydantic_v1_to_v2.py +191 -22
  23. codeshift/scanner/code_scanner.py +22 -2
  24. codeshift/utils/api_client.py +144 -4
  25. codeshift/utils/credential_store.py +393 -0
  26. codeshift/utils/llm_client.py +111 -9
  27. {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/METADATA +4 -1
  28. {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/RECORD +32 -20
  29. {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/WHEEL +0 -0
  30. {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/entry_points.txt +0 -0
  31. {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/licenses/LICENSE +0 -0
  32. {codeshift-0.4.0.dist-info → codeshift-0.7.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,142 @@
1
+ """Migration readiness metric calculator."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from codeshift.health.metrics import BaseMetricCalculator
8
+ from codeshift.health.models import DependencyHealth, MetricCategory, MetricResult
9
+ from codeshift.knowledge_base import KnowledgeBaseLoader
10
+ from codeshift.scanner.dependency_parser import DependencyParser
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class MigrationReadinessCalculator(BaseMetricCalculator):
16
+ """Calculates migration readiness score (20% weight).
17
+
18
+ Score based on Tier 1/2 support coverage:
19
+ - Tier 1 (deterministic AST): 100% score contribution
20
+ - Tier 2 (knowledge base + LLM): 50% score contribution
21
+ - No support: 0% score contribution
22
+ """
23
+
24
+ @property
25
+ def category(self) -> MetricCategory:
26
+ return MetricCategory.MIGRATION_READINESS
27
+
28
+ @property
29
+ def weight(self) -> float:
30
+ return 0.20
31
+
32
+ def calculate(
33
+ self,
34
+ project_path: Path,
35
+ dependencies: list[DependencyHealth] | None = None,
36
+ **kwargs: Any,
37
+ ) -> MetricResult:
38
+ """Calculate the migration readiness score.
39
+
40
+ Args:
41
+ project_path: Path to the project
42
+ dependencies: Pre-populated dependency health list (optional)
43
+
44
+ Returns:
45
+ MetricResult with migration readiness score
46
+ """
47
+ if dependencies is None:
48
+ dependencies = self._analyze_dependencies(project_path)
49
+
50
+ if not dependencies:
51
+ return self._create_result(
52
+ score=100,
53
+ description="No dependencies to analyze",
54
+ details={"dependency_count": 0},
55
+ recommendations=[],
56
+ )
57
+
58
+ tier1_count = sum(1 for d in dependencies if d.has_tier1_support)
59
+ tier2_count = sum(
60
+ 1 for d in dependencies if d.has_tier2_support and not d.has_tier1_support
61
+ )
62
+ no_support_count = len(dependencies) - tier1_count - tier2_count
63
+
64
+ total = len(dependencies)
65
+ # Score: Tier 1 gets full points, Tier 2 gets half points
66
+ score = ((tier1_count * 100) + (tier2_count * 50)) / total if total > 0 else 100
67
+
68
+ # Build recommendations
69
+ recommendations: list[str] = []
70
+
71
+ if no_support_count > 0:
72
+ unsupported = [
73
+ d.name for d in dependencies if not d.has_tier1_support and not d.has_tier2_support
74
+ ]
75
+ recommendations.append(
76
+ f"Consider requesting Tier 1 support for: {', '.join(unsupported[:3])}"
77
+ + (f" (+{len(unsupported) - 3} more)" if len(unsupported) > 3 else "")
78
+ )
79
+
80
+ if tier2_count > 0:
81
+ tier2_deps = [
82
+ d.name for d in dependencies if d.has_tier2_support and not d.has_tier1_support
83
+ ]
84
+ recommendations.append(
85
+ f"Libraries with Tier 2 (LLM) support: {', '.join(tier2_deps[:3])}"
86
+ )
87
+
88
+ return self._create_result(
89
+ score=score,
90
+ description=f"{tier1_count} Tier 1, {tier2_count} Tier 2, {no_support_count} unsupported",
91
+ details={
92
+ "total_dependencies": total,
93
+ "tier1_count": tier1_count,
94
+ "tier2_count": tier2_count,
95
+ "unsupported_count": no_support_count,
96
+ "tier1_ratio": tier1_count / total if total > 0 else 0,
97
+ "tier2_ratio": tier2_count / total if total > 0 else 0,
98
+ },
99
+ recommendations=recommendations,
100
+ )
101
+
102
+ def _analyze_dependencies(self, project_path: Path) -> list[DependencyHealth]:
103
+ """Analyze project dependencies for migration support.
104
+
105
+ Args:
106
+ project_path: Path to the project
107
+
108
+ Returns:
109
+ List of DependencyHealth objects with tier support info
110
+ """
111
+ parser = DependencyParser(project_path)
112
+ dependencies = parser.parse_all()
113
+
114
+ loader = KnowledgeBaseLoader()
115
+ supported_libraries = loader.get_supported_libraries()
116
+
117
+ results: list[DependencyHealth] = []
118
+
119
+ # Tier 1 supported libraries (have AST transformers)
120
+ tier1_libraries = {"pydantic", "fastapi", "sqlalchemy", "pandas", "requests"}
121
+
122
+ for dep in dependencies:
123
+ dep_name_lower = dep.name.lower()
124
+
125
+ # Check Tier 1 support (deterministic AST transforms)
126
+ has_tier1 = dep_name_lower in tier1_libraries
127
+
128
+ # Check Tier 2 support (knowledge base exists)
129
+ has_tier2 = dep_name_lower in [lib.lower() for lib in supported_libraries]
130
+
131
+ results.append(
132
+ DependencyHealth(
133
+ name=dep.name,
134
+ current_version=str(dep.min_version) if dep.min_version else None,
135
+ latest_version=None,
136
+ is_outdated=False,
137
+ has_tier1_support=has_tier1,
138
+ has_tier2_support=has_tier2,
139
+ )
140
+ )
141
+
142
+ return results
@@ -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