python-checkup 0.0.1__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.
- python_checkup/__init__.py +9 -0
- python_checkup/__main__.py +3 -0
- python_checkup/analysis_request.py +35 -0
- python_checkup/analyzer_catalog.py +100 -0
- python_checkup/analyzers/__init__.py +54 -0
- python_checkup/analyzers/bandit.py +158 -0
- python_checkup/analyzers/basedpyright.py +103 -0
- python_checkup/analyzers/cached.py +106 -0
- python_checkup/analyzers/dependency_vulns.py +298 -0
- python_checkup/analyzers/deptry.py +142 -0
- python_checkup/analyzers/detect_secrets.py +101 -0
- python_checkup/analyzers/mypy.py +217 -0
- python_checkup/analyzers/radon.py +150 -0
- python_checkup/analyzers/registry.py +69 -0
- python_checkup/analyzers/ruff.py +256 -0
- python_checkup/analyzers/typos.py +80 -0
- python_checkup/analyzers/vulture.py +151 -0
- python_checkup/cache.py +244 -0
- python_checkup/cli.py +763 -0
- python_checkup/config.py +87 -0
- python_checkup/dedup.py +119 -0
- python_checkup/dependencies/discovery.py +192 -0
- python_checkup/detection.py +298 -0
- python_checkup/diff.py +130 -0
- python_checkup/discovery.py +180 -0
- python_checkup/formatters/__init__.py +0 -0
- python_checkup/formatters/badge.py +38 -0
- python_checkup/formatters/json_fmt.py +22 -0
- python_checkup/formatters/terminal.py +396 -0
- python_checkup/mcp/__init__.py +3 -0
- python_checkup/mcp/installer.py +119 -0
- python_checkup/mcp/server.py +411 -0
- python_checkup/models.py +114 -0
- python_checkup/plan.py +109 -0
- python_checkup/progress.py +95 -0
- python_checkup/runner.py +438 -0
- python_checkup/scoring/__init__.py +0 -0
- python_checkup/scoring/engine.py +397 -0
- python_checkup/skills/SKILL.md +416 -0
- python_checkup/skills/__init__.py +0 -0
- python_checkup/skills/agents.py +98 -0
- python_checkup/skills/installer.py +248 -0
- python_checkup/skills/rule_db.py +806 -0
- python_checkup/web/__init__.py +0 -0
- python_checkup/web/server.py +285 -0
- python_checkup/web/static/__init__.py +0 -0
- python_checkup/web/static/index.html +959 -0
- python_checkup/web/template.py +26 -0
- python_checkup-0.0.1.dist-info/METADATA +250 -0
- python_checkup-0.0.1.dist-info/RECORD +53 -0
- python_checkup-0.0.1.dist-info/WHEEL +4 -0
- python_checkup-0.0.1.dist-info/entry_points.txt +14 -0
- python_checkup-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Generator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.progress import (
|
|
9
|
+
BarColumn,
|
|
10
|
+
Progress,
|
|
11
|
+
SpinnerColumn,
|
|
12
|
+
TaskID,
|
|
13
|
+
TextColumn,
|
|
14
|
+
TimeElapsedColumn,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@contextmanager
|
|
19
|
+
def analysis_progress(
|
|
20
|
+
analyzer_names: list[str],
|
|
21
|
+
*,
|
|
22
|
+
quiet: bool = False,
|
|
23
|
+
) -> Generator[AnalysisTracker, None, None]:
|
|
24
|
+
"""Context manager that shows a progress display during analysis.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
analyzer_names: Names of analyzers that will run.
|
|
28
|
+
quiet: If True, suppress all output (for --score and --json).
|
|
29
|
+
"""
|
|
30
|
+
if quiet:
|
|
31
|
+
yield AnalysisTracker(progress=None, tasks={})
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
console = Console(file=sys.stderr)
|
|
35
|
+
progress = Progress(
|
|
36
|
+
SpinnerColumn(),
|
|
37
|
+
TextColumn("[bold blue]{task.description}[/bold blue]"),
|
|
38
|
+
BarColumn(bar_width=20),
|
|
39
|
+
TextColumn("[dim]{task.fields[status]}[/dim]"),
|
|
40
|
+
TimeElapsedColumn(),
|
|
41
|
+
console=console,
|
|
42
|
+
transient=True,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
with progress:
|
|
46
|
+
tasks: dict[str, TaskID] = {}
|
|
47
|
+
for name in analyzer_names:
|
|
48
|
+
task_id = progress.add_task(f" {name}", total=1, status="waiting...")
|
|
49
|
+
tasks[name] = task_id
|
|
50
|
+
|
|
51
|
+
yield AnalysisTracker(progress=progress, tasks=tasks)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AnalysisTracker:
|
|
55
|
+
"""Tracks progress of individual analyzers."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
progress: Progress | None,
|
|
60
|
+
tasks: dict[str, TaskID],
|
|
61
|
+
) -> None:
|
|
62
|
+
self._progress = progress
|
|
63
|
+
self._tasks = tasks
|
|
64
|
+
|
|
65
|
+
def start(self, analyzer_name: str) -> None:
|
|
66
|
+
if self._progress and analyzer_name in self._tasks:
|
|
67
|
+
self._progress.update(
|
|
68
|
+
self._tasks[analyzer_name],
|
|
69
|
+
status="running...",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def complete(self, analyzer_name: str, issue_count: int) -> None:
|
|
73
|
+
if self._progress and analyzer_name in self._tasks:
|
|
74
|
+
status = f"done ({issue_count} issues)" if issue_count else "done"
|
|
75
|
+
self._progress.update(
|
|
76
|
+
self._tasks[analyzer_name],
|
|
77
|
+
completed=1,
|
|
78
|
+
status=status,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def fail(self, analyzer_name: str, error: str) -> None:
|
|
82
|
+
if self._progress and analyzer_name in self._tasks:
|
|
83
|
+
self._progress.update(
|
|
84
|
+
self._tasks[analyzer_name],
|
|
85
|
+
completed=1,
|
|
86
|
+
status=f"[red]failed: {error}[/red]",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def skip(self, analyzer_name: str) -> None:
|
|
90
|
+
if self._progress and analyzer_name in self._tasks:
|
|
91
|
+
self._progress.update(
|
|
92
|
+
self._tasks[analyzer_name],
|
|
93
|
+
completed=1,
|
|
94
|
+
status="[dim]skipped[/dim]",
|
|
95
|
+
)
|
python_checkup/runner.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from python_checkup.analysis_request import AnalysisRequest
|
|
11
|
+
from python_checkup.analyzer_catalog import ANALYZER_CATALOG, get_analyzer_info
|
|
12
|
+
from python_checkup.analyzers.cached import CachedAnalyzer
|
|
13
|
+
from python_checkup.analyzers.registry import discover_analyzers
|
|
14
|
+
from python_checkup.cache import AnalysisCache
|
|
15
|
+
from python_checkup.config import CheckupConfig
|
|
16
|
+
from python_checkup.dedup import deduplicate
|
|
17
|
+
from python_checkup.detection import detect_framework
|
|
18
|
+
from python_checkup.discovery import discover_python_files
|
|
19
|
+
from python_checkup.models import (
|
|
20
|
+
Category,
|
|
21
|
+
CategoryCoverage,
|
|
22
|
+
CoverageInfo,
|
|
23
|
+
Diagnostic,
|
|
24
|
+
HealthReport,
|
|
25
|
+
ProjectInfo,
|
|
26
|
+
)
|
|
27
|
+
from python_checkup.plan import PROFILE_DEFAULT, ScanPlan, build_scan_plan
|
|
28
|
+
from python_checkup.progress import analysis_progress
|
|
29
|
+
from python_checkup.scoring.engine import compute_health_report
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from python_checkup.analyzers import Analyzer
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("python_checkup")
|
|
35
|
+
|
|
36
|
+
# Analyzers that benefit from per-file caching.
|
|
37
|
+
# mypy is excluded because it needs project-wide context
|
|
38
|
+
# (it relies on its own .mypy_cache/ instead).
|
|
39
|
+
CACHEABLE_ANALYZERS = {"ruff", "bandit", "radon", "vulture"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _detect_project_framework(
|
|
43
|
+
project_root: Path,
|
|
44
|
+
) -> tuple[str | None, str | None]:
|
|
45
|
+
"""Detect the project framework and return (name, label)."""
|
|
46
|
+
framework_info = detect_framework(project_root)
|
|
47
|
+
if not framework_info:
|
|
48
|
+
return None, None
|
|
49
|
+
name = framework_info.name
|
|
50
|
+
label = (
|
|
51
|
+
f"{framework_info.name}-{framework_info.version}"
|
|
52
|
+
if framework_info.version
|
|
53
|
+
else framework_info.name
|
|
54
|
+
)
|
|
55
|
+
return name, label
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _filter_analyzers(
|
|
59
|
+
all_analyzers: list[Analyzer],
|
|
60
|
+
skip_analyzers: set[str],
|
|
61
|
+
plan: ScanPlan,
|
|
62
|
+
cache: AnalysisCache,
|
|
63
|
+
) -> tuple[list[Analyzer | CachedAnalyzer], list[str], list[str]]:
|
|
64
|
+
"""Filter analyzers based on skip list, plan, and cache settings.
|
|
65
|
+
|
|
66
|
+
Returns (active, skipped_names, optional_unavailable).
|
|
67
|
+
"""
|
|
68
|
+
active: list[Analyzer | CachedAnalyzer] = []
|
|
69
|
+
skipped_names = [a.name for a in all_analyzers if a.name in skip_analyzers]
|
|
70
|
+
optional_unavailable: list[str] = []
|
|
71
|
+
|
|
72
|
+
for a in all_analyzers:
|
|
73
|
+
if a.name in skip_analyzers:
|
|
74
|
+
continue
|
|
75
|
+
if _should_skip_analyzer(a.name, plan, skipped_names, optional_unavailable):
|
|
76
|
+
continue
|
|
77
|
+
if a.name in CACHEABLE_ANALYZERS and cache.enabled:
|
|
78
|
+
active.append(CachedAnalyzer(a, cache))
|
|
79
|
+
else:
|
|
80
|
+
active.append(a)
|
|
81
|
+
|
|
82
|
+
return active, skipped_names, optional_unavailable
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _should_skip_analyzer(
|
|
86
|
+
name: str,
|
|
87
|
+
plan: ScanPlan,
|
|
88
|
+
skipped_names: list[str],
|
|
89
|
+
optional_unavailable: list[str],
|
|
90
|
+
) -> bool:
|
|
91
|
+
"""Check if an analyzer should be skipped based on catalog info and plan."""
|
|
92
|
+
info = get_analyzer_info(name)
|
|
93
|
+
if info is None:
|
|
94
|
+
return False
|
|
95
|
+
if not info.categories.intersection(plan.categories):
|
|
96
|
+
skipped_names.append(name)
|
|
97
|
+
return True
|
|
98
|
+
if plan.profile not in info.profiles:
|
|
99
|
+
skipped_names.append(name)
|
|
100
|
+
return True
|
|
101
|
+
if info.optional and not plan.include_optional:
|
|
102
|
+
optional_unavailable.append(name)
|
|
103
|
+
skipped_names.append(name)
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _collect_results(
|
|
109
|
+
results: list[tuple[str, list[Diagnostic] | None, dict[str, object]]],
|
|
110
|
+
) -> tuple[list[Diagnostic], list[str], list[float], list[str]]:
|
|
111
|
+
"""Collect diagnostics, used analyzers, MI scores, and failed names from results."""
|
|
112
|
+
all_diagnostics: list[Diagnostic] = []
|
|
113
|
+
analyzers_used: list[str] = []
|
|
114
|
+
mi_scores: list[float] = []
|
|
115
|
+
failed_names: list[str] = []
|
|
116
|
+
|
|
117
|
+
for name, result, cfg in results:
|
|
118
|
+
if result is not None:
|
|
119
|
+
all_diagnostics.extend(result)
|
|
120
|
+
analyzers_used.append(name)
|
|
121
|
+
if name == "radon" and "_radon_mi_scores" in cfg:
|
|
122
|
+
raw_mi = cfg["_radon_mi_scores"]
|
|
123
|
+
if isinstance(raw_mi, list):
|
|
124
|
+
mi_scores = raw_mi
|
|
125
|
+
else:
|
|
126
|
+
failed_names.append(name)
|
|
127
|
+
|
|
128
|
+
return all_diagnostics, analyzers_used, mi_scores, failed_names
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _handle_cache_stats(cache: AnalysisCache) -> dict[str, int] | None:
|
|
132
|
+
"""Collect cache statistics and run cleanup if cache is enabled."""
|
|
133
|
+
if not cache.enabled:
|
|
134
|
+
return None
|
|
135
|
+
cache_stats = cache.get_stats()
|
|
136
|
+
logger.info(
|
|
137
|
+
"Cache: %d hits, %d misses (%d%% hit rate)",
|
|
138
|
+
cache_stats["hits"],
|
|
139
|
+
cache_stats["misses"],
|
|
140
|
+
cache_stats["hit_rate_pct"],
|
|
141
|
+
)
|
|
142
|
+
cache.cleanup()
|
|
143
|
+
return cache_stats
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def run_analysis(
|
|
147
|
+
project_root: Path,
|
|
148
|
+
config: CheckupConfig,
|
|
149
|
+
files: list[Path] | None = None,
|
|
150
|
+
skip_analyzers: set[str] | None = None,
|
|
151
|
+
quiet: bool = False,
|
|
152
|
+
no_cache: bool = False,
|
|
153
|
+
plan: ScanPlan | None = None,
|
|
154
|
+
diff_base: str | None = None,
|
|
155
|
+
) -> HealthReport:
|
|
156
|
+
"""Run all available analyzers with progress feedback.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
project_root: Root directory to analyze.
|
|
160
|
+
config: Resolved configuration.
|
|
161
|
+
files: Specific files to analyze. If None, discovers all.
|
|
162
|
+
skip_analyzers: Set of analyzer names to skip.
|
|
163
|
+
quiet: Suppress progress output (for --score and --json).
|
|
164
|
+
no_cache: If True, skip the cache entirely (fresh run).
|
|
165
|
+
"""
|
|
166
|
+
start = time.monotonic()
|
|
167
|
+
skip_analyzers = skip_analyzers or set()
|
|
168
|
+
plan = plan or build_scan_plan(profile=PROFILE_DEFAULT)
|
|
169
|
+
|
|
170
|
+
if files is None:
|
|
171
|
+
files = discover_python_files(project_root, config.ignore_files)
|
|
172
|
+
|
|
173
|
+
if not files:
|
|
174
|
+
return _empty_report(start)
|
|
175
|
+
|
|
176
|
+
cache = AnalysisCache(project_root, enabled=not no_cache)
|
|
177
|
+
framework_name, framework_label = _detect_project_framework(project_root)
|
|
178
|
+
|
|
179
|
+
all_analyzers = await discover_analyzers()
|
|
180
|
+
active, skipped_names, optional_unavailable = _filter_analyzers(
|
|
181
|
+
all_analyzers, skip_analyzers, plan, cache
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if not active:
|
|
185
|
+
logger.warning("No analyzers available after filtering")
|
|
186
|
+
return _empty_report(start)
|
|
187
|
+
|
|
188
|
+
# Run with progress tracking
|
|
189
|
+
with analysis_progress([a.name for a in active], quiet=quiet) as tracker:
|
|
190
|
+
|
|
191
|
+
async def run_one(
|
|
192
|
+
analyzer: Analyzer | CachedAnalyzer,
|
|
193
|
+
request: AnalysisRequest,
|
|
194
|
+
) -> tuple[str, list[Diagnostic] | None, dict[str, object]]:
|
|
195
|
+
tracker.start(analyzer.name)
|
|
196
|
+
try:
|
|
197
|
+
result = await analyzer.analyze(request)
|
|
198
|
+
tracker.complete(analyzer.name, len(result))
|
|
199
|
+
return analyzer.name, result, request.metadata
|
|
200
|
+
except asyncio.TimeoutError:
|
|
201
|
+
tracker.fail(analyzer.name, "timed out")
|
|
202
|
+
logger.warning("%s timed out", analyzer.name)
|
|
203
|
+
return analyzer.name, None, request.metadata
|
|
204
|
+
except Exception as e:
|
|
205
|
+
error_str = str(e)[:50]
|
|
206
|
+
tracker.fail(analyzer.name, error_str)
|
|
207
|
+
logger.warning(
|
|
208
|
+
"%s failed: %s: %s",
|
|
209
|
+
analyzer.name,
|
|
210
|
+
type(e).__name__,
|
|
211
|
+
e,
|
|
212
|
+
)
|
|
213
|
+
return analyzer.name, None, request.metadata
|
|
214
|
+
|
|
215
|
+
tasks = []
|
|
216
|
+
for a in active:
|
|
217
|
+
request = AnalysisRequest(
|
|
218
|
+
project_root=project_root,
|
|
219
|
+
files=files,
|
|
220
|
+
config=config,
|
|
221
|
+
categories=set(plan.categories),
|
|
222
|
+
profile=plan.profile,
|
|
223
|
+
framework=framework_name,
|
|
224
|
+
diff_base=diff_base,
|
|
225
|
+
quiet=quiet,
|
|
226
|
+
no_cache=no_cache,
|
|
227
|
+
metadata={},
|
|
228
|
+
)
|
|
229
|
+
tasks.append(run_one(a, request))
|
|
230
|
+
results = await asyncio.gather(*tasks)
|
|
231
|
+
|
|
232
|
+
all_diagnostics, analyzers_used, mi_scores, failed_names = _collect_results(results)
|
|
233
|
+
skipped_names.extend(failed_names)
|
|
234
|
+
|
|
235
|
+
cache_stats = _handle_cache_stats(cache)
|
|
236
|
+
|
|
237
|
+
# Deduplicate (Ruff/Bandit overlap)
|
|
238
|
+
all_diagnostics = deduplicate(all_diagnostics)
|
|
239
|
+
|
|
240
|
+
if config.ignore_rules:
|
|
241
|
+
all_diagnostics = [
|
|
242
|
+
d for d in all_diagnostics if d.rule_id not in config.ignore_rules
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
total_lines = sum(_count_lines(f) for f in files)
|
|
246
|
+
project = ProjectInfo(
|
|
247
|
+
python_version=_detect_python_version(),
|
|
248
|
+
framework=framework_label,
|
|
249
|
+
total_files=len(files),
|
|
250
|
+
total_lines=total_lines,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
254
|
+
|
|
255
|
+
coverage = _build_coverage(
|
|
256
|
+
plan=plan,
|
|
257
|
+
analyzers_used=analyzers_used,
|
|
258
|
+
analyzers_skipped=skipped_names,
|
|
259
|
+
optional_unavailable=optional_unavailable,
|
|
260
|
+
request_metadata={
|
|
261
|
+
key: value
|
|
262
|
+
for _name, _result, metadata in results
|
|
263
|
+
for key, value in metadata.items()
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return compute_health_report(
|
|
268
|
+
diagnostics=all_diagnostics,
|
|
269
|
+
project=project,
|
|
270
|
+
config=config,
|
|
271
|
+
duration_ms=duration_ms,
|
|
272
|
+
analyzers_used=analyzers_used,
|
|
273
|
+
analyzers_skipped=skipped_names,
|
|
274
|
+
mi_scores=mi_scores if mi_scores else None,
|
|
275
|
+
cache_stats=cache_stats,
|
|
276
|
+
coverage=coverage,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _count_lines(path: Path) -> int:
|
|
281
|
+
"""Count lines in a file, handling encoding errors."""
|
|
282
|
+
try:
|
|
283
|
+
return len(path.read_text(errors="ignore").splitlines())
|
|
284
|
+
except OSError:
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _detect_python_version() -> str:
|
|
289
|
+
return f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _empty_report(start: float) -> HealthReport:
|
|
293
|
+
return HealthReport(
|
|
294
|
+
score=100,
|
|
295
|
+
label="Healthy",
|
|
296
|
+
category_scores=[],
|
|
297
|
+
diagnostics=[],
|
|
298
|
+
project=ProjectInfo(
|
|
299
|
+
python_version=_detect_python_version(),
|
|
300
|
+
framework=None,
|
|
301
|
+
total_files=0,
|
|
302
|
+
total_lines=0,
|
|
303
|
+
),
|
|
304
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
305
|
+
coverage=CoverageInfo(profile=PROFILE_DEFAULT, confidence="limited"),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _categorize_coverage(
|
|
310
|
+
plan: ScanPlan,
|
|
311
|
+
used_set: set[str],
|
|
312
|
+
optional_unavailable: list[str],
|
|
313
|
+
) -> tuple[set[Category], list[CategoryCoverage], list[str]]:
|
|
314
|
+
"""Classify each planned category as scored, partial, or unavailable.
|
|
315
|
+
|
|
316
|
+
Returns (scored_categories, category_coverage, partial_reasons).
|
|
317
|
+
"""
|
|
318
|
+
scored_categories: set[Category] = set()
|
|
319
|
+
category_coverage: list[CategoryCoverage] = []
|
|
320
|
+
partial_reasons: list[str] = []
|
|
321
|
+
|
|
322
|
+
for category in sorted(plan.categories, key=lambda c: c.value):
|
|
323
|
+
category_analyzers = [
|
|
324
|
+
info.name
|
|
325
|
+
for info in ANALYZER_CATALOG.values()
|
|
326
|
+
if category in info.categories
|
|
327
|
+
and plan.profile in info.profiles
|
|
328
|
+
and (plan.include_optional or not info.optional)
|
|
329
|
+
]
|
|
330
|
+
used_for_category = [name for name in category_analyzers if name in used_set]
|
|
331
|
+
if used_for_category:
|
|
332
|
+
status, reason = _category_status(category, optional_unavailable)
|
|
333
|
+
category_coverage.append(
|
|
334
|
+
CategoryCoverage(
|
|
335
|
+
category=category,
|
|
336
|
+
status=status,
|
|
337
|
+
analyzers=used_for_category,
|
|
338
|
+
reason=reason,
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
scored_categories.add(category)
|
|
342
|
+
if reason:
|
|
343
|
+
partial_reasons.append(f"{category.value}: {reason}")
|
|
344
|
+
else:
|
|
345
|
+
category_coverage.append(
|
|
346
|
+
CategoryCoverage(
|
|
347
|
+
category=category,
|
|
348
|
+
status="unavailable",
|
|
349
|
+
analyzers=[],
|
|
350
|
+
reason="no analyzer ran for this category",
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# Add entries for categories explicitly skipped by the user
|
|
355
|
+
for category in sorted(plan.skipped_categories, key=lambda c: c.value):
|
|
356
|
+
category_coverage.append(
|
|
357
|
+
CategoryCoverage(
|
|
358
|
+
category=category,
|
|
359
|
+
status="skipped_by_user",
|
|
360
|
+
analyzers=[],
|
|
361
|
+
reason="excluded via --skip",
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
return scored_categories, category_coverage, partial_reasons
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _category_status(
|
|
369
|
+
category: Category,
|
|
370
|
+
optional_unavailable: list[str],
|
|
371
|
+
) -> tuple[str, str]:
|
|
372
|
+
"""Return (status, reason) for a category that has at least one active analyzer."""
|
|
373
|
+
if category == Category.SECURITY and "detect-secrets" in optional_unavailable:
|
|
374
|
+
return "partial", "optional secret scan not installed"
|
|
375
|
+
if category == Category.DEPENDENCIES and "dependency-vulns" in optional_unavailable:
|
|
376
|
+
return "partial", "optional dependency vulnerability scan not installed"
|
|
377
|
+
return "scored", ""
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _build_provenance(request_metadata: dict[str, object]) -> list[str]:
|
|
381
|
+
"""Build provenance notes from request metadata."""
|
|
382
|
+
provenance: list[str] = []
|
|
383
|
+
dep_source = request_metadata.get("dependency_vulns_source")
|
|
384
|
+
dep_count = request_metadata.get("dependency_vulns_package_count")
|
|
385
|
+
dep_note = request_metadata.get("dependency_vulns_note")
|
|
386
|
+
if isinstance(dep_source, str) and isinstance(dep_count, int):
|
|
387
|
+
provenance.append(
|
|
388
|
+
f"Dependency vulnerabilities: scanned from {dep_source} "
|
|
389
|
+
f"({dep_count} packages)"
|
|
390
|
+
)
|
|
391
|
+
elif isinstance(dep_note, str):
|
|
392
|
+
provenance.append(f"Dependency vulnerabilities: skipped ({dep_note})")
|
|
393
|
+
return provenance
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _determine_confidence(
|
|
397
|
+
partial_reasons: list[str],
|
|
398
|
+
scored_categories: set[Category],
|
|
399
|
+
plan: ScanPlan,
|
|
400
|
+
) -> str:
|
|
401
|
+
"""Determine the confidence level based on coverage gaps."""
|
|
402
|
+
if len(scored_categories) < len(plan.categories):
|
|
403
|
+
return "limited"
|
|
404
|
+
if partial_reasons:
|
|
405
|
+
return "partial"
|
|
406
|
+
return "full"
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _build_coverage(
|
|
410
|
+
*,
|
|
411
|
+
plan: ScanPlan,
|
|
412
|
+
analyzers_used: list[str],
|
|
413
|
+
analyzers_skipped: list[str],
|
|
414
|
+
optional_unavailable: list[str],
|
|
415
|
+
request_metadata: dict[str, object] | None = None,
|
|
416
|
+
) -> CoverageInfo:
|
|
417
|
+
used_set = set(analyzers_used)
|
|
418
|
+
request_metadata = request_metadata or {}
|
|
419
|
+
|
|
420
|
+
scored_categories, category_coverage, partial_reasons = _categorize_coverage(
|
|
421
|
+
plan, used_set, optional_unavailable
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
confidence = _determine_confidence(partial_reasons, scored_categories, plan)
|
|
425
|
+
provenance = _build_provenance(request_metadata)
|
|
426
|
+
|
|
427
|
+
return CoverageInfo(
|
|
428
|
+
profile=plan.profile,
|
|
429
|
+
confidence=confidence,
|
|
430
|
+
requested_categories=sorted(plan.categories, key=lambda c: c.value),
|
|
431
|
+
scored_categories=sorted(scored_categories, key=lambda c: c.value),
|
|
432
|
+
category_coverage=category_coverage,
|
|
433
|
+
analyzers_used=analyzers_used,
|
|
434
|
+
analyzers_missing=[],
|
|
435
|
+
analyzers_optional_unavailable=optional_unavailable,
|
|
436
|
+
partial_reasons=partial_reasons,
|
|
437
|
+
provenance=provenance,
|
|
438
|
+
)
|
|
File without changes
|