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,217 @@
|
|
|
1
|
+
"""Main health score calculator orchestrator."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from codeshift.health.metrics import BaseMetricCalculator
|
|
7
|
+
from codeshift.health.metrics.documentation import DocumentationCalculator
|
|
8
|
+
from codeshift.health.metrics.freshness import FreshnessCalculator
|
|
9
|
+
from codeshift.health.metrics.migration_readiness import MigrationReadinessCalculator
|
|
10
|
+
from codeshift.health.metrics.security import SecurityCalculator
|
|
11
|
+
from codeshift.health.metrics.test_coverage import TestCoverageCalculator
|
|
12
|
+
from codeshift.health.models import (
|
|
13
|
+
DependencyHealth,
|
|
14
|
+
HealthGrade,
|
|
15
|
+
HealthReport,
|
|
16
|
+
HealthScore,
|
|
17
|
+
MetricResult,
|
|
18
|
+
SecurityVulnerability,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HealthCalculator:
|
|
25
|
+
"""Orchestrates health score calculation across all metrics."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
"""Initialize the calculator with all metric calculators."""
|
|
29
|
+
self.calculators: list[BaseMetricCalculator] = [
|
|
30
|
+
FreshnessCalculator(),
|
|
31
|
+
SecurityCalculator(),
|
|
32
|
+
MigrationReadinessCalculator(),
|
|
33
|
+
TestCoverageCalculator(),
|
|
34
|
+
DocumentationCalculator(),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
def calculate(self, project_path: Path) -> HealthScore:
|
|
38
|
+
"""Calculate the complete health score for a project.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
project_path: Path to the project root
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
HealthScore with all metrics and overall score
|
|
45
|
+
"""
|
|
46
|
+
project_path = project_path.resolve()
|
|
47
|
+
|
|
48
|
+
# First, analyze dependencies once to share across calculators
|
|
49
|
+
dependencies = self._analyze_dependencies(project_path)
|
|
50
|
+
|
|
51
|
+
# Calculate each metric
|
|
52
|
+
metrics: list[MetricResult] = []
|
|
53
|
+
for calculator in self.calculators:
|
|
54
|
+
try:
|
|
55
|
+
result = calculator.calculate(
|
|
56
|
+
project_path,
|
|
57
|
+
dependencies=dependencies,
|
|
58
|
+
)
|
|
59
|
+
metrics.append(result)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
logger.warning(f"Failed to calculate {calculator.category.value}: {e}")
|
|
62
|
+
# Add a neutral result on failure
|
|
63
|
+
metrics.append(
|
|
64
|
+
MetricResult(
|
|
65
|
+
category=calculator.category,
|
|
66
|
+
score=50,
|
|
67
|
+
weight=calculator.weight,
|
|
68
|
+
description=f"Error: {str(e)[:50]}",
|
|
69
|
+
details={"error": str(e)},
|
|
70
|
+
recommendations=["Fix metric calculation error"],
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Calculate overall weighted score
|
|
75
|
+
total_weight = sum(m.weight for m in metrics)
|
|
76
|
+
if total_weight > 0:
|
|
77
|
+
overall_score = sum(m.weighted_score for m in metrics) / total_weight
|
|
78
|
+
else:
|
|
79
|
+
overall_score = 0
|
|
80
|
+
|
|
81
|
+
# Collect all vulnerabilities
|
|
82
|
+
all_vulns: list[SecurityVulnerability] = []
|
|
83
|
+
for dep in dependencies:
|
|
84
|
+
all_vulns.extend(dep.vulnerabilities)
|
|
85
|
+
|
|
86
|
+
return HealthScore(
|
|
87
|
+
overall_score=overall_score,
|
|
88
|
+
grade=HealthGrade.from_score(overall_score),
|
|
89
|
+
metrics=metrics,
|
|
90
|
+
dependencies=dependencies,
|
|
91
|
+
vulnerabilities=all_vulns,
|
|
92
|
+
project_path=project_path,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def calculate_report(
|
|
96
|
+
self,
|
|
97
|
+
project_path: Path,
|
|
98
|
+
previous: HealthScore | None = None,
|
|
99
|
+
) -> HealthReport:
|
|
100
|
+
"""Calculate a health report with trend information.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
project_path: Path to the project root
|
|
104
|
+
previous: Optional previous health score for comparison
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
HealthReport with current score and trend
|
|
108
|
+
"""
|
|
109
|
+
current = self.calculate(project_path)
|
|
110
|
+
return HealthReport(current=current, previous=previous)
|
|
111
|
+
|
|
112
|
+
def _analyze_dependencies(self, project_path: Path) -> list[DependencyHealth]:
|
|
113
|
+
"""Analyze all dependencies for shared data.
|
|
114
|
+
|
|
115
|
+
This method runs once and provides data for multiple calculators
|
|
116
|
+
to avoid redundant API calls.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
project_path: Path to the project
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
List of DependencyHealth with all analyzable data
|
|
123
|
+
"""
|
|
124
|
+
from codeshift.scanner.dependency_parser import DependencyParser
|
|
125
|
+
|
|
126
|
+
parser = DependencyParser(project_path)
|
|
127
|
+
raw_deps = parser.parse_all()
|
|
128
|
+
|
|
129
|
+
# Get knowledge base info for tier support
|
|
130
|
+
from codeshift.knowledge_base import KnowledgeBaseLoader
|
|
131
|
+
|
|
132
|
+
loader = KnowledgeBaseLoader()
|
|
133
|
+
supported_libraries = loader.get_supported_libraries()
|
|
134
|
+
tier1_libraries = {"pydantic", "fastapi", "sqlalchemy", "pandas", "requests"}
|
|
135
|
+
|
|
136
|
+
dependencies: list[DependencyHealth] = []
|
|
137
|
+
|
|
138
|
+
for dep in raw_deps:
|
|
139
|
+
dep_name_lower = dep.name.lower()
|
|
140
|
+
|
|
141
|
+
# Get latest version and vulnerabilities from PyPI
|
|
142
|
+
latest_version = None
|
|
143
|
+
vulnerabilities: list[SecurityVulnerability] = []
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
import httpx
|
|
147
|
+
from packaging.version import Version
|
|
148
|
+
|
|
149
|
+
response = httpx.get(
|
|
150
|
+
f"https://pypi.org/pypi/{dep.name}/json",
|
|
151
|
+
timeout=5.0,
|
|
152
|
+
)
|
|
153
|
+
if response.status_code == 200:
|
|
154
|
+
data = response.json()
|
|
155
|
+
|
|
156
|
+
# Get latest version
|
|
157
|
+
version_str = data.get("info", {}).get("version")
|
|
158
|
+
if version_str:
|
|
159
|
+
latest_version = Version(version_str)
|
|
160
|
+
|
|
161
|
+
# Get vulnerabilities
|
|
162
|
+
from codeshift.health.models import VulnerabilitySeverity
|
|
163
|
+
|
|
164
|
+
for vuln_data in data.get("vulnerabilities", []):
|
|
165
|
+
try:
|
|
166
|
+
severity = VulnerabilitySeverity.MEDIUM
|
|
167
|
+
vulnerabilities.append(
|
|
168
|
+
SecurityVulnerability(
|
|
169
|
+
package=dep.name,
|
|
170
|
+
vulnerability_id=vuln_data.get("id", "unknown"),
|
|
171
|
+
severity=severity,
|
|
172
|
+
description=vuln_data.get("summary", "")[:200],
|
|
173
|
+
fixed_in=(
|
|
174
|
+
vuln_data.get("fixed_in", [None])[0]
|
|
175
|
+
if vuln_data.get("fixed_in")
|
|
176
|
+
else None
|
|
177
|
+
),
|
|
178
|
+
url=vuln_data.get("link"),
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
except Exception as e:
|
|
185
|
+
logger.debug(f"Failed to fetch PyPI data for {dep.name}: {e}")
|
|
186
|
+
|
|
187
|
+
# Calculate version lag
|
|
188
|
+
current = dep.min_version
|
|
189
|
+
is_outdated = False
|
|
190
|
+
major_behind = 0
|
|
191
|
+
minor_behind = 0
|
|
192
|
+
|
|
193
|
+
if current and latest_version:
|
|
194
|
+
is_outdated = current < latest_version
|
|
195
|
+
major_behind = max(0, latest_version.major - current.major)
|
|
196
|
+
if major_behind == 0:
|
|
197
|
+
minor_behind = max(0, latest_version.minor - current.minor)
|
|
198
|
+
|
|
199
|
+
# Check tier support
|
|
200
|
+
has_tier1 = dep_name_lower in tier1_libraries
|
|
201
|
+
has_tier2 = dep_name_lower in [lib.lower() for lib in supported_libraries]
|
|
202
|
+
|
|
203
|
+
dependencies.append(
|
|
204
|
+
DependencyHealth(
|
|
205
|
+
name=dep.name,
|
|
206
|
+
current_version=str(current) if current else None,
|
|
207
|
+
latest_version=str(latest_version) if latest_version else None,
|
|
208
|
+
is_outdated=is_outdated,
|
|
209
|
+
major_versions_behind=major_behind,
|
|
210
|
+
minor_versions_behind=minor_behind,
|
|
211
|
+
has_tier1_support=has_tier1,
|
|
212
|
+
has_tier2_support=has_tier2,
|
|
213
|
+
vulnerabilities=vulnerabilities,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
return dependencies
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Base class and utilities for health metric calculators."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from codeshift.health.models import MetricCategory, MetricResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseMetricCalculator(ABC):
|
|
11
|
+
"""Abstract base class for health metric calculators."""
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def category(self) -> MetricCategory:
|
|
16
|
+
"""Return the metric category."""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def weight(self) -> float:
|
|
22
|
+
"""Return the weight for this metric (0.0 to 1.0)."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def calculate(self, project_path: Path, **kwargs: Any) -> MetricResult:
|
|
27
|
+
"""Calculate the metric score.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
project_path: Path to the project root
|
|
31
|
+
**kwargs: Additional arguments specific to the metric
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
MetricResult with score and details
|
|
35
|
+
"""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def _create_result(
|
|
39
|
+
self,
|
|
40
|
+
score: float,
|
|
41
|
+
description: str,
|
|
42
|
+
details: dict | None = None,
|
|
43
|
+
recommendations: list[str] | None = None,
|
|
44
|
+
) -> MetricResult:
|
|
45
|
+
"""Helper to create a MetricResult.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
score: Score from 0-100
|
|
49
|
+
description: Human-readable description
|
|
50
|
+
details: Optional details dictionary
|
|
51
|
+
recommendations: Optional list of recommendations
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
MetricResult instance
|
|
55
|
+
"""
|
|
56
|
+
return MetricResult(
|
|
57
|
+
category=self.category,
|
|
58
|
+
score=max(0, min(100, score)), # Clamp to 0-100
|
|
59
|
+
weight=self.weight,
|
|
60
|
+
description=description,
|
|
61
|
+
details=details or {},
|
|
62
|
+
recommendations=recommendations or [],
|
|
63
|
+
)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Documentation quality metric calculator."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import libcst as cst
|
|
8
|
+
|
|
9
|
+
from codeshift.health.metrics import BaseMetricCalculator
|
|
10
|
+
from codeshift.health.models import MetricCategory, MetricResult
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DocumentationCalculator(BaseMetricCalculator):
|
|
16
|
+
"""Calculates documentation score (10% weight).
|
|
17
|
+
|
|
18
|
+
Score based on:
|
|
19
|
+
- Type hints coverage: 70% of score
|
|
20
|
+
- Docstring coverage: 30% of score
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def category(self) -> MetricCategory:
|
|
25
|
+
return MetricCategory.DOCUMENTATION
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def weight(self) -> float:
|
|
29
|
+
return 0.10
|
|
30
|
+
|
|
31
|
+
def calculate(self, project_path: Path, **kwargs: Any) -> MetricResult:
|
|
32
|
+
"""Calculate the documentation score.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
project_path: Path to the project
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
MetricResult with documentation score
|
|
39
|
+
"""
|
|
40
|
+
# Find all Python files
|
|
41
|
+
python_files = list(project_path.rglob("*.py"))
|
|
42
|
+
|
|
43
|
+
# Exclude common non-source directories
|
|
44
|
+
excluded_patterns = [
|
|
45
|
+
".venv",
|
|
46
|
+
"venv",
|
|
47
|
+
".git",
|
|
48
|
+
"__pycache__",
|
|
49
|
+
".tox",
|
|
50
|
+
".eggs",
|
|
51
|
+
"build",
|
|
52
|
+
"dist",
|
|
53
|
+
".mypy_cache",
|
|
54
|
+
".pytest_cache",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
python_files = [
|
|
58
|
+
f for f in python_files if not any(pattern in str(f) for pattern in excluded_patterns)
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
if not python_files:
|
|
62
|
+
return self._create_result(
|
|
63
|
+
score=100,
|
|
64
|
+
description="No Python files to analyze",
|
|
65
|
+
details={"file_count": 0},
|
|
66
|
+
recommendations=[],
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Analyze files
|
|
70
|
+
total_functions = 0
|
|
71
|
+
typed_functions = 0
|
|
72
|
+
documented_functions = 0
|
|
73
|
+
|
|
74
|
+
for file_path in python_files:
|
|
75
|
+
try:
|
|
76
|
+
source = file_path.read_text()
|
|
77
|
+
tree = cst.parse_module(source)
|
|
78
|
+
stats = self._analyze_file(tree)
|
|
79
|
+
|
|
80
|
+
total_functions += stats["total"]
|
|
81
|
+
typed_functions += stats["typed"]
|
|
82
|
+
documented_functions += stats["documented"]
|
|
83
|
+
except Exception as e:
|
|
84
|
+
logger.debug(f"Failed to analyze {file_path}: {e}")
|
|
85
|
+
|
|
86
|
+
if total_functions == 0:
|
|
87
|
+
return self._create_result(
|
|
88
|
+
score=100,
|
|
89
|
+
description="No functions found to analyze",
|
|
90
|
+
details={"file_count": len(python_files), "function_count": 0},
|
|
91
|
+
recommendations=[],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
typed_ratio = typed_functions / total_functions
|
|
95
|
+
documented_ratio = documented_functions / total_functions
|
|
96
|
+
|
|
97
|
+
# Score = (typed_ratio * 70) + (documented_ratio * 30)
|
|
98
|
+
score = (typed_ratio * 70) + (documented_ratio * 30)
|
|
99
|
+
|
|
100
|
+
recommendations: list[str] = []
|
|
101
|
+
if typed_ratio < 0.5:
|
|
102
|
+
recommendations.append(
|
|
103
|
+
f"Add type hints to functions ({typed_functions}/{total_functions} typed)"
|
|
104
|
+
)
|
|
105
|
+
if documented_ratio < 0.3:
|
|
106
|
+
recommendations.append(
|
|
107
|
+
f"Add docstrings to functions ({documented_functions}/{total_functions} documented)"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return self._create_result(
|
|
111
|
+
score=score,
|
|
112
|
+
description=f"{typed_ratio:.0%} typed, {documented_ratio:.0%} documented",
|
|
113
|
+
details={
|
|
114
|
+
"file_count": len(python_files),
|
|
115
|
+
"function_count": total_functions,
|
|
116
|
+
"typed_count": typed_functions,
|
|
117
|
+
"documented_count": documented_functions,
|
|
118
|
+
"typed_ratio": typed_ratio,
|
|
119
|
+
"documented_ratio": documented_ratio,
|
|
120
|
+
},
|
|
121
|
+
recommendations=recommendations,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _analyze_file(self, tree: cst.Module) -> dict:
|
|
125
|
+
"""Analyze a file for type hints and docstrings.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
tree: Parsed CST module
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Dict with total, typed, and documented counts
|
|
132
|
+
"""
|
|
133
|
+
visitor = FunctionAnalyzer()
|
|
134
|
+
# Use MetadataWrapper to walk the tree with the visitor
|
|
135
|
+
wrapper = cst.MetadataWrapper(tree)
|
|
136
|
+
wrapper.visit(visitor)
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
"total": visitor.total_functions,
|
|
140
|
+
"typed": visitor.typed_functions,
|
|
141
|
+
"documented": visitor.documented_functions,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class FunctionAnalyzer(cst.CSTVisitor):
|
|
146
|
+
"""CST visitor to analyze functions for type hints and docstrings."""
|
|
147
|
+
|
|
148
|
+
def __init__(self) -> None:
|
|
149
|
+
self.total_functions = 0
|
|
150
|
+
self.typed_functions = 0
|
|
151
|
+
self.documented_functions = 0
|
|
152
|
+
|
|
153
|
+
def visit_FunctionDef(self, node: cst.FunctionDef) -> bool:
|
|
154
|
+
self.total_functions += 1
|
|
155
|
+
|
|
156
|
+
# Check for type hints
|
|
157
|
+
if self._has_type_hints(node):
|
|
158
|
+
self.typed_functions += 1
|
|
159
|
+
|
|
160
|
+
# Check for docstring
|
|
161
|
+
if self._has_docstring(node):
|
|
162
|
+
self.documented_functions += 1
|
|
163
|
+
|
|
164
|
+
return True # Continue visiting nested functions
|
|
165
|
+
|
|
166
|
+
def _has_type_hints(self, node: cst.FunctionDef) -> bool:
|
|
167
|
+
"""Check if a function has type hints.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
node: Function definition node
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
True if function has return type or any parameter types
|
|
174
|
+
"""
|
|
175
|
+
# Check return type
|
|
176
|
+
if node.returns is not None:
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
# Check parameter types
|
|
180
|
+
for param in node.params.params:
|
|
181
|
+
if param.annotation is not None:
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
def _has_docstring(self, node: cst.FunctionDef) -> bool:
|
|
187
|
+
"""Check if a function has a docstring.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
node: Function definition node
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
True if function has a docstring
|
|
194
|
+
"""
|
|
195
|
+
if not node.body.body:
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
first_stmt = node.body.body[0]
|
|
199
|
+
|
|
200
|
+
# Check if first statement is an expression statement with a string
|
|
201
|
+
if isinstance(first_stmt, cst.SimpleStatementLine):
|
|
202
|
+
if first_stmt.body and isinstance(first_stmt.body[0], cst.Expr):
|
|
203
|
+
expr = first_stmt.body[0].value
|
|
204
|
+
if isinstance(expr, (cst.SimpleString, cst.ConcatenatedString)):
|
|
205
|
+
return True
|
|
206
|
+
if isinstance(expr, cst.FormattedString):
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
return False
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Dependency freshness metric calculator."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from packaging.version import Version
|
|
9
|
+
|
|
10
|
+
from codeshift.health.metrics import BaseMetricCalculator
|
|
11
|
+
from codeshift.health.models import DependencyHealth, MetricCategory, MetricResult
|
|
12
|
+
from codeshift.scanner.dependency_parser import DependencyParser
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# PyPI API timeout
|
|
17
|
+
PYPI_TIMEOUT = 5.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FreshnessCalculator(BaseMetricCalculator):
|
|
21
|
+
"""Calculates dependency freshness score (30% weight).
|
|
22
|
+
|
|
23
|
+
Score is based on how up-to-date dependencies are:
|
|
24
|
+
- Major version behind: -15 points per dependency
|
|
25
|
+
- Minor version behind: -5 points each (up to 3 per dependency)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def category(self) -> MetricCategory:
|
|
30
|
+
return MetricCategory.FRESHNESS
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def weight(self) -> float:
|
|
34
|
+
return 0.30
|
|
35
|
+
|
|
36
|
+
def calculate(
|
|
37
|
+
self,
|
|
38
|
+
project_path: Path,
|
|
39
|
+
dependencies: list[DependencyHealth] | None = None,
|
|
40
|
+
**kwargs: Any,
|
|
41
|
+
) -> MetricResult:
|
|
42
|
+
"""Calculate the freshness score.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
project_path: Path to the project
|
|
46
|
+
dependencies: Pre-populated dependency health list (optional)
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
MetricResult with freshness score
|
|
50
|
+
"""
|
|
51
|
+
if dependencies is None:
|
|
52
|
+
dependencies = self._analyze_dependencies(project_path)
|
|
53
|
+
|
|
54
|
+
if not dependencies:
|
|
55
|
+
return self._create_result(
|
|
56
|
+
score=100,
|
|
57
|
+
description="No dependencies to analyze",
|
|
58
|
+
details={"dependency_count": 0},
|
|
59
|
+
recommendations=[],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Calculate penalty
|
|
63
|
+
total_penalty = 0
|
|
64
|
+
outdated_deps: list[str] = []
|
|
65
|
+
major_outdated: list[str] = []
|
|
66
|
+
|
|
67
|
+
for dep in dependencies:
|
|
68
|
+
if dep.is_outdated:
|
|
69
|
+
outdated_deps.append(dep.name)
|
|
70
|
+
penalty = dep.version_lag_penalty
|
|
71
|
+
total_penalty += penalty
|
|
72
|
+
|
|
73
|
+
if dep.major_versions_behind > 0:
|
|
74
|
+
major_outdated.append(
|
|
75
|
+
f"{dep.name} ({dep.current_version} -> {dep.latest_version})"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Score starts at 100, subtract penalties (min 0)
|
|
79
|
+
score = max(0, 100 - total_penalty)
|
|
80
|
+
|
|
81
|
+
# Build recommendations
|
|
82
|
+
recommendations: list[str] = []
|
|
83
|
+
if major_outdated:
|
|
84
|
+
recommendations.append(
|
|
85
|
+
f"Update major versions: {', '.join(major_outdated[:3])}"
|
|
86
|
+
+ (f" (+{len(major_outdated) - 3} more)" if len(major_outdated) > 3 else "")
|
|
87
|
+
)
|
|
88
|
+
if len(outdated_deps) > len(major_outdated):
|
|
89
|
+
minor_count = len(outdated_deps) - len(major_outdated)
|
|
90
|
+
recommendations.append(f"Update {minor_count} dependencies with minor version updates")
|
|
91
|
+
|
|
92
|
+
return self._create_result(
|
|
93
|
+
score=score,
|
|
94
|
+
description=f"{len(outdated_deps)}/{len(dependencies)} dependencies outdated",
|
|
95
|
+
details={
|
|
96
|
+
"total_dependencies": len(dependencies),
|
|
97
|
+
"outdated_count": len(outdated_deps),
|
|
98
|
+
"major_outdated_count": len(major_outdated),
|
|
99
|
+
"total_penalty": total_penalty,
|
|
100
|
+
},
|
|
101
|
+
recommendations=recommendations,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def _analyze_dependencies(self, project_path: Path) -> list[DependencyHealth]:
|
|
105
|
+
"""Analyze project dependencies for freshness.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
project_path: Path to the project
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
List of DependencyHealth objects
|
|
112
|
+
"""
|
|
113
|
+
parser = DependencyParser(project_path)
|
|
114
|
+
dependencies = parser.parse_all()
|
|
115
|
+
|
|
116
|
+
results: list[DependencyHealth] = []
|
|
117
|
+
|
|
118
|
+
for dep in dependencies:
|
|
119
|
+
try:
|
|
120
|
+
latest = self._get_latest_version(dep.name)
|
|
121
|
+
current = dep.min_version
|
|
122
|
+
|
|
123
|
+
if current and latest:
|
|
124
|
+
is_outdated = current < latest
|
|
125
|
+
major_behind = max(0, latest.major - current.major)
|
|
126
|
+
minor_behind = 0
|
|
127
|
+
if major_behind == 0:
|
|
128
|
+
minor_behind = max(0, latest.minor - current.minor)
|
|
129
|
+
else:
|
|
130
|
+
is_outdated = False
|
|
131
|
+
major_behind = 0
|
|
132
|
+
minor_behind = 0
|
|
133
|
+
|
|
134
|
+
results.append(
|
|
135
|
+
DependencyHealth(
|
|
136
|
+
name=dep.name,
|
|
137
|
+
current_version=str(current) if current else None,
|
|
138
|
+
latest_version=str(latest) if latest else None,
|
|
139
|
+
is_outdated=is_outdated,
|
|
140
|
+
major_versions_behind=major_behind,
|
|
141
|
+
minor_versions_behind=minor_behind,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.debug(f"Error analyzing {dep.name}: {e}")
|
|
146
|
+
# Add with unknown status
|
|
147
|
+
results.append(
|
|
148
|
+
DependencyHealth(
|
|
149
|
+
name=dep.name,
|
|
150
|
+
current_version=str(dep.min_version) if dep.min_version else None,
|
|
151
|
+
latest_version=None,
|
|
152
|
+
is_outdated=False,
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return results
|
|
157
|
+
|
|
158
|
+
def _get_latest_version(self, package_name: str) -> Version | None:
|
|
159
|
+
"""Get the latest version of a package from PyPI.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
package_name: Name of the package
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Latest Version or None if not found
|
|
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
|
+
version_str = data.get("info", {}).get("version")
|
|
175
|
+
if version_str:
|
|
176
|
+
return Version(version_str)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.debug(f"Failed to get latest version for {package_name}: {e}")
|
|
179
|
+
|
|
180
|
+
return None
|